Files
OpenMesh/coworker/skills/base.py
zhaolei 3e356b117a
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
```
feat(agent): 添加技能自动路由功能
- 引入 SkillRouter 实现根据用户消息自动推荐技能
- 在构建引擎时集成技能路由器
- 从对话历史中提取最后一条用户消息作为路由输入
- 为技能添加触发关键词和中文标题字段支持

refactor(browser): 重构浏览器自动化为子进程架构

- 将 Playwright 浏览器控制移至独立的子进程 worker
- 解决 PyInstaller 打包环境下 C 扩展兼容性问题
- 通过 JSON RPC 协议与浏览器 worker 通信
- 添加工具目录和脚本路径查找机制

feat(skills): 增强技能元数据和UI展示

- 为技能添加 triggers 和 title 字段
- 在技能商店中包含中文标题信息
- 添加 office-viz 技能优先级排序
- 在服务器管理器中返回技能标题

feat(gui): 实现技能选择器UI组件

- 添加带下拉菜单的技能选择器按钮
- 支持中文标题和拼音首字母显示
- 集成会话技能加载和状态管理
- 提供通用技能选项和已启用技能列表
```
2026-09-14 17:36:02 +08:00

180 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Skill loading — Anthropic SKILL.md format with progressive disclosure.
A skill is a folder containing `SKILL.md` (YAML frontmatter: name, description,
optional allowed-tools) + a markdown body of instructions + optional resources/scripts.
Progressive disclosure: at session start only the catalog (name + description) is injected
into the agent's context; the full body is loaded on demand via the `load_skill` tool.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional, Union
import aisuite as ai
@dataclass
class Skill:
name: str
description: str
instructions: str = "" # full body — loaded on demand
path: Optional[str] = None
allowed_tools: list[str] = field(default_factory=list)
triggers: list[str] = field(default_factory=list) # 触发关键词,用于自动路由
title: str = "" # 中文标题,用于 UI 显示(如 "办公可视化看板"
class SkillLoader:
def __init__(self, dirs: list[str | Path]) -> None:
self._dirs = [Path(d) for d in dirs]
self._skills: dict[str, Skill] = {}
self.rescan()
def rescan(self) -> None:
"""Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER
the session's engine was built is still loadable (the catalog line stays static
until the next session, but an explicitly requested skill must not 404)."""
self._skills = {}
for directory in self._dirs:
self._discover(directory)
def _discover(self, directory: Path) -> None:
if not directory.is_dir():
return
for sub in sorted(directory.iterdir()):
md = sub / "SKILL.md"
if md.is_file():
skill = _parse_skill(md)
self._skills[skill.name] = skill
def names(self) -> list[str]:
return list(self._skills)
def get(self, name: str) -> Optional[Skill]:
return self._skills.get(name)
def catalog(self) -> list[dict]:
# 按优先级排序office-viz 排最前(数据分析可视化优先),其余按字母顺序
def sort_key(s: Skill) -> tuple[int, str]:
if s.name == "office-viz":
return (0, "")
return (1, s.name)
return [
{"name": s.name, "description": s.description, "title": s.title}
for s in sorted(self._skills.values(), key=sort_key)
]
def _parse_skill(md: Path) -> Skill:
text = md.read_text(encoding="utf-8")
name, description, allowed, body = md.parent.name, "", [], text
triggers: list[str] = []
title: str = ""
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
frontmatter = text[3:end]
body = text[end + 4 :].lstrip("\n")
for line in frontmatter.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key, value = key.strip().lower(), value.strip()
if key == "name" and value:
name = value
elif key == "description":
description = value
elif key in ("allowed-tools", "allowed_tools"):
allowed = [t.strip() for t in value.split(",") if t.strip()]
elif key == "title":
title = value
elif key in ("triggers", "trigger-keywords", "trigger_keywords"):
# 支持逗号分隔: triggers: 关键词1, 关键词2, 关键词3
raw = value
# 去掉首尾可能的引号
if raw.startswith('"') and raw.endswith('"'):
raw = raw[1:-1]
elif raw.startswith("'") and raw.endswith("'"):
raw = raw[1:-1]
triggers = [t.strip() for t in raw.split(",") if t.strip()]
return Skill(
name=name,
description=description,
instructions=body.strip(),
path=str(md.parent),
allowed_tools=allowed,
triggers=triggers,
title=title,
)
def skill_catalog_text(
loader: SkillLoader, allowed: Optional[set[str]] = None
) -> str:
catalog = [
c for c in loader.catalog() if allowed is None or c["name"] in allowed
]
if not catalog:
return ""
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
# 在列表前加优先级提示
priority_notice = (
"⚠️ Skill 选择优先级说明:\n"
" • 数据分析 + 可视化 + 看板 → 使用 office-viz不要用 xlsx\n"
" • 纯文件读写(无分析) → 使用 xlsx\n"
" • PDF 操作 → 使用 pdf\n"
" • ...\n\n"
)
return (
priority_notice +
"Available skills — call load_skill(name) to load one's full instructions when "
"it's relevant to the task:\n" + "\n".join(lines)
)
AllowedSkills = Union[set, Callable[[], set], None]
def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list:
"""`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted
on every call — the manager passes one so Settings disables apply to live sessions
immediately, and skills created after the engine was built are still loadable
(loader rescans on a miss)."""
def _allowed_now() -> Optional[set]:
return allowed() if callable(allowed) else allowed
def load_skill(name: str) -> dict:
"""Load a skill's full instructions + resources path by name. Call this when a
skill from the catalog is relevant to the current task."""
skill = loader.get(name)
if skill is None:
loader.rescan() # created after this session started? pick it up now
skill = loader.get(name)
gate = _allowed_now()
if skill is None or (gate is not None and name not in gate):
available = sorted(
n for n in loader.names() if gate is None or n in gate
)
return {"error": f"unknown skill: {name}", "available": available}
return {
"name": skill.name,
"instructions": skill.instructions,
"resources_path": skill.path,
}
return [
ai.tool(
load_skill,
metadata=ai.ToolMetadata(
category="skills", risk_level="low", capabilities=["load_skill"]
),
)
]