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

View File

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

View File

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

View File

@ -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, '&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) => {
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) : [],

View File

@ -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, '&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_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();

View File

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

View File

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

View File

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

View File

@ -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个字符作为标题