feat: add read_skill tool and frontend read-skill rendering

This commit is contained in:
JOJO 2026-04-19 16:57:02 +08:00
parent 9891f68aca
commit 8991ed5084
10 changed files with 113 additions and 10 deletions

View File

@ -262,6 +262,23 @@ class MainTerminalToolsDefinitionMixin:
}
}
},
{
"type": "function",
"function": {
"name": "read_skill",
"description": "按 skill 名称读取 skills/<name>/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": {

View File

@ -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")

View File

@ -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):
"""将输入转换为整数并限制范围。"""

View File

@ -41,6 +41,7 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
"read_focus": ToolCategory(
label="阅读聚焦",
tools=[
"read_skill",
"read_file",
"vlm_analyze",
"ocr_image",

View File

@ -1,4 +1,4 @@
agent skills系统已启用以下是可用的skills含简要说明
{skills_list}
[skills_empty]暂无可用的skills[/skills_empty]
使用技能时,先用 run_command 查看对应 skills/<name>/ 目录内容,再用 read_file 阅读 SKILL.md 或相关文件获取具体指导
使用技能时,优先用 read_skill 通过技能名读取 SKILL.md需要阅读 skill 目录中的其他文件时再使用 read_file

View File

@ -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);

View File

@ -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);

View File

@ -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}`;

View File

@ -5,6 +5,7 @@ type ToolPayload = Record<string, any> | null | undefined;
const RUNNING_ANIMATIONS: Record<string, string> = {
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<string, string> = {
update_memory: '正在更新记忆...',
terminal_session: '正在管理终端会话...',
terminal_input: '调用 terminal_input',
terminal_snapshot: '正在获取终端快照...'
terminal_snapshot: '正在获取终端快照...',
read_skill: '正在读取技能...'
};
const COMPLETED_STATUS_TEXTS: Record<string, string> = {
@ -63,7 +65,8 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
update_memory: '记忆更新成功',
terminal_session: '终端操作完成',
terminal_input: '终端输入完成',
terminal_snapshot: '终端快照已返回'
terminal_snapshot: '终端快照已返回',
read_skill: '技能读取完成'
};
const LANGUAGE_CLASS_MAP: Record<string, string> = {
@ -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] || '执行完成';

View File

@ -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',