diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 1ca554e..2832218 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -832,6 +832,31 @@ async def handle_task_with_sender( videos=videos, metadata=user_message_metadata ) + try: + user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1 + except Exception: + user_message_index = -1 + skill_context_messages = getattr(web_terminal, "_skill_context_messages", None) + if not auto_user_message_event and isinstance(skill_context_messages, list): + for skill_item in skill_context_messages: + if not isinstance(skill_item, dict): + continue + skill_content = str(skill_item.get("content") or "") + if not skill_content: + continue + skill_name = str(skill_item.get("name") or "").strip() + skill_path = str(skill_item.get("path") or "").strip() + web_terminal.context_manager.add_conversation( + "user", + f"[系统通知|skill]\n{skill_content}", + metadata={ + "source": "skill", + "message_source": "skill", + "hidden": True, + "skill_name": skill_name, + "skill_path": skill_path, + } + ) if not auto_user_message_event: try: sender( @@ -847,10 +872,6 @@ async def handle_task_with_sender( ) except Exception as exc: debug_log(f"[TaskFlow] 发送 user_message 回显失败: {exc}") - try: - user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1 - except Exception: - user_message_index = -1 _prepare_hidden_versioning_baseline_for_first_input( web_terminal=web_terminal, workspace=workspace, diff --git a/server/tasks.py b/server/tasks.py index f3ca9a1..a0efede 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -5,6 +5,7 @@ import json import time import threading import uuid +import re from collections import deque from pathlib import Path from typing import Dict, Any, Optional, List @@ -18,7 +19,93 @@ from .chat_flow import run_chat_task_sync from .state import stop_flags from .utils_common import debug_log, log_conn_diag from utils.host_workspace_debug import write_host_workspace_debug -from config import DATA_DIR +from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME + + +SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n?", re.S) +SKILL_FIELD_RE = re.compile(r"^(?Pname|description)\s*:\s*(?P.*)$") + + +def _parse_skill_metadata(content: str, fallback_name: str) -> Dict[str, str]: + metadata = {"name": fallback_name, "description": ""} + match = SKILL_FRONTMATTER_RE.match(content or "") + if not match: + return metadata + for raw_line in match.group("body").splitlines(): + field = SKILL_FIELD_RE.match(raw_line.strip()) + if not field: + continue + key = field.group("key") + value = field.group("value").strip().strip('"').strip("'") + metadata[key] = value + metadata["name"] = metadata.get("name") or fallback_name + return metadata + + +def _workspace_skills_dir(workspace) -> Path: + return (Path(workspace.project_path).expanduser().resolve() / WORKSPACE_SKILLS_DIRNAME).resolve() + + +def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path: + skills_dir = _workspace_skills_dir(workspace) + target = Path(str(raw_path or "")).expanduser() + if not target.is_absolute(): + target = Path(workspace.project_path).expanduser().resolve() / target + target = target.resolve() + if target.name != "SKILL.md": + raise ValueError("skill 路径必须指向 SKILL.md") + try: + target.relative_to(skills_dir) + except ValueError as exc: + raise ValueError("skill 路径必须位于当前工作区 .agents/skills/ 内") from exc + if not target.is_file(): + raise ValueError("skill 文件不存在") + return target + + +def _list_workspace_skills(workspace) -> List[Dict[str, str]]: + skills_dir = _workspace_skills_dir(workspace) + if not skills_dir.is_dir(): + return [] + result: List[Dict[str, str]] = [] + for skill_file in sorted(skills_dir.glob("*/SKILL.md"), key=lambda p: p.parent.name.lower()): + try: + content = skill_file.read_text(encoding="utf-8") + except Exception: + continue + metadata = _parse_skill_metadata(content, skill_file.parent.name) + result.append({ + "name": metadata.get("name") or skill_file.parent.name, + "description": metadata.get("description") or "", + "path": str(skill_file.resolve()), + }) + return result + + +def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]: + if not isinstance(raw_refs, list): + return [] + messages: List[Dict[str, str]] = [] + seen_paths = set() + for item in raw_refs[:10]: + if not isinstance(item, dict): + continue + raw_path = str(item.get("path") or "").strip() + if not raw_path: + continue + skill_file = _resolve_workspace_skill_path(workspace, raw_path) + path_key = str(skill_file) + if path_key in seen_paths: + continue + seen_paths.add(path_key) + content = skill_file.read_text(encoding="utf-8") + metadata = _parse_skill_metadata(content, skill_file.parent.name) + messages.append({ + "name": metadata.get("name") or skill_file.parent.name, + "path": path_key, + "content": content, + }) + return messages class TaskRecord: @@ -120,6 +207,7 @@ class TaskManager: session_data: Optional[Dict[str, Any]] = None, message_source: Optional[str] = None, goal_mode: bool = False, + skill_context_messages: Optional[List[Dict[str, str]]] = None, ) -> TaskRecord: if run_mode: normalized = str(run_mode).lower() @@ -139,6 +227,8 @@ class TaskManager: if message_source is not None: snapshot.setdefault("message_source", str(message_source)) snapshot["goal_mode"] = bool(goal_mode) + if skill_context_messages: + snapshot["skill_context_messages"] = list(skill_context_messages) try: snapshot.setdefault("host_mode", session.get("host_mode")) if snapshot.get("host_mode"): @@ -161,6 +251,7 @@ class TaskManager: "model_key": session.get("model_key"), "message_source": str(message_source) if message_source is not None else None, "goal_mode": bool(goal_mode), + "skill_context_messages": list(skill_context_messages or []), } except Exception: record.session_data = {} @@ -653,11 +744,13 @@ class TaskManager: previous_message_source = None previous_auto_user_payload = None previous_goal_mode_requested = None + previous_skill_context_messages = None try: previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False) previous_message_source = getattr(terminal, "_current_user_message_source", None) previous_auto_user_payload = getattr(terminal, "_auto_user_message_payload", None) previous_goal_mode_requested = getattr(terminal, "_goal_mode_requested", False) + previous_skill_context_messages = getattr(terminal, "_skill_context_messages", None) setattr( terminal, "_auto_user_message_event", @@ -678,11 +771,17 @@ class TaskManager: "_goal_mode_requested", bool((rec.session_data or {}).get("goal_mode")), ) + setattr( + terminal, + "_skill_context_messages", + list((rec.session_data or {}).get("skill_context_messages") or []), + ) except Exception: previous_auto_user_event = None previous_message_source = None previous_auto_user_payload = None previous_goal_mode_requested = None + previous_skill_context_messages = None try: run_chat_task_sync( @@ -705,6 +804,10 @@ class TaskManager: setattr(terminal, "_current_user_message_source", previous_message_source) if previous_goal_mode_requested is not None: setattr(terminal, "_goal_mode_requested", previous_goal_mode_requested) + if previous_skill_context_messages is not None: + setattr(terminal, "_skill_context_messages", previous_skill_context_messages) + else: + setattr(terminal, "_skill_context_messages", []) if terminal and getattr(terminal, "context_manager", None): terminal.context_manager.set_web_terminal_callback(previous_ctx_callback) except Exception as exc: @@ -842,6 +945,24 @@ def list_tasks_api(): }) +@tasks_bp.route("/api/skills", methods=["GET"]) +@api_login_required +def list_skills_api(): + username = get_current_username() + workspace_id = session.get("workspace_id") or "default" + try: + _terminal, workspace = get_user_resources(username, workspace_id) + if not workspace: + return jsonify({"success": False, "error": "工作区不可用"}), 400 + return jsonify({ + "success": True, + "skills": _list_workspace_skills(workspace), + }) + except Exception as exc: + debug_log(f"[SkillsAPI] 列出技能失败: {exc}") + return jsonify({"success": False, "error": str(exc)}), 500 + + @tasks_bp.route("/api/tasks", methods=["POST"]) @api_login_required def create_task_api(): @@ -859,6 +980,19 @@ def create_task_api(): message_source = payload.get("message_source") max_iterations = payload.get("max_iterations") goal_mode = bool(payload.get("goal_mode")) + skill_context_messages: List[Dict[str, str]] = [] + raw_skill_refs = payload.get("skill_refs") + if raw_skill_refs: + try: + _terminal, workspace = get_user_resources(username, workspace_id) + if not workspace: + return jsonify({"success": False, "error": "工作区不可用"}), 400 + skill_context_messages = _build_skill_context_messages(workspace, raw_skill_refs) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + debug_log(f"[SkillsAPI] 读取技能上下文失败: {exc}") + return jsonify({"success": False, "error": "读取 skill 失败"}), 500 try: debug_log( "[TaskAPI] create_task payload " @@ -882,6 +1016,7 @@ def create_task_api(): max_iterations=max_iterations, message_source=message_source, goal_mode=goal_mode, + skill_context_messages=skill_context_messages, ) except RuntimeError as exc: return jsonify({"success": False, "error": str(exc)}), 409 diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index b98e6a3..d3d4c11 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -2,6 +2,26 @@ import { debugLog } from './common'; import { useModelStore } from '../../stores/model'; +const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g; + +function extractSkillRefsFromMessage(message = '') { + const refs = []; + const seen = new Set(); + const text = String(message || ''); + let match; + SKILL_MARKDOWN_LINK_RE.lastIndex = 0; + while ((match = SKILL_MARKDOWN_LINK_RE.exec(text))) { + const name = String(match[1] || '').trim().replace(/^\$/, ''); + const path = String(match[2] || '').trim(); + if (!path || seen.has(path)) { + continue; + } + seen.add(path); + refs.push({ name, path }); + } + return refs; +} + export const messageMethods = { buildRuntimeQueueSnapshotKey(messages = []) { const list = Array.isArray(messages) ? messages : []; @@ -705,6 +725,7 @@ export const messageMethods = { } const message = text; + const skillRefs = usePresetText ? [] : extractSkillRefsFromMessage(message); const wasBlank = this.isConversationBlank(); if (wasBlank) { @@ -802,6 +823,7 @@ export const messageMethods = { thinking_mode: this.thinkingMode, message_source: localMessageSource, goal_mode: startingGoalMode, + skill_refs: skillRefs, eventHandler: (event: any) => this.handleTaskEvent(event) }); diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 85f89fc..44113fa 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -16,7 +16,11 @@ {{ userHeaderLabel(msg) }}
-
{{ msg.content }}
+
{ meta.background_command_notice ); }; +const escapeUserHtml = (value: string): string => + String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g; + +function renderUserMessageContent(content: string): string { + const source = String(content || ''); + USER_SKILL_LINK_RE.lastIndex = 0; + let html = ''; + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = USER_SKILL_LINK_RE.exec(source))) { + html += escapeUserHtml(source.slice(cursor, match.index)); + html += `${escapeUserHtml(match[1] || '')}`; + cursor = match.index + match[0].length; + } + html += escapeUserHtml(source.slice(cursor)); + return html; +} +const isHiddenSkillMessage = (message: any) => { + if (!message || message.role !== 'user') { + return false; + } + const meta = message.metadata || {}; + const source = String(meta.source || meta.message_source || '') + .trim() + .toLowerCase(); + return source === 'skill' || meta.hidden === true; +}; const isEmptyAssistantMessage = (message: any) => { if (!message || message.role !== 'assistant') { return false; @@ -682,6 +720,9 @@ const filteredMessages = computed(() => { if (m?.role === 'system') { return false; } + if (isHiddenSkillMessage(m)) { + return false; + } if (isEmptyAssistantMessage(m)) { userMDebug('ChatArea.filteredMessages:drop-empty-assistant', { actions: Array.isArray(m?.actions) ? m.actions.map((a: any) => a?.type) : [], diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index a25dd9a..58adda1 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -52,6 +52,30 @@
+
+ +
+ 没有匹配的 skill +
+
+ - +
+
+ +