feat(web): add skill slash selector

This commit is contained in:
JOJO 2026-05-30 14:50:00 +08:00
parent 8a1e17b9a0
commit e220b3703a
9 changed files with 750 additions and 30 deletions

View File

@ -832,6 +832,31 @@ async def handle_task_with_sender(
videos=videos, videos=videos,
metadata=user_message_metadata 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: if not auto_user_message_event:
try: try:
sender( sender(
@ -847,10 +872,6 @@ async def handle_task_with_sender(
) )
except Exception as exc: except Exception as exc:
debug_log(f"[TaskFlow] 发送 user_message 回显失败: {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( _prepare_hidden_versioning_baseline_for_first_input(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,

View File

@ -5,6 +5,7 @@ import json
import time import time
import threading import threading
import uuid import uuid
import re
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List 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 .state import stop_flags
from .utils_common import debug_log, log_conn_diag from .utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug 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<body>.*?)\n---\s*\n?", re.S)
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
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: class TaskRecord:
@ -120,6 +207,7 @@ class TaskManager:
session_data: Optional[Dict[str, Any]] = None, session_data: Optional[Dict[str, Any]] = None,
message_source: Optional[str] = None, message_source: Optional[str] = None,
goal_mode: bool = False, goal_mode: bool = False,
skill_context_messages: Optional[List[Dict[str, str]]] = None,
) -> TaskRecord: ) -> TaskRecord:
if run_mode: if run_mode:
normalized = str(run_mode).lower() normalized = str(run_mode).lower()
@ -139,6 +227,8 @@ class TaskManager:
if message_source is not None: if message_source is not None:
snapshot.setdefault("message_source", str(message_source)) snapshot.setdefault("message_source", str(message_source))
snapshot["goal_mode"] = bool(goal_mode) snapshot["goal_mode"] = bool(goal_mode)
if skill_context_messages:
snapshot["skill_context_messages"] = list(skill_context_messages)
try: try:
snapshot.setdefault("host_mode", session.get("host_mode")) snapshot.setdefault("host_mode", session.get("host_mode"))
if snapshot.get("host_mode"): if snapshot.get("host_mode"):
@ -161,6 +251,7 @@ class TaskManager:
"model_key": session.get("model_key"), "model_key": session.get("model_key"),
"message_source": str(message_source) if message_source is not None else None, "message_source": str(message_source) if message_source is not None else None,
"goal_mode": bool(goal_mode), "goal_mode": bool(goal_mode),
"skill_context_messages": list(skill_context_messages or []),
} }
except Exception: except Exception:
record.session_data = {} record.session_data = {}
@ -653,11 +744,13 @@ class TaskManager:
previous_message_source = None previous_message_source = None
previous_auto_user_payload = None previous_auto_user_payload = None
previous_goal_mode_requested = None previous_goal_mode_requested = None
previous_skill_context_messages = None
try: try:
previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False) previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False)
previous_message_source = getattr(terminal, "_current_user_message_source", None) previous_message_source = getattr(terminal, "_current_user_message_source", None)
previous_auto_user_payload = getattr(terminal, "_auto_user_message_payload", None) previous_auto_user_payload = getattr(terminal, "_auto_user_message_payload", None)
previous_goal_mode_requested = getattr(terminal, "_goal_mode_requested", False) previous_goal_mode_requested = getattr(terminal, "_goal_mode_requested", False)
previous_skill_context_messages = getattr(terminal, "_skill_context_messages", None)
setattr( setattr(
terminal, terminal,
"_auto_user_message_event", "_auto_user_message_event",
@ -678,11 +771,17 @@ class TaskManager:
"_goal_mode_requested", "_goal_mode_requested",
bool((rec.session_data or {}).get("goal_mode")), 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: except Exception:
previous_auto_user_event = None previous_auto_user_event = None
previous_message_source = None previous_message_source = None
previous_auto_user_payload = None previous_auto_user_payload = None
previous_goal_mode_requested = None previous_goal_mode_requested = None
previous_skill_context_messages = None
try: try:
run_chat_task_sync( run_chat_task_sync(
@ -705,6 +804,10 @@ class TaskManager:
setattr(terminal, "_current_user_message_source", previous_message_source) setattr(terminal, "_current_user_message_source", previous_message_source)
if previous_goal_mode_requested is not None: if previous_goal_mode_requested is not None:
setattr(terminal, "_goal_mode_requested", previous_goal_mode_requested) 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): if terminal and getattr(terminal, "context_manager", None):
terminal.context_manager.set_web_terminal_callback(previous_ctx_callback) terminal.context_manager.set_web_terminal_callback(previous_ctx_callback)
except Exception as exc: 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"]) @tasks_bp.route("/api/tasks", methods=["POST"])
@api_login_required @api_login_required
def create_task_api(): def create_task_api():
@ -859,6 +980,19 @@ def create_task_api():
message_source = payload.get("message_source") message_source = payload.get("message_source")
max_iterations = payload.get("max_iterations") max_iterations = payload.get("max_iterations")
goal_mode = bool(payload.get("goal_mode")) 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: try:
debug_log( debug_log(
"[TaskAPI] create_task payload " "[TaskAPI] create_task payload "
@ -882,6 +1016,7 @@ def create_task_api():
max_iterations=max_iterations, max_iterations=max_iterations,
message_source=message_source, message_source=message_source,
goal_mode=goal_mode, goal_mode=goal_mode,
skill_context_messages=skill_context_messages,
) )
except RuntimeError as exc: except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 409 return jsonify({"success": False, "error": str(exc)}), 409

View File

@ -2,6 +2,26 @@
import { debugLog } from './common'; import { debugLog } from './common';
import { useModelStore } from '../../stores/model'; 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 = { export const messageMethods = {
buildRuntimeQueueSnapshotKey(messages = []) { buildRuntimeQueueSnapshotKey(messages = []) {
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
@ -705,6 +725,7 @@ export const messageMethods = {
} }
const message = text; const message = text;
const skillRefs = usePresetText ? [] : extractSkillRefsFromMessage(message);
const wasBlank = this.isConversationBlank(); const wasBlank = this.isConversationBlank();
if (wasBlank) { if (wasBlank) {
@ -802,6 +823,7 @@ export const messageMethods = {
thinking_mode: this.thinkingMode, thinking_mode: this.thinkingMode,
message_source: localMessageSource, message_source: localMessageSource,
goal_mode: startingGoalMode, goal_mode: startingGoalMode,
skill_refs: skillRefs,
eventHandler: (event: any) => this.handleTaskEvent(event) eventHandler: (event: any) => this.handleTaskEvent(event)
}); });

View File

@ -16,7 +16,11 @@
<span>{{ userHeaderLabel(msg) }}</span> <span>{{ userHeaderLabel(msg) }}</span>
</div> </div>
<div class="message-text user-bubble-text"> <div class="message-text user-bubble-text">
<div v-if="msg.content" class="bubble-text">{{ msg.content }}</div> <div
v-if="msg.content"
class="bubble-text"
v-html="renderUserMessageContent(msg.content)"
></div>
<div v-if="msg.images && msg.images.length" class="image-inline-row"> <div v-if="msg.images && msg.images.length" class="image-inline-row">
<div <div
class="image-thumbnail-wrapper" class="image-thumbnail-wrapper"
@ -649,6 +653,40 @@ const isSystemAutoUserMessage = (message: any) => {
meta.background_command_notice meta.background_command_notice
); );
}; };
const escapeUserHtml = (value: string): string =>
String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 += `<span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span>`;
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) => { const isEmptyAssistantMessage = (message: any) => {
if (!message || message.role !== 'assistant') { if (!message || message.role !== 'assistant') {
return false; return false;
@ -682,6 +720,9 @@ const filteredMessages = computed(() => {
if (m?.role === 'system') { if (m?.role === 'system') {
return false; return false;
} }
if (isHiddenSkillMessage(m)) {
return false;
}
if (isEmptyAssistantMessage(m)) { if (isEmptyAssistantMessage(m)) {
userMDebug('ChatArea.filteredMessages:drop-empty-assistant', { userMDebug('ChatArea.filteredMessages:drop-empty-assistant', {
actions: Array.isArray(m?.actions) ? m.actions.map((a: any) => a?.type) : [], actions: Array.isArray(m?.actions) ? m.actions.map((a: any) => a?.type) : [],

View File

@ -52,6 +52,30 @@
</button> </button>
</div> </div>
</div> </div>
<div
v-if="skillSlashMenuOpen"
ref="skillSlashList"
class="skill-slash-menu"
role="listbox"
aria-label="可用 Skills"
>
<button
v-for="(skill, index) in filteredSkills"
:key="skill.path"
type="button"
class="skill-slash-item"
:class="{ 'skill-slash-item--active': index === skillSlashActiveIndex }"
role="option"
:aria-selected="index === skillSlashActiveIndex"
@mousedown.prevent="selectSkillSlashItem(index)"
>
<span class="skill-slash-item__name">{{ skill.name }}</span>
<span class="skill-slash-item__description">{{ skill.description }}</span>
</button>
<div v-if="!filteredSkills.length" class="skill-slash-empty">
没有匹配的 skill
</div>
</div>
<div <div
class="stadium-shell" class="stadium-shell"
ref="compactInputShell" ref="compactInputShell"
@ -110,22 +134,40 @@
> >
+ +
</button> </button>
<textarea <div class="stadium-input-rich">
:key="composerInputKey" <div
ref="stadiumInput" v-if="hasSkillMarkdownLink"
class="stadium-input" ref="richInput"
rows="1" class="stadium-input stadium-input-editor"
:value="inputMessage" contenteditable="true"
:disabled="!isConnected || inputLocked" role="textbox"
autocomplete="off" aria-multiline="true"
autocorrect="off" :data-placeholder="props.inputMessage ? '' : '输入消息... (Ctrl+Enter 发送)'"
autocapitalize="off" @input="onRichInput"
placeholder="输入消息... (Ctrl+Enter 发送)" @focus="$emit('input-focus')"
@input="onInput" @blur="onInputBlur"
@focus="$emit('input-focus')" @keydown="onRichKeydown"
@blur="$emit('input-blur')" ></div>
@keydown.enter.ctrl.prevent="$emit('send-or-stop')" <textarea
></textarea> v-else
:key="composerInputKey"
ref="stadiumInput"
class="stadium-input"
rows="1"
:value="inputMessage"
:disabled="!isConnected || inputLocked"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="输入消息... (Ctrl+Enter 发送)"
@input="onInput"
@scroll="onTextareaScroll"
@focus="$emit('input-focus')"
@blur="onInputBlur"
@keydown="onKeydown"
@keydown.enter.ctrl.prevent="$emit('send-or-stop')"
></textarea>
</div>
<button <button
type="button" type="button"
class="stadium-btn send-btn" class="stadium-btn send-btn"
@ -401,8 +443,17 @@ const inputAreaRoot = ref<HTMLElement | null>(null);
const stadiumShellOuter = ref<HTMLElement | null>(null); const stadiumShellOuter = ref<HTMLElement | null>(null);
const compactInputShell = ref<HTMLElement | null>(null); const compactInputShell = ref<HTMLElement | null>(null);
const stadiumInput = ref<HTMLTextAreaElement | null>(null); const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const richInput = ref<HTMLElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null); const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(0); const baselineComposerVisualHeight = ref<number>(0);
type SkillItem = { name: string; description: string; path: string };
const availableSkills = ref<SkillItem[]>([]);
const skillsLoaded = ref(false);
const skillsLoading = ref(false);
const skillSlashOpen = ref(false);
const skillSlashQuery = ref('');
const skillSlashActiveIndex = ref(0);
const skillSlashList = ref<HTMLElement | null>(null);
let composerResizeObserver: ResizeObserver | null = null; let composerResizeObserver: ResizeObserver | null = null;
const formatImageName = (path: string): string => { const formatImageName = (path: string): string => {
@ -435,6 +486,287 @@ const runtimeQueuedMessagesForRender = computed(() => {
})); }));
}); });
const escapeHtml = (value: string): string =>
String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g;
const hasSkillMarkdownLink = computed(() => {
SKILL_LINK_RE.lastIndex = 0;
return SKILL_LINK_RE.test(props.inputMessage || '');
});
const parseSkillSegments = (sourceValue = '') => {
const source = String(sourceValue || '');
const segments: Array<{ type: 'text' | 'skill'; text: string; raw?: string }> = [];
SKILL_LINK_RE.lastIndex = 0;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = SKILL_LINK_RE.exec(source))) {
if (match.index > cursor) {
segments.push({ type: 'text', text: source.slice(cursor, match.index) });
}
segments.push({ type: 'skill', text: match[1] || '', raw: match[0] || '' });
cursor = match.index + match[0].length;
}
if (cursor < source.length) {
segments.push({ type: 'text', text: source.slice(cursor) });
}
return segments;
};
const renderRichEditor = () => {
const el = richInput.value;
if (!el) return;
const rawValue = props.inputMessage || '';
el.innerHTML = '';
const segments = parseSkillSegments(rawValue);
segments.forEach((segment) => {
if (segment.type === 'skill') {
const span = document.createElement('span');
span.className = 'skill-md-link';
span.contentEditable = 'false';
span.dataset.skillRaw = segment.raw || '';
span.textContent = segment.text || '';
el.appendChild(span);
return;
}
el.appendChild(document.createTextNode(segment.text || ''));
});
if (!segments.length) {
el.appendChild(document.createTextNode(''));
}
};
const rawFromRichEditor = () => {
const el = richInput.value;
if (!el) return props.inputMessage || '';
const readNode = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node instanceof HTMLBRElement) {
return '\n';
}
if (node instanceof HTMLElement) {
if (node.dataset.skillRaw) {
return node.dataset.skillRaw;
}
const childText = Array.from(node.childNodes).map(readNode).join('');
if (node.tagName === 'DIV' || node.tagName === 'P') {
return `${childText}\n`;
}
return childText;
}
return '';
};
return Array.from(el.childNodes).map(readNode).join('').replace(/\n$/, '');
};
const findSlashToken = () => {
const textarea = stadiumInput.value;
const cursor = textarea?.selectionStart ?? (props.inputMessage || '').length;
const before = (props.inputMessage || '').slice(0, cursor);
const match = /(^|\s)\/([A-Za-z0-9_-]*)$/.exec(before);
if (!match) {
return null;
}
const slashStart = before.length - (match[2] || '').length - 1;
return {
start: slashStart,
end: cursor,
query: match[2] || ''
};
};
const scrollSkillSlashSelectionIntoMiddle = () => {
nextTick(() => {
const list = skillSlashList.value;
if (!list) return;
const rowHeight = 41;
const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5);
const middleOffset = Math.floor(visibleRows / 2);
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight));
list.scrollTop = target;
});
};
const filteredSkills = computed(() => {
const query = skillSlashQuery.value.trim().toLowerCase();
const list = Array.isArray(availableSkills.value) ? availableSkills.value : [];
const filtered = query
? list.filter((skill) => String(skill.name || '').toLowerCase().includes(query))
: list;
return filtered.slice(0, 100);
});
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || filteredSkills.value.length >= 0));
const loadSkills = async () => {
if (skillsLoaded.value || skillsLoading.value) {
return;
}
skillsLoading.value = true;
try {
const response = await fetch('/api/skills');
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success) {
throw new Error(data?.error || '加载 skills 失败');
}
availableSkills.value = Array.isArray(data.skills)
? data.skills
.filter((item: any) => item && item.name && item.path)
.map((item: any) => ({
name: String(item.name || ''),
description: String(item.description || ''),
path: String(item.path || '')
}))
: [];
skillsLoaded.value = true;
} catch (error) {
availableSkills.value = [];
skillsLoaded.value = false;
console.warn('[SkillSlash] 加载 skills 失败:', error);
} finally {
skillsLoading.value = false;
}
};
const refreshSkillSlashState = () => {
const token = findSlashToken();
if (!token || !props.isConnected || props.inputLocked) {
skillSlashOpen.value = false;
skillSlashQuery.value = '';
skillSlashActiveIndex.value = 0;
return;
}
skillSlashOpen.value = true;
if (skillSlashQuery.value !== token.query) {
skillSlashActiveIndex.value = 0;
}
skillSlashQuery.value = token.query;
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, filteredSkills.value.length - 1));
scrollSkillSlashSelectionIntoMiddle();
void loadSkills();
};
const selectSkillSlashItem = (index = skillSlashActiveIndex.value) => {
const token = findSlashToken();
const skill = filteredSkills.value[index];
if (!token || !skill) {
return;
}
const current = props.inputMessage || '';
const link = `[$${skill.name}](${skill.path})`;
const nextValue = `${current.slice(0, token.start)}${link}${current.slice(token.end)}`;
const nextCursor = token.start + link.length;
emit('update:input-message', nextValue);
emit('input-change');
skillSlashOpen.value = false;
skillSlashQuery.value = '';
skillSlashActiveIndex.value = 0;
nextTick(() => {
const textarea = stadiumInput.value;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
} else if (richInput.value) {
renderRichEditor();
richInput.value.focus();
const range = document.createRange();
range.selectNodeContents(richInput.value);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
adjustTextareaSize();
});
};
const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
if (event.ctrlKey && event.key === 'Enter') {
return false;
}
if (skillSlashOpen.value) {
if (event.key === 'ArrowDown') {
event.preventDefault();
const count = filteredSkills.value.length;
if (count > 0) {
skillSlashActiveIndex.value = (skillSlashActiveIndex.value + 1) % count;
scrollSkillSlashSelectionIntoMiddle();
}
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
const count = filteredSkills.value.length;
if (count > 0) {
skillSlashActiveIndex.value = (skillSlashActiveIndex.value - 1 + count) % count;
scrollSkillSlashSelectionIntoMiddle();
}
return true;
}
if (event.key === 'Enter') {
const count = filteredSkills.value.length;
if (count > 0) {
event.preventDefault();
selectSkillSlashItem(skillSlashActiveIndex.value);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
skillSlashOpen.value = false;
return true;
}
}
return false;
};
const onKeydown = (event: KeyboardEvent) => {
if (handleSlashMenuKeydown(event)) {
return;
}
requestAnimationFrame(refreshSkillSlashState);
};
const onRichKeydown = (event: KeyboardEvent) => {
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault();
emit('send-or-stop');
return;
}
if (handleSlashMenuKeydown(event)) {
return;
}
requestAnimationFrame(() => {
emit('update:input-message', rawFromRichEditor());
emit('input-change');
});
};
const onRichInput = () => {
emit('update:input-message', rawFromRichEditor());
emit('input-change');
nextTick(() => {
adjustTextareaSize();
});
};
const onInputBlur = () => {
emit('input-blur');
window.setTimeout(() => {
skillSlashOpen.value = false;
}, 120);
};
const RUNTIME_QUEUE_ANIM_DURATION = 300; const RUNTIME_QUEUE_ANIM_DURATION = 300;
const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)'; const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null); const runtimeQueueList = ref<HTMLElement | null>(null);
@ -703,7 +1035,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0; const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0; const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0; const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasQueue || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value; return hasQueue || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
}); });
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done'); const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
@ -717,7 +1049,7 @@ const collectComposerVisualHeight = () => {
const shellRect = shell.getBoundingClientRect(); const shellRect = shell.getBoundingClientRect();
let top = shellRect.top; let top = shellRect.top;
let bottom = shellRect.bottom; let bottom = shellRect.bottom;
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .goal-mode-banner'); const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .skill-slash-menu, .goal-mode-banner');
nodes.forEach((node) => { nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return; if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
@ -748,18 +1080,21 @@ const emitComposerHeight = () => {
const adjustTextareaSize = () => { const adjustTextareaSize = () => {
const textarea = stadiumInput.value; const textarea = stadiumInput.value;
if (!textarea) { const rich = richInput.value;
if (!textarea && !rich) {
return; return;
} }
textarea.style.height = 'auto'; const target = textarea || rich;
const computedStyle = window.getComputedStyle(textarea); if (!target) return;
target.style.height = 'auto';
const computedStyle = window.getComputedStyle(target);
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
const maxHeight = lineHeight * 6; const maxHeight = lineHeight * 6;
const targetHeight = Math.min(textarea.scrollHeight, maxHeight); const targetHeight = Math.min(target.scrollHeight, maxHeight);
const lines = Math.max(1, Math.round(targetHeight / lineHeight)); const lines = Math.max(1, Math.round(targetHeight / lineHeight));
const multiline = targetHeight > lineHeight * 1.4; const multiline = targetHeight > lineHeight * 1.4;
applyLineMetrics(lines, multiline); applyLineMetrics(lines, multiline);
textarea.style.height = `${targetHeight}px`; target.style.height = `${targetHeight}px`;
nextTick(() => { nextTick(() => {
emitComposerHeight(); emitComposerHeight();
}); });
@ -774,6 +1109,7 @@ const onInput = (event: Event) => {
emit('update:input-message', target.value); emit('update:input-message', target.value);
emit('input-change'); emit('input-change');
adjustTextareaSize(); adjustTextareaSize();
nextTick(refreshSkillSlashState);
}; };
const onFileChange = (event: Event) => { const onFileChange = (event: Event) => {
@ -869,6 +1205,7 @@ defineExpose({
stadiumShellOuter, stadiumShellOuter,
compactInputShell, compactInputShell,
stadiumInput, stadiumInput,
richInput,
fileUploadInput fileUploadInput
}); });
@ -876,7 +1213,11 @@ watch(
() => props.inputMessage, () => props.inputMessage,
async () => { async () => {
await nextTick(); await nextTick();
if (hasSkillMarkdownLink.value && richInput.value && document.activeElement !== richInput.value) {
renderRichEditor();
}
adjustTextareaSize(); adjustTextareaSize();
refreshSkillSlashState();
} }
); );
@ -918,6 +1259,9 @@ watch(
); );
onMounted(() => { onMounted(() => {
if (hasSkillMarkdownLink.value) {
renderRichEditor();
}
adjustTextareaSize(); adjustTextareaSize();
nextTick(() => { nextTick(() => {
emitComposerHeight(); emitComposerHeight();

View File

@ -70,6 +70,7 @@ export const useTaskStore = defineStore('task', {
thinking_mode?: boolean | null; thinking_mode?: boolean | null;
message_source?: string | null; message_source?: string | null;
goal_mode?: boolean | null; goal_mode?: boolean | null;
skill_refs?: Array<{ name?: string; path: string }> | null;
eventHandler?: (event: any) => void; eventHandler?: (event: any) => void;
} = {} } = {}
) { ) {
@ -91,7 +92,8 @@ export const useTaskStore = defineStore('task', {
thinking_mode: thinking_mode:
typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined, typeof options.thinking_mode === 'boolean' ? options.thinking_mode : undefined,
message_source: options.message_source ?? undefined, message_source: options.message_source ?? undefined,
goal_mode: options.goal_mode === true ? true : undefined goal_mode: options.goal_mode === true ? true : undefined,
skill_refs: Array.isArray(options.skill_refs) ? options.skill_refs : undefined
}) })
}); });

View File

@ -496,6 +496,16 @@
align-self: flex-end; /* 确保靠右 */ align-self: flex-end; /* 确保靠右 */
} }
.user-message .message-text .user-skill-link {
color: #2563eb;
font-weight: 600;
}
:root[data-theme='dark'] .user-message .message-text .user-skill-link,
body[data-theme='dark'] .user-message .message-text .user-skill-link {
color: #60a5fa;
}
.assistant-message .message-text { .assistant-message .message-text {
background: var(--claude-highlight); background: var(--claude-highlight);
border-left: 4px solid var(--claude-accent); border-left: 4px solid var(--claude-accent);

View File

@ -122,6 +122,96 @@
pointer-events: none; pointer-events: none;
} }
.skill-slash-menu {
--skill-slash-row-height: 38px;
--skill-slash-gap: 3px;
--skill-slash-visible-rows: 5;
position: absolute;
left: 50%;
bottom: calc(100% - 2px);
transform: translateX(-50%);
z-index: 1;
width: 90%;
max-width: 90%;
box-sizing: border-box;
max-height: calc(
(var(--skill-slash-row-height) * var(--skill-slash-visible-rows)) +
(var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1))
);
overflow-y: auto;
border: 1px solid var(--claude-border);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow: none;
pointer-events: auto;
scrollbar-width: none;
padding: var(--skill-slash-gap) 7px;
display: flex;
flex-direction: column;
gap: var(--skill-slash-gap);
}
.skill-slash-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
.skill-slash-item {
width: 100%;
height: var(--skill-slash-row-height);
min-height: var(--skill-slash-row-height);
flex: 0 0 var(--skill-slash-row-height);
display: flex;
align-items: center;
gap: 10px;
border: none;
border-radius: 10px;
padding: 7px 12px;
background: transparent;
color: var(--claude-text);
text-align: left;
cursor: pointer;
}
.skill-slash-item:hover,
.skill-slash-item--active {
background: rgba(118, 103, 84, 0.12);
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.skill-slash-item__name {
flex: 0 0 auto;
max-width: 210px;
color: #2563eb;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skill-slash-item__description {
flex: 1 1 auto;
min-width: 0;
color: var(--claude-text-secondary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skill-slash-empty {
height: var(--skill-slash-row-height);
min-height: var(--skill-slash-row-height);
flex: 0 0 var(--skill-slash-row-height);
display: flex;
align-items: center;
padding: 7px 12px;
color: var(--claude-text-secondary);
font-size: 13px;
}
body[data-theme='dark'] { body[data-theme='dark'] {
.runtime-queue-list { .runtime-queue-list {
--runtime-queue-bg: #2a2a2a; --runtime-queue-bg: #2a2a2a;
@ -138,6 +228,25 @@ body[data-theme='dark'] {
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
.skill-slash-menu {
background: #2a2a2a;
border-color: var(--claude-border);
}
.skill-slash-item__name {
color: #60a5fa;
}
.skill-slash-item:hover,
.skill-slash-item--active {
background: rgba(255, 255, 255, 0.078);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
.stadium-input-editor .skill-md-link {
color: #60a5fa;
}
} }
.permission-switcher { .permission-switcher {
@ -417,6 +526,32 @@ body[data-theme='dark'] {
width: 100%; width: 100%;
} }
.stadium-input-rich {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
}
.stadium-input-rich .stadium-input {
grid-area: 1 / 1;
}
.stadium-input-editor .skill-md-link {
color: #2563eb;
font-weight: 600;
display: inline-block;
border-radius: 6px;
padding: 0 1px;
user-select: all;
}
.stadium-input-editor:empty::before {
content: attr(data-placeholder);
color: var(--claude-text-tertiary);
pointer-events: none;
}
.image-inline-row { .image-inline-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -468,6 +603,12 @@ body[data-theme='dark'] {
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
transition: none; transition: none;
position: relative;
z-index: 1;
}
.stadium-input-editor {
cursor: text;
} }
.stadium-input:disabled { .stadium-input:disabled {

View File

@ -358,6 +358,10 @@ class ConversationManager:
# 找到第一个用户消息作为标题 # 找到第一个用户消息作为标题
for msg in messages: for msg in messages:
if msg.get("role") == "user": if msg.get("role") == "user":
metadata = msg.get("metadata") or {}
source = str(metadata.get("source") or metadata.get("message_source") or "").strip().lower()
if source == "skill" or metadata.get("hidden") is True:
continue
content = msg.get("content", "").strip() content = msg.get("content", "").strip()
if content: if content:
# 取前50个字符作为标题 # 取前50个字符作为标题