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 @@
+
+
-
+