From 8991ed508429debf4813cabd02d686971145cda7 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 19 Apr 2026 16:57:02 +0800 Subject: [PATCH] feat: add read_skill tool and frontend read-skill rendering --- core/main_terminal_parts/tools_definition.py | 17 +++++ core/main_terminal_parts/tools_execution.py | 11 ++- core/main_terminal_parts/tools_read.py | 72 +++++++++++++++++++ core/tool_config.py | 1 + prompts/skills_system.txt | 2 +- .../components/chat/actions/ToolAction.vue | 2 +- .../components/chat/actions/toolRenderers.ts | 2 +- .../overlay/SubAgentActivityDialog.vue | 4 ++ static/src/utils/chatDisplay.ts | 11 +-- static/src/utils/icons.ts | 1 + 10 files changed, 113 insertions(+), 10 deletions(-) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 92e9191..4529b59 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -262,6 +262,23 @@ class MainTerminalToolsDefinitionMixin: } } }, + { + "type": "function", + "function": { + "name": "read_skill", + "description": "按 skill 名称读取 skills//SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "skill_name": { + "type": "string", + "description": "skill 名称(支持 skill id 或技能名称)" + } + }), + "required": ["skill_name"] + } + } + }, { "type": "function", "function": { diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 441aea3..f9883f1 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -113,6 +113,7 @@ class MainTerminalToolsExecutionMixin: "web_search", "extract_webpage", "read_file", + "read_skill", "view_image", "view_video", "vlm_analyze", @@ -372,7 +373,7 @@ class MainTerminalToolsExecutionMixin: return raw.lstrip("./").lower() def _mark_skill_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None: - if tool_name != "read_file": + if tool_name not in {"read_file", "read_skill"}: return if not isinstance(result, dict) or not result.get("success"): return @@ -405,7 +406,7 @@ class MainTerminalToolsExecutionMixin: return visited def _mark_file_read_from_result(self, tool_name: str, arguments: Dict[str, Any], result: Dict[str, Any]) -> None: - if tool_name != "read_file": + if tool_name not in {"read_file", "read_skill"}: return if not isinstance(result, dict) or not result.get("success"): return @@ -536,7 +537,7 @@ class MainTerminalToolsExecutionMixin: return { "success": False, "error": f"调用 {tool_name} 前必须先阅读 {required_path}", - "message": f"请先使用 read_file 阅读 {required_path},随后再调用该工具。", + "message": f"请先使用 read_skill(或 read_file)阅读 {required_path},随后再调用该工具。", "required_skill": skill_id, "required_path": required_path, "enforcement": "skill_read_required" @@ -608,6 +609,10 @@ class MainTerminalToolsExecutionMixin: result = self._handle_read_tool(arguments) self._mark_skill_read_from_result(tool_name, arguments, result) self._mark_file_read_from_result(tool_name, arguments, result) + elif tool_name == "read_skill": + result = self._handle_read_skill_tool(arguments) + self._mark_skill_read_from_result(tool_name, arguments, result) + self._mark_file_read_from_result(tool_name, arguments, result) elif tool_name in {"vlm_analyze", "ocr_image"}: path = arguments.get("path") prompt = arguments.get("prompt") diff --git a/core/main_terminal_parts/tools_read.py b/core/main_terminal_parts/tools_read.py index e018b45..c92d798 100644 --- a/core/main_terminal_parts/tools_read.py +++ b/core/main_terminal_parts/tools_read.py @@ -85,6 +85,78 @@ logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True class MainTerminalToolsReadMixin: + @staticmethod + def _normalize_skill_name(skill_name: Any) -> str: + raw = str(skill_name or "").strip() + if not raw: + return "" + normalized = raw.replace("\\", "/").strip("/") + if normalized.lower().startswith("skills/"): + normalized = normalized.split("/", 1)[1] if "/" in normalized else normalized + if normalized.lower().endswith("/skill.md"): + normalized = normalized[: -len("/SKILL.md")] + return normalized.strip() + + def _resolve_skill_id(self, skill_name: Any) -> Dict[str, Any]: + normalized_input = self._normalize_skill_name(skill_name) + if not normalized_input: + return {"success": False, "error": "skill_name 不能为空"} + + try: + personalization = load_personalization_config(self.data_dir) + except Exception: + personalization = {} + catalog = get_skills_catalog() + enabled_skills = merge_enabled_skills( + personalization.get("enabled_skills") if isinstance(personalization, dict) else None, + catalog, + personalization.get("skills_catalog_snapshot") if isinstance(personalization, dict) else None, + ) + enabled_set = set(enabled_skills or []) + filtered_catalog = [item for item in catalog if item.get("id") in enabled_set] if enabled_set else list(catalog) + + normalized_lower = normalized_input.lower() + + # 1) 优先按 skill id 精确匹配(忽略大小写) + id_map = {str(item.get("id", "")).lower(): item.get("id") for item in filtered_catalog if item.get("id")} + if normalized_lower in id_map: + return {"success": True, "skill_id": id_map[normalized_lower]} + + # 2) 再按 label 匹配(忽略大小写) + label_matches: List[str] = [] + for item in filtered_catalog: + label = str(item.get("label") or "").strip().lower() + if label and label == normalized_lower and item.get("id"): + label_matches.append(item["id"]) + if len(label_matches) == 1: + return {"success": True, "skill_id": label_matches[0]} + if len(label_matches) > 1: + return { + "success": False, + "error": f"skill_name 匹配到多个技能: {', '.join(sorted(label_matches))},请改用 skill id" + } + + return {"success": False, "error": f"未找到技能: {normalized_input}"} + + def _handle_read_skill_tool(self, arguments: Dict) -> Dict: + skill_name = arguments.get("skill_name") + resolved = self._resolve_skill_id(skill_name) + if not resolved.get("success"): + return resolved + + skill_id = resolved["skill_id"] + read_args = { + "path": f"skills/{skill_id}/SKILL.md", + "type": "read", + } + result = self._handle_read_tool(read_args) + if not result.get("success"): + return result + + result["skill_id"] = skill_id + result["skill_name"] = skill_name + return result + @staticmethod def _clamp_int(value, default, min_value=None, max_value=None): """将输入转换为整数并限制范围。""" diff --git a/core/tool_config.py b/core/tool_config.py index 2ca5f08..bc12583 100644 --- a/core/tool_config.py +++ b/core/tool_config.py @@ -41,6 +41,7 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { "read_focus": ToolCategory( label="阅读聚焦", tools=[ + "read_skill", "read_file", "vlm_analyze", "ocr_image", diff --git a/prompts/skills_system.txt b/prompts/skills_system.txt index c3c98ff..9c95adf 100644 --- a/prompts/skills_system.txt +++ b/prompts/skills_system.txt @@ -1,4 +1,4 @@ agent skills系统已启用,以下是可用的skills(含简要说明) {skills_list} [skills_empty]暂无可用的skills[/skills_empty] -使用技能时,先用 run_command 查看对应 skills// 目录内容,再用 read_file 阅读 SKILL.md 或相关文件获取具体指导 +使用技能时,优先用 read_skill 通过技能名读取 SKILL.md;需要阅读 skill 目录中的其他文件时再使用 read_file diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index 55f18b1..e03d325 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -130,7 +130,7 @@ function renderEnhancedToolResult(): string { } // 阅读聚焦类 - else if (name === 'read_file') { + else if (name === 'read_file' || name === 'read_skill') { return renderReadFile(result, args); } else if (name === 'vlm_analyze') { return renderVlmAnalyze(result, args); diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 350b791..a463386 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -66,7 +66,7 @@ export function renderEnhancedToolResult( } // 阅读聚焦类 - else if (name === 'read_file') { + else if (name === 'read_file' || name === 'read_skill') { return renderReadFile(result, args); } else if (name === 'vlm_analyze') { return renderVlmAnalyze(result, args); diff --git a/static/src/components/overlay/SubAgentActivityDialog.vue b/static/src/components/overlay/SubAgentActivityDialog.vue index 73a5349..e38c376 100644 --- a/static/src/components/overlay/SubAgentActivityDialog.vue +++ b/static/src/components/overlay/SubAgentActivityDialog.vue @@ -67,6 +67,10 @@ const buildText = (entry: ActivityEntry) => { const path = args.path || args.file_path || ''; return `阅读 ${path}`; } + if (tool === 'read_skill') { + const skillName = args.skill_name || ''; + return `阅读技能 ${skillName}`; + } if (tool === 'search_workspace') { const query = args.query || args.keyword || ''; return `在工作区搜索 ${query}`; diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index 6993aac..62d3245 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -5,6 +5,7 @@ type ToolPayload = Record | null | undefined; const RUNNING_ANIMATIONS: Record = { create_file: 'file-animation', read_file: 'read-animation', + read_skill: 'read-animation', delete_file: 'file-animation', rename_file: 'file-animation', write_file: 'file-animation', @@ -43,7 +44,8 @@ const RUNNING_STATUS_TEXTS: Record = { update_memory: '正在更新记忆...', terminal_session: '正在管理终端会话...', terminal_input: '调用 terminal_input', - terminal_snapshot: '正在获取终端快照...' + terminal_snapshot: '正在获取终端快照...', + read_skill: '正在读取技能...' }; const COMPLETED_STATUS_TEXTS: Record = { @@ -63,7 +65,8 @@ const COMPLETED_STATUS_TEXTS: Record = { update_memory: '记忆更新成功', terminal_session: '终端操作完成', terminal_input: '终端输入完成', - terminal_snapshot: '终端快照已返回' + terminal_snapshot: '终端快照已返回', + read_skill: '技能读取完成' }; const LANGUAGE_CLASS_MAP: Record = { @@ -171,7 +174,7 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }) return `准备调用 ${tool.name}...`; } if (tool.status === 'running') { - if (tool.name === 'read_file') { + if (tool.name === 'read_file' || tool.name === 'read_skill') { const readType = String( tool.argumentSnapshot?.type || tool.arguments?.type || 'read' ).toLowerCase(); @@ -193,7 +196,7 @@ export function getToolStatusText(tool: any, opts?: { intentEnabled?: boolean }) return '等待审批...'; } if (tool.status === 'completed') { - if (tool.name === 'read_file') { + if (tool.name === 'read_file' || tool.name === 'read_skill') { return describeReadFileResult(tool); } return COMPLETED_STATUS_TEXTS[tool.name] || '执行完成'; diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index 8e5419e..33cb249 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -54,6 +54,7 @@ export const TOOL_ICON_MAP = Object.freeze({ vlm_analyze: 'camera', ocr_image: 'camera', read_file: 'book', + read_skill: 'book', rename_file: 'pencil', run_command: 'terminal', run_python: 'python',