feat(web): add skill slash selector
This commit is contained in:
parent
8a1e17b9a0
commit
e220b3703a
@ -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,
|
||||
|
||||
137
server/tasks.py
137
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<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:
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
});
|
||||
|
||||
|
||||
@ -16,7 +16,11 @@
|
||||
<span>{{ userHeaderLabel(msg) }}</span>
|
||||
</div>
|
||||
<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
|
||||
class="image-thumbnail-wrapper"
|
||||
@ -649,6 +653,40 @@ const isSystemAutoUserMessage = (message: any) => {
|
||||
meta.background_command_notice
|
||||
);
|
||||
};
|
||||
const escapeUserHtml = (value: string): string =>
|
||||
String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.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 += `<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) => {
|
||||
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) : [],
|
||||
|
||||
@ -52,6 +52,30 @@
|
||||
</button>
|
||||
</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
|
||||
class="stadium-shell"
|
||||
ref="compactInputShell"
|
||||
@ -110,22 +134,40 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<textarea
|
||||
: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"
|
||||
@focus="$emit('input-focus')"
|
||||
@blur="$emit('input-blur')"
|
||||
@keydown.enter.ctrl.prevent="$emit('send-or-stop')"
|
||||
></textarea>
|
||||
<div class="stadium-input-rich">
|
||||
<div
|
||||
v-if="hasSkillMarkdownLink"
|
||||
ref="richInput"
|
||||
class="stadium-input stadium-input-editor"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
:data-placeholder="props.inputMessage ? '' : '输入消息... (Ctrl+Enter 发送)'"
|
||||
@input="onRichInput"
|
||||
@focus="$emit('input-focus')"
|
||||
@blur="onInputBlur"
|
||||
@keydown="onRichKeydown"
|
||||
></div>
|
||||
<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
|
||||
type="button"
|
||||
class="stadium-btn send-btn"
|
||||
@ -401,8 +443,17 @@ const inputAreaRoot = ref<HTMLElement | null>(null);
|
||||
const stadiumShellOuter = ref<HTMLElement | null>(null);
|
||||
const compactInputShell = ref<HTMLElement | null>(null);
|
||||
const stadiumInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const richInput = ref<HTMLElement | null>(null);
|
||||
const fileUploadInput = ref<HTMLInputElement | null>(null);
|
||||
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;
|
||||
|
||||
const formatImageName = (path: string): string => {
|
||||
@ -435,6 +486,287 @@ const runtimeQueuedMessagesForRender = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
|
||||
const runtimeQueueList = ref<HTMLElement | null>(null);
|
||||
@ -703,7 +1035,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
|
||||
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
|
||||
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.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');
|
||||
@ -717,7 +1049,7 @@ const collectComposerVisualHeight = () => {
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
let top = shellRect.top;
|
||||
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) => {
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
@ -748,18 +1080,21 @@ const emitComposerHeight = () => {
|
||||
|
||||
const adjustTextareaSize = () => {
|
||||
const textarea = stadiumInput.value;
|
||||
if (!textarea) {
|
||||
const rich = richInput.value;
|
||||
if (!textarea && !rich) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = 'auto';
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const target = textarea || rich;
|
||||
if (!target) return;
|
||||
target.style.height = 'auto';
|
||||
const computedStyle = window.getComputedStyle(target);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20;
|
||||
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 multiline = targetHeight > lineHeight * 1.4;
|
||||
applyLineMetrics(lines, multiline);
|
||||
textarea.style.height = `${targetHeight}px`;
|
||||
target.style.height = `${targetHeight}px`;
|
||||
nextTick(() => {
|
||||
emitComposerHeight();
|
||||
});
|
||||
@ -774,6 +1109,7 @@ const onInput = (event: Event) => {
|
||||
emit('update:input-message', target.value);
|
||||
emit('input-change');
|
||||
adjustTextareaSize();
|
||||
nextTick(refreshSkillSlashState);
|
||||
};
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
@ -869,6 +1205,7 @@ defineExpose({
|
||||
stadiumShellOuter,
|
||||
compactInputShell,
|
||||
stadiumInput,
|
||||
richInput,
|
||||
fileUploadInput
|
||||
});
|
||||
|
||||
@ -876,7 +1213,11 @@ watch(
|
||||
() => props.inputMessage,
|
||||
async () => {
|
||||
await nextTick();
|
||||
if (hasSkillMarkdownLink.value && richInput.value && document.activeElement !== richInput.value) {
|
||||
renderRichEditor();
|
||||
}
|
||||
adjustTextareaSize();
|
||||
refreshSkillSlashState();
|
||||
}
|
||||
);
|
||||
|
||||
@ -918,6 +1259,9 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (hasSkillMarkdownLink.value) {
|
||||
renderRichEditor();
|
||||
}
|
||||
adjustTextareaSize();
|
||||
nextTick(() => {
|
||||
emitComposerHeight();
|
||||
|
||||
@ -70,6 +70,7 @@ export const useTaskStore = defineStore('task', {
|
||||
thinking_mode?: boolean | null;
|
||||
message_source?: string | null;
|
||||
goal_mode?: boolean | null;
|
||||
skill_refs?: Array<{ name?: string; path: string }> | null;
|
||||
eventHandler?: (event: any) => void;
|
||||
} = {}
|
||||
) {
|
||||
@ -91,7 +92,8 @@ export const useTaskStore = defineStore('task', {
|
||||
thinking_mode:
|
||||
typeof options.thinking_mode === 'boolean' ? options.thinking_mode : 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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@ -496,6 +496,16 @@
|
||||
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 {
|
||||
background: var(--claude-highlight);
|
||||
border-left: 4px solid var(--claude-accent);
|
||||
|
||||
@ -122,6 +122,96 @@
|
||||
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'] {
|
||||
.runtime-queue-list {
|
||||
--runtime-queue-bg: #2a2a2a;
|
||||
@ -138,6 +228,25 @@ body[data-theme='dark'] {
|
||||
border: 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 {
|
||||
@ -417,6 +526,32 @@ body[data-theme='dark'] {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -468,6 +603,12 @@ body[data-theme='dark'] {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
transition: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stadium-input-editor {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.stadium-input:disabled {
|
||||
|
||||
@ -358,6 +358,10 @@ class ConversationManager:
|
||||
# 找到第一个用户消息作为标题
|
||||
for msg in messages:
|
||||
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()
|
||||
if content:
|
||||
# 取前50个字符作为标题
|
||||
|
||||
Loading…
Reference in New Issue
Block a user