feat(input): support file mentions

This commit is contained in:
JOJO 2026-07-04 22:58:58 +08:00
parent ae5676cd30
commit 359caca672
7 changed files with 1785 additions and 976 deletions

View File

@ -1,10 +1,11 @@
"""文件与GUI文件管理相关路由。""" """文件与GUI文件管理相关路由。"""
from __future__ import annotations from __future__ import annotations
import os import os
import re
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any, List, Optional, Tuple
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@ -311,4 +312,145 @@ def gui_text_entry(terminal, workspace, username):
return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": False, "error": str(exc)}), 400
def _score_file_match(path: str, query: str) -> Optional[Tuple[int, ...]]:
"""为 @文件 搜索计算匹配分数分数越低越靠前None 表示不匹配。"""
path_lower = path.lower()
query_lower = query.lower()
if path_lower.startswith(query_lower):
return (0, len(path))
query_parts = query_lower.split('/')
path_parts = path_lower.split('/')
if len(query_parts) > 1 and len(path_parts) >= len(query_parts):
matched = True
for i, q in enumerate(query_parts):
p = path_parts[i]
if not p.startswith(q) and q not in p:
matched = False
break
if matched:
return (1, len(path))
name = path_parts[-1] if path_parts else path_lower
if name.startswith(query_lower):
return (2, len(path))
if query_lower in name:
return (3, len(path))
if query_lower in path_lower:
return (4, len(path))
return None
def _scan_project_entries(project_path: Path, max_depth: int = 6) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""扫描项目目录,返回文件和文件夹列表(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
for root, dirs, filenames in os.walk(project_path):
rel_root = Path(root).relative_to(project_path)
level = len(rel_root.parts) if str(rel_root) != '.' else 0
if level > max_depth:
del dirs[:]
continue
for d in list(dirs):
dir_path = rel_root / d if str(rel_root) != '.' else Path(d)
folders.append({
"name": d,
"path": str(dir_path).replace('\\', '/'),
"type": "directory"
})
for f in filenames:
file_path = rel_root / f if str(rel_root) != '.' else Path(f)
ext = Path(f).suffix.lower()
files.append({
"name": f,
"path": str(file_path).replace('\\', '/'),
"type": "file",
"extension": ext
})
return files, folders
def _scan_root_entries(project_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""只扫描项目根目录的直接子项(包含隐藏目录)。"""
files: List[Dict[str, Any]] = []
folders: List[Dict[str, Any]] = []
if not project_path.exists() or not project_path.is_dir():
return files, folders
for entry in sorted(project_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
if entry.is_dir():
folders.append({
"name": entry.name,
"path": entry.name,
"type": "directory"
})
elif entry.is_file():
files.append({
"name": entry.name,
"path": entry.name,
"type": "file",
"extension": entry.suffix.lower()
})
return files, folders
@files_bp.route('/api/project/files/search', methods=['GET'])
@api_login_required
@with_terminal
def search_project_files(terminal, workspace, username):
"""为 @文件 功能提供项目内文件/文件夹搜索(包含隐藏目录)。"""
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
query = str(request.args.get('q') or '').strip()
try:
project_path = Path(getattr(workspace, 'project_path', '') or '').expanduser().resolve()
if not project_path.exists():
return jsonify({"success": False, "error": "项目路径不存在"}), 400
except Exception as exc:
return jsonify({"success": False, "error": f"项目路径无效: {exc}"}), 400
try:
files, folders = _scan_project_entries(project_path, max_depth=6)
except Exception as exc:
return jsonify({"success": False, "error": f"扫描项目失败: {exc}"}), 500
if not query:
root_files, root_folders = _scan_root_entries(project_path)
root_items = root_folders + root_files
return jsonify({
"success": True,
"data": {
"items": root_items[:50],
"total": len(root_items)
}
})
scored: List[Tuple[Tuple[int, ...], Dict[str, Any]]] = []
for entry in files + folders:
score = _score_file_match(entry["path"], query)
if score is not None:
scored.append((score, entry))
scored.sort(key=lambda x: (x[0], x[1]["path"].lower()))
limit = max(10, min(100, int(request.args.get('limit') or 50)))
return jsonify({
"success": True,
"data": {
"items": [entry for _, entry in scored[:limit]],
"total": len(scored)
}
})
__all__ = ["files_bp"] __all__ = ["files_bp"]

View File

@ -295,6 +295,7 @@
:block-tool-toggle="policyUiBlocks.block_tool_toggle" :block-tool-toggle="policyUiBlocks.block_tool_toggle"
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal" :block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
:terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0" :terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0"
:host-mode="versioningHostMode"
:block-focus-panel="policyUiBlocks.block_focus_panel" :block-focus-panel="policyUiBlocks.block_focus_panel"
:block-token-panel="policyUiBlocks.block_token_panel" :block-token-panel="policyUiBlocks.block_token_panel"
:block-compress-conversation="policyUiBlocks.block_compress_conversation" :block-compress-conversation="policyUiBlocks.block_compress_conversation"

View File

@ -933,16 +933,35 @@ const escapeUserHtml = (value: string): string =>
.replace(/'/g, '''); .replace(/'/g, ''');
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g; const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g;
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;
const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/;
const RENDERABLE_ACTION_TYPES = new Set([
'thinking',
'text',
'tool',
'append',
'append_payload',
'system'
]);
function renderUserMessageContent(content: string): string { function renderUserMessageContent(content: string): string {
const source = String(content || ''); const source = String(content || '');
USER_SKILL_LINK_RE.lastIndex = 0; const combinedRe = new RegExp(
`(?:${USER_SKILL_LINK_RE.source})|(?:${USER_FILE_LINK_RE.source})`,
'g'
);
let html = ''; let html = '';
let cursor = 0; let cursor = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = USER_SKILL_LINK_RE.exec(source))) { while ((match = combinedRe.exec(source))) {
html += escapeUserHtml(source.slice(cursor, match.index)); html += escapeUserHtml(source.slice(cursor, match.index));
html += ` <span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span> `; if (match[1] !== undefined) {
html += ` <span class="user-skill-link">${escapeUserHtml(match[1] || '')}</span> `;
} else if (match[3] !== undefined) {
html += ` <span class="user-file-link">${escapeUserHtml(match[3] || '')}</span> `;
}
cursor = match.index + match[0].length; cursor = match.index + match[0].length;
} }
html += escapeUserHtml(source.slice(cursor)); html += escapeUserHtml(source.slice(cursor));
@ -1040,17 +1059,6 @@ watch(
statusAvatarActiveIndex.value = 0; statusAvatarActiveIndex.value = 0;
} }
); );
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;
const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/;
const RENDERABLE_ACTION_TYPES = new Set([
'thinking',
'text',
'tool',
'append',
'append_payload',
'system'
]);
const nowMs = ref(Date.now()); const nowMs = ref(Date.now());
let timerHandle: number | null = null; let timerHandle: number | null = null;
const debugLoggedSystemActionKeys = new Set<string>(); const debugLoggedSystemActionKeys = new Set<string>();

View File

@ -0,0 +1,234 @@
<template>
<transition name="file-at-menu-motion">
<div
v-if="visible"
class="file-at-menu-wrapper"
role="listbox"
aria-label="文件引用"
:style="menuStyle"
@click.stop
>
<div ref="fileAtList" class="file-at-menu">
<button
v-if="hostMode"
type="button"
class="file-at-item file-at-item--picker"
:class="{ 'file-at-item--active': activeIndex === 0 }"
role="option"
:aria-selected="activeIndex === 0"
@mouseenter="$emit('hover', 0)"
@mousedown.prevent="$emit('select', 0)"
>
<span class="file-at-item__name">在文件管理器中选中</span>
<span class="file-at-item__description">选择本地文件</span>
</button>
<button
v-for="(item, index) in fileItems"
:key="item.path"
type="button"
class="file-at-item"
:class="{ 'file-at-item--active': displayIndex(index) === activeIndex }"
role="option"
:aria-selected="displayIndex(index) === activeIndex"
@mouseenter="$emit('hover', displayIndex(index))"
@mousedown.prevent="$emit('select', displayIndex(index))"
>
<span class="file-at-item__name">{{ item.name }}</span>
<span class="file-at-item__description">{{ item.path }}</span>
</button>
<div v-if="!hostMode && !fileItems.length && !loading" class="file-at-empty">
没有匹配的文件
</div>
<div v-if="loading" class="file-at-empty">搜索中...</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
export interface FileAtItem {
name: string;
path: string;
type: 'file' | 'directory';
extension?: string;
}
const props = defineProps<{
visible: boolean;
items: FileAtItem[];
activeIndex: number;
loading: boolean;
hostMode: boolean;
menuStyle: Record<string, string>;
}>();
defineEmits<{
(e: 'select', index: number): void;
(e: 'hover', index: number): void;
}>();
const fileAtList = ref<HTMLElement | null>(null);
const hasPicker = computed(() => props.hostMode);
const fileItems = computed(() => props.items || []);
const displayIndex = (fileIndex: number) => (hasPicker.value ? fileIndex + 1 : fileIndex);
watch(
() => props.visible,
() => {
if (props.visible) {
nextTick(() => {
const list = fileAtList.value;
if (list) list.scrollTop = 0;
});
}
}
);
const scrollActiveIntoView = (index: number) => {
const list = fileAtList.value;
if (!list) return;
const rowHeight = 31;
const targetTop = index * rowHeight;
const targetBottom = targetTop + rowHeight;
if (targetTop < list.scrollTop) {
list.scrollTop = targetTop;
} else if (targetBottom > list.scrollTop + list.clientHeight) {
list.scrollTop = targetBottom - list.clientHeight;
}
};
const focusActive = () => {
nextTick(() => {
scrollActiveIntoView(props.activeIndex);
});
};
const publicApi = {
focusActive,
setManualScroll: () => {}
};
defineExpose(publicApi);
</script>
<style scoped>
.file-at-menu-wrapper {
--file-at-row-height: 28px;
--file-at-gap: 3px;
--file-at-radius: 9px;
--file-at-pad-x: 4px;
--file-at-pad-y: 4px;
position: fixed;
z-index: 1000;
min-width: 240px;
max-width: min(840px, calc(100vw - 24px));
max-height: calc(
((var(--file-at-row-height) * 7) + (var(--file-at-gap) * 8) + (var(--file-at-pad-y) * 2)) * 1.5
);
border: 1px solid var(--claude-border);
border-radius: var(--file-at-radius);
background: var(--surface-soft);
box-shadow: 0 8px 24px var(--shadow-color);
pointer-events: auto;
overflow: hidden;
}
.file-at-menu {
height: 100%;
max-height: inherit;
overflow-y: auto;
scrollbar-width: none;
padding: var(--file-at-pad-y) var(--file-at-pad-x) 6px;
display: flex;
flex-direction: column;
gap: var(--file-at-gap);
}
.file-at-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
.file-at-item {
width: 100%;
height: var(--file-at-row-height);
min-height: var(--file-at-row-height);
flex: 0 0 var(--file-at-row-height);
display: flex;
align-items: center;
gap: 10px;
border: none;
border-radius: 7px;
padding: 3px 9px;
background: transparent;
color: var(--claude-text);
text-align: left;
cursor: pointer;
}
.file-at-item:first-child {
border-top-left-radius: calc(var(--file-at-radius) - var(--file-at-gap));
border-top-right-radius: calc(var(--file-at-radius) - var(--file-at-gap));
}
.file-at-item:hover,
.file-at-item--active {
background: var(--hover-bg);
box-shadow: 0 1px 2px var(--shadow-color);
}
.file-at-item__name {
flex: 0 1 auto;
min-width: 0;
color: var(--claude-text);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-at-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;
}
.file-at-empty {
height: var(--file-at-row-height);
min-height: var(--file-at-row-height);
flex: 0 0 var(--file-at-row-height);
display: flex;
align-items: center;
padding: 3px 9px;
color: var(--claude-text-secondary);
font-size: 11px;
}
body[data-theme='dark'] .file-at-menu-wrapper {
background: var(--badge-bg);
border-color: var(--claude-border);
}
.file-at-menu-motion-enter-active,
.file-at-menu-motion-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
transform-origin: bottom left;
}
.file-at-menu-motion-enter-from,
.file-at-menu-motion-leave-to {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
</style>

View File

@ -47,11 +47,7 @@
role="listbox" role="listbox"
:aria-label="slashMenuAriaLabel" :aria-label="slashMenuAriaLabel"
> >
<div <div ref="skillSlashList" class="skill-slash-menu" @scroll="onSlashScroll">
ref="skillSlashList"
class="skill-slash-menu"
@scroll="onSlashScroll"
>
<button <button
v-for="(item, index) in activeSlashMenuItems" v-for="(item, index) in activeSlashMenuItems"
:key="item.id" :key="item.id"
@ -73,24 +69,30 @@
{{ slashMenuEmptyText }} {{ slashMenuEmptyText }}
</div> </div>
</div> </div>
<div <div class="skill-slash-menu__highlight" :style="slashHighlightStyle" />
class="skill-slash-menu__highlight"
:style="slashHighlightStyle"
/>
</div> </div>
</transition> </transition>
<FileAtMenu
ref="fileAtMenuRef"
:visible="fileAtOpen"
:items="fileAtItems"
:active-index="fileAtActiveIndex"
:loading="fileAtLoading"
:host-mode="!!props.hostMode"
:menu-style="fileAtMenuStyle"
@select="selectFileAtItemByIndex"
@hover="fileAtActiveIndex = $event"
/>
<transition name="floating-status-motion"> <transition name="floating-status-motion">
<div <div v-if="floatingStatusVisible" class="floating-project-status" @click.stop>
v-if="floatingStatusVisible"
class="floating-project-status"
@click.stop
>
<span v-if="projectGitSummaryForRender" class="floating-project-status__left"> <span v-if="projectGitSummaryForRender" class="floating-project-status__left">
<span class="floating-project-status__menu-wrap"> <span class="floating-project-status__menu-wrap">
<button <button
type="button" type="button"
class="floating-project-status__button floating-project-status__button--project" class="floating-project-status__button floating-project-status__button--project"
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'project' }" :class="{
'floating-project-status__button--active': projectGitMenuOpen === 'project'
}"
@click.stop="toggleProjectGitMenu('project')" @click.stop="toggleProjectGitMenu('project')"
> >
{{ projectGitSummaryForRender.projectName }} {{ projectGitSummaryForRender.projectName }}
@ -100,7 +102,9 @@
class="floating-project-status__submenu floating-project-status__submenu--project" class="floating-project-status__submenu floating-project-status__submenu--project"
@click.stop @click.stop
> >
<button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button> <button type="button" @click.stop="openProjectInFileManager">
在文件管理器中打开
</button>
<button type="button" @click.stop="copyProjectPath">复制地址</button> <button type="button" @click.stop="copyProjectPath">复制地址</button>
</div> </div>
</span> </span>
@ -108,7 +112,9 @@
<button <button
type="button" type="button"
class="floating-project-status__button floating-project-status__button--branch" class="floating-project-status__button floating-project-status__button--branch"
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'branch' }" :class="{
'floating-project-status__button--active': projectGitMenuOpen === 'branch'
}"
@click.stop="toggleProjectGitMenu('branch')" @click.stop="toggleProjectGitMenu('branch')"
> >
{{ projectGitSummaryForRender.branch }} {{ projectGitSummaryForRender.branch }}
@ -124,7 +130,9 @@
</span> </span>
<span class="floating-project-status__right"> <span class="floating-project-status__right">
<span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span> <span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span>
<span v-else-if="goalModeArmed" class="floating-project-status__notice">目标模式就绪</span> <span v-else-if="goalModeArmed" class="floating-project-status__notice"
>目标模式就绪</span
>
<button <button
v-if="userQuestionMinimized && pendingUserQuestionCount > 0" v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
type="button" type="button"
@ -147,8 +155,12 @@
class="floating-project-status__stats" class="floating-project-status__stats"
@click.stop="$emit('open-git-changes-panel')" @click.stop="$emit('open-git-changes-panel')"
> >
<span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span> <span class="floating-project-status__add"
<span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span> >+<RollingNumber :value="projectGitSummaryForRender.additions"
/></span>
<span class="floating-project-status__del"
>-<RollingNumber :value="projectGitSummaryForRender.deletions"
/></span>
</button> </button>
</span> </span>
</div> </div>
@ -227,7 +239,11 @@
v-if="isVoiceInputSupported" v-if="isVoiceInputSupported"
type="button" type="button"
class="stadium-btn voice-btn" class="stadium-btn voice-btn"
:class="{ 'voice-btn--recording': isRecording, 'voice-btn--disabled': voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready' }" :class="{
'voice-btn--recording': isRecording,
'voice-btn--disabled':
voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready'
}"
@click="toggleVoiceRecording" @click="toggleVoiceRecording"
:disabled="!isConnected || inputLocked" :disabled="!isConnected || inputLocked"
:title="voiceButtonTitle" :title="voiceButtonTitle"
@ -264,9 +280,9 @@
@click="$emit('send-or-stop')" @click="$emit('send-or-stop')"
:disabled="sendButtonDisabled" :disabled="sendButtonDisabled"
> >
<span v-if="showStopIcon" class="stop-icon"></span> <span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span> <span v-else class="send-icon"></span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@ -445,6 +461,7 @@ import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state'; import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.vue'; import QuickMenu from '@/components/input/QuickMenu.vue';
import FileAtMenu, { type FileAtItem } from '@/components/input/FileAtMenu.vue';
import RollingNumber from '@/components/input/RollingNumber.vue'; import RollingNumber from '@/components/input/RollingNumber.vue';
import { useInputStore } from '@/stores/input'; import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
@ -561,6 +578,7 @@ const props = defineProps<{
goalProgress?: Record<string, any> | null; goalProgress?: Record<string, any> | null;
hasPendingRuntimeGuidance?: boolean; hasPendingRuntimeGuidance?: boolean;
terminalCount?: number; terminalCount?: number;
hostMode?: boolean;
}>(); }>();
const inputStore = useInputStore(); const inputStore = useInputStore();
@ -572,7 +590,14 @@ const stadiumInput = ref<HTMLTextAreaElement | 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 }; type SkillItem = { name: string; description: string; path: string };
type SlashMenuMode = 'root' | 'skills' | 'theme' | 'permission' | 'execution' | 'model' | 'run-mode'; type SlashMenuMode =
| 'root'
| 'skills'
| 'theme'
| 'permission'
| 'execution'
| 'model'
| 'run-mode';
type SlashMenuItem = { type SlashMenuItem = {
id: string; id: string;
label: string; label: string;
@ -603,15 +628,27 @@ let slashAnimating = false;
let lastSlashAnimatedScroll: number | null = null; let lastSlashAnimatedScroll: number | null = null;
let lastSlashAnimationEndTime = 0; let lastSlashAnimationEndTime = 0;
const fileAtMenuRef = ref<InstanceType<typeof FileAtMenu> | null>(null);
const fileAtOpen = ref(false);
const fileAtQuery = ref<string | null>(null);
const fileAtActiveIndex = ref(0);
const fileAtItems = ref<FileAtItem[]>([]);
const fileAtLoading = ref(false);
const fileAtMenuStyle = ref<Record<string, string>>({});
const fileAtPickerActive = ref(false);
let fileAtSearchTimer: number | null = null;
const slashHighlightStyle = computed(() => { const slashHighlightStyle = computed(() => {
if (slashManualScrolled.value) return { display: 'none' }; if (slashManualScrolled.value) return { display: 'none' };
const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0; const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0;
return { return {
top: `${slashHighlightTop.value}px`, top: `${slashHighlightTop.value}px`,
...(isFirst ? { ...(isFirst
borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))', ? {
borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))', borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
} : {}), borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))'
}
: {})
}; };
}); });
@ -715,9 +752,10 @@ const projectGitSummaryForRender = computed(() => {
}); });
const floatingStatusVisible = computed(() => { const floatingStatusVisible = computed(() => {
if (skillSlashMenuOpen.value || props.quickMenuOpen) return false; if (skillSlashMenuOpen.value || fileAtOpen.value || props.quickMenuOpen) return false;
// /git / // /git /
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) return false; if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance)
return false;
return ( return (
!!projectGitSummaryForRender.value || !!projectGitSummaryForRender.value ||
!!props.goalRunning || !!props.goalRunning ||
@ -782,6 +820,25 @@ const findSlashToken = () => {
}; };
}; };
const findAtToken = () => {
const instance = editor.value;
if (!instance) return null;
const { state } = instance;
const { $from } = state.selection;
const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n');
const match = /(^|[ \n])@([^\s@]*)$/.exec(beforeInLine);
if (!match) return null;
const query = match[2] || '';
const start = $from.pos - query.length - 1;
const end = $from.pos;
return {
start,
end,
query,
charStart: start
};
};
const closeSlashMenu = () => { const closeSlashMenu = () => {
if (slashAnimId !== null) { if (slashAnimId !== null) {
cancelAnimationFrame(slashAnimId); cancelAnimationFrame(slashAnimId);
@ -799,6 +856,192 @@ const closeSlashMenu = () => {
slashManualScrolled.value = false; slashManualScrolled.value = false;
}; };
const closeFileAtMenu = () => {
fileAtOpen.value = false;
fileAtQuery.value = null;
fileAtActiveIndex.value = 0;
fileAtItems.value = [];
fileAtLoading.value = false;
fileAtMenuStyle.value = {};
if (fileAtSearchTimer !== null) {
window.clearTimeout(fileAtSearchTimer);
fileAtSearchTimer = null;
}
};
const updateFileAtMenuPosition = (tokenEnd: number) => {
nextTick(() => {
const instance = editor.value;
if (!instance) return;
try {
const coords = instance.view.coordsAtPos(tokenEnd);
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 280;
const menuHeight = 250;
const gap = 4;
// @
let left = coords.right;
let top = coords.top - menuHeight;
if (left + menuWidth > viewportWidth - gap) {
left = Math.max(gap, viewportWidth - menuWidth - gap);
}
if (top < gap) {
top = coords.bottom;
}
if (top + menuHeight > viewportHeight - gap) {
top = Math.max(gap, viewportHeight - menuHeight - gap);
}
fileAtMenuStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${menuWidth}px`,
maxHeight: `${menuHeight}px`
};
} catch {
fileAtMenuStyle.value = {};
}
});
};
const isImageFile = (path: string): boolean => {
if (!path) return false;
const ext = path.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext);
};
const fetchProjectFileSearch = async (query: string) => {
fileAtLoading.value = true;
try {
const url = `/api/project/files/search?q=${encodeURIComponent(query)}&limit=50`;
const response = await fetch(url);
const data = await response.json().catch(() => ({}));
if (!response.ok || !data?.success) {
fileAtItems.value = [];
return;
}
fileAtItems.value = Array.isArray(data?.data?.items) ? data.data.items : [];
} catch (error) {
console.warn('[FileAt] 搜索文件失败:', error);
fileAtItems.value = [];
} finally {
fileAtLoading.value = false;
}
};
const scheduleFileAtSearch = (query: string) => {
if (fileAtSearchTimer !== null) {
window.clearTimeout(fileAtSearchTimer);
}
fileAtSearchTimer = window.setTimeout(() => {
void fetchProjectFileSearch(query);
}, 120);
};
const refreshFileAtState = () => {
const token = findAtToken();
if (!token || !props.isConnected || props.inputLocked) {
closeFileAtMenu();
return;
}
closeProjectGitMenu();
const queryChanged = fileAtQuery.value !== token.query;
fileAtOpen.value = true;
if (queryChanged || fileAtItems.value.length === 0) {
fileAtActiveIndex.value = 0;
fileAtQuery.value = token.query;
scheduleFileAtSearch(token.query);
}
updateFileAtMenuPosition(token.end);
};
const insertFileAtMention = (item: FileAtItem) => {
const token = findAtToken();
const instance = editor.value;
if (!token || !instance) return;
const { state, view, schema } = instance;
const mentionType = schema.nodes.mention;
if (!mentionType) return;
const mentionNode = mentionType.create({
id: `file://${item.path}`,
label: item.name
});
let tr = state.tr.delete(token.start, token.end);
tr = tr.insert(token.start, mentionNode);
const afterMention = token.start + mentionNode.nodeSize;
tr = tr.insertText(' ', afterMention);
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
view.dispatch(tr);
view.focus();
closeFileAtMenu();
nextTick(() => {
adjustTextareaSize();
});
};
const openFilePickerAndReference = () => {
closeFileAtMenu();
if (!props.hostMode) {
return;
}
fileAtPickerActive.value = true;
fileUploadInput.value?.click();
};
const selectFileAtItemByIndex = (index: number) => {
const hasPicker = !!props.hostMode;
if (hasPicker && index === 0) {
void openFilePickerAndReference();
return;
}
const fileIndex = hasPicker ? index - 1 : index;
const item = fileAtItems.value[fileIndex];
if (!item) return;
if (item.type === 'file' && isImageFile(item.path)) {
closeFileAtMenu();
emit('pick-images');
return;
}
insertFileAtMention(item);
};
const handleFileAtKeydown = (event: KeyboardEvent): boolean => {
if (!fileAtOpen.value) return false;
const hasPicker = !!props.hostMode;
const total = (hasPicker ? 1 : 0) + fileAtItems.value.length;
if (total <= 0) return false;
if (event.key === 'ArrowDown') {
event.preventDefault();
fileAtMenuRef.value?.setManualScroll(false);
fileAtActiveIndex.value = (fileAtActiveIndex.value + 1) % total;
fileAtMenuRef.value?.focusActive();
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
fileAtMenuRef.value?.setManualScroll(false);
fileAtActiveIndex.value = (fileAtActiveIndex.value - 1 + total) % total;
fileAtMenuRef.value?.focusActive();
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectFileAtItemByIndex(fileAtActiveIndex.value);
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
closeFileAtMenu();
return true;
}
return false;
};
const deleteSlashToken = () => { const deleteSlashToken = () => {
const token = findSlashToken(); const token = findSlashToken();
const instance = editor.value; const instance = editor.value;
@ -822,12 +1065,15 @@ const scrollSkillSlashSelectionIntoMiddle = () => {
const styles = getComputedStyle(list); const styles = getComputedStyle(list);
const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height')); const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height'));
const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap')); const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap'));
const rowHeight = (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) + const rowHeight =
(Number.isFinite(cssGap) ? cssGap : 5); (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) + (Number.isFinite(cssGap) ? cssGap : 5);
const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5); const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5);
const middleOffset = Math.floor(visibleRows / 2); const middleOffset = Math.floor(visibleRows / 2);
const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight); const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight);
const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight)); const target = Math.max(
0,
Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight)
);
animateSlash(list, target); animateSlash(list, target);
}); });
}; };
@ -836,7 +1082,11 @@ const filteredSkills = computed(() => {
const query = skillSlashQuery.value.trim().toLowerCase(); const query = skillSlashQuery.value.trim().toLowerCase();
const list = Array.isArray(availableSkills.value) ? availableSkills.value : []; const list = Array.isArray(availableSkills.value) ? availableSkills.value : [];
const filtered = query const filtered = query
? list.filter((skill) => String(skill.name || '').toLowerCase().includes(query)) ? list.filter((skill) =>
String(skill.name || '')
.toLowerCase()
.includes(query)
)
: list; : list;
return filtered.slice(0, 100); return filtered.slice(0, 100);
}); });
@ -938,7 +1188,11 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{ {
id: 'goal', id: 'goal',
label: '目标模式', label: '目标模式',
description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式', description: props.goalRunning
? '目标模式运行中'
: props.goalModeArmed
? '目标模式已就绪'
: '切换目标模式',
disabled: !props.isConnected, disabled: !props.isConnected,
action: () => emit('toggle-goal-mode') action: () => emit('toggle-goal-mode')
}, },
@ -979,13 +1233,15 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
mode: 'permission' mode: 'permission'
}, },
...(props.executionModeEnabled ...(props.executionModeEnabled
? [{ ? [
id: 'execution', {
label: '执行环境', id: 'execution',
description: '切换 sandbox / direct', label: '执行环境',
disabled: !props.isConnected || props.streamingMessage, description: '切换 sandbox / direct',
mode: 'execution' disabled: !props.isConnected || props.streamingMessage,
} as SlashMenuItem] mode: 'execution'
} as SlashMenuItem
]
: []), : []),
{ {
id: 'versioning', id: 'versioning',
@ -997,7 +1253,8 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{ {
id: 'git-bar', id: 'git-bar',
label: 'Git 状态栏', label: 'Git 状态栏',
description: (personalizationStore?.form?.show_git_status_bar !== false) ? '当前:显示' : '当前:隐藏', description:
personalizationStore?.form?.show_git_status_bar !== false ? '当前:显示' : '当前:隐藏',
action: () => { action: () => {
const currentValue = personalizationStore?.form?.show_git_status_bar !== false; const currentValue = personalizationStore?.form?.show_git_status_bar !== false;
const newValue = !currentValue; const newValue = !currentValue;
@ -1066,7 +1323,7 @@ const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
}); });
const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => { const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = (props.permissionOptions || []); const options = props.permissionOptions || [];
const current = String(props.currentPermissionMode || ''); const current = String(props.currentPermissionMode || '');
return options.map((opt) => ({ return options.map((opt) => ({
id: `perm:${opt.value}`, id: `perm:${opt.value}`,
@ -1077,7 +1334,7 @@ const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
}); });
const executionSlashMenuItems = computed<SlashMenuItem[]>(() => { const executionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = (props.executionModeOptions || []); const options = props.executionModeOptions || [];
const current = String(props.currentExecutionMode || ''); const current = String(props.currentExecutionMode || '');
return options.map((opt) => ({ return options.map((opt) => ({
id: `exec:${opt.value}`, id: `exec:${opt.value}`,
@ -1156,14 +1413,17 @@ const slashMenuAriaLabel = computed(() => {
}); });
const slashMenuEmptyText = computed(() => { const slashMenuEmptyText = computed(() => {
if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; if (slashMenuMode.value === 'skills')
return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill';
if (slashMenuMode.value === 'permission') return '无可用权限模式'; if (slashMenuMode.value === 'permission') return '无可用权限模式';
if (slashMenuMode.value === 'execution') return '无可用执行环境'; if (slashMenuMode.value === 'execution') return '无可用执行环境';
if (slashMenuMode.value === 'model') return '无可用模型'; if (slashMenuMode.value === 'model') return '无可用模型';
return '没有匹配的指令'; return '没有匹配的指令';
}); });
const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0)); const skillSlashMenuOpen = computed(
() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0)
);
const loadSkills = async () => { const loadSkills = async () => {
if (skillsLoaded.value || skillsLoading.value) { if (skillsLoaded.value || skillsLoading.value) {
@ -1210,7 +1470,10 @@ const refreshSkillSlashState = () => {
if (slashMenuMode.value === 'skills') { if (slashMenuMode.value === 'skills') {
void loadSkills(); void loadSkills();
} }
skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1)); skillSlashActiveIndex.value = Math.min(
skillSlashActiveIndex.value,
Math.max(0, activeSlashMenuItems.value.length - 1)
);
scrollSkillSlashSelectionIntoMiddle(); scrollSkillSlashSelectionIntoMiddle();
}; };
@ -1327,10 +1590,16 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
}; };
const onKeydown = (event: KeyboardEvent): boolean => { const onKeydown = (event: KeyboardEvent): boolean => {
if (handleFileAtKeydown(event)) {
return true;
}
if (handleSlashMenuKeydown(event)) { if (handleSlashMenuKeydown(event)) {
return true; return true;
} }
requestAnimationFrame(refreshSkillSlashState); requestAnimationFrame(() => {
refreshSkillSlashState();
refreshFileAtState();
});
return false; return false;
}; };
@ -1338,6 +1607,7 @@ const onInputBlur = () => {
emit('input-blur'); emit('input-blur');
window.setTimeout(() => { window.setTimeout(() => {
closeSlashMenu(); closeSlashMenu();
closeFileAtMenu();
}, 120); }, 120);
}; };
@ -1365,8 +1635,15 @@ const editorJsonToMessage = (json?: JSONContent | null) => {
if (node.type === 'text') return node.text || ''; if (node.type === 'text') return node.text || '';
if (node.type === 'mention') { if (node.type === 'mention') {
const attrs = node.attrs || {}; const attrs = node.attrs || {};
const name = String(attrs.label || attrs.id || '').trim(); const id = String(attrs.id || '');
const path = String(attrs.path || attrs.id || '').trim(); const label = String(attrs.label || id).trim();
const isFile = id.startsWith('file://');
if (isFile) {
const filePath = id.slice(7);
return label && filePath ? `[${label}](file://${filePath})` : label;
}
const name = label;
const path = String(attrs.path || id).trim();
if (name && path && !skillRefs.some((item) => item.path === path)) { if (name && path && !skillRefs.some((item) => item.path === path)) {
skillRefs.push({ name, path }); skillRefs.push({ name, path });
} }
@ -1376,7 +1653,10 @@ const editorJsonToMessage = (json?: JSONContent | null) => {
return Array.isArray(node.content) ? node.content.map(readInline).join('') : ''; return Array.isArray(node.content) ? node.content.map(readInline).join('') : '';
}; };
const blocks = Array.isArray(json?.content) ? json.content : []; const blocks = Array.isArray(json?.content) ? json.content : [];
const message = blocks.map((block) => readInline(block)).join('\n').trim(); const message = blocks
.map((block) => readInline(block))
.join('\n')
.trim();
return { message, skillRefs }; return { message, skillRefs };
}; };
@ -1406,12 +1686,19 @@ const editor = useEditor({
}, },
renderHTML({ node, HTMLAttributes }) { renderHTML({ node, HTMLAttributes }) {
const attrs = HTMLAttributes || {}; const attrs = HTMLAttributes || {};
const id = String(node.attrs.id || '');
const isFile = id.startsWith('file://');
const className = isFile
? `${attrs.class || ''} file-md-link`.trim()
: `${attrs.class || ''} skill-md-link`.trim();
const dataAttr = isFile ? 'data-file-path' : 'data-skill-path';
const dataValue = isFile ? id.slice(7) : node.attrs.path || node.attrs.id || '';
return [ return [
'span', 'span',
{ {
...attrs, ...attrs,
class: `${attrs.class || ''} skill-md-link`.trim(), class: className,
'data-skill-path': node.attrs.path || node.attrs.id || '' [dataAttr]: dataValue
}, },
String(node.attrs.label || node.attrs.id || '') String(node.attrs.label || node.attrs.id || '')
]; ];
@ -1442,6 +1729,7 @@ const editor = useEditor({
nextTick(() => { nextTick(() => {
adjustTextareaSize(); adjustTextareaSize();
refreshSkillSlashState(); refreshSkillSlashState();
refreshFileAtState();
}); });
}, },
onFocus() { onFocus() {
@ -1492,12 +1780,19 @@ const setupVoiceBridgeCallbacks = () => {
return; return;
} }
bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字'); bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字');
bridge?.debugLog('__onVoiceResult: voiceAnchorPos=' + voiceAnchorPos + ' lastVoiceTextLength=' + lastVoiceTextLength); bridge?.debugLog(
'__onVoiceResult: voiceAnchorPos=' +
voiceAnchorPos +
' lastVoiceTextLength=' +
lastVoiceTextLength
);
const { state, view } = ed; const { state, view } = ed;
const docSize = state.doc.content.size; const docSize = state.doc.content.size;
const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize)); const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize));
const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize); const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize);
bridge?.debugLog('__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd); bridge?.debugLog(
'__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd
);
try { try {
let tr = state.tr; let tr = state.tr;
if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd); if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd);
@ -1575,7 +1870,7 @@ const isWebSpeechSupported = computed(() => {
const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => { const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
const Ctor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; const Ctor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
console.log('[VoiceInput] 构造函数:', Ctor ? (Ctor.name || 'webkitSpeechRecognition') : 'null'); console.log('[VoiceInput] 构造函数:', Ctor ? Ctor.name || 'webkitSpeechRecognition' : 'null');
return Ctor; return Ctor;
}; };
@ -1623,7 +1918,11 @@ const initSpeechRecognition = () => {
let fullText = ''; let fullText = '';
for (let i = 0; i < event.results.length; i++) { for (let i = 0; i < event.results.length; i++) {
const r = event.results[i]; const r = event.results[i];
console.log(`[VoiceInput] result[${i}]:`, { transcript: r[0]?.transcript, isFinal: r.isFinal, confidence: r[0]?.confidence }); console.log(`[VoiceInput] result[${i}]:`, {
transcript: r[0]?.transcript,
isFinal: r.isFinal,
confidence: r[0]?.confidence
});
fullText += r[0].transcript; fullText += r[0].transcript;
} }
@ -1806,7 +2105,10 @@ const keepRuntimeQueueTransitionCollapsed = () => {
const cancelAnim = (el: HTMLElement) => { const cancelAnim = (el: HTMLElement) => {
const timer = runtimeQueueFallbackTimers.get(el); const timer = runtimeQueueFallbackTimers.get(el);
if (timer) { clearTimeout(timer); runtimeQueueFallbackTimers.delete(el); } if (timer) {
clearTimeout(timer);
runtimeQueueFallbackTimers.delete(el);
}
el.style.removeProperty('transition'); el.style.removeProperty('transition');
el.style.removeProperty('transform'); el.style.removeProperty('transform');
el.style.removeProperty('opacity'); el.style.removeProperty('opacity');
@ -1831,14 +2133,18 @@ const scheduleTransition = (
}; };
// Apply from state immediately (no transition) // Apply from state immediately (no transition)
el.style.transition = 'none'; el.style.transition = 'none';
Object.entries(from).forEach(([k, v]) => { el.style.setProperty(k, v); }); Object.entries(from).forEach(([k, v]) => {
el.style.setProperty(k, v);
});
// Defer to next frame: enable transition + apply to state // Defer to next frame: enable transition + apply to state
// All scheduleTransition calls in the same microtask share this RAF // All scheduleTransition calls in the same microtask share this RAF
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.style.transition = props el.style.transition = props
.map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`) .map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`)
.join(', '); .join(', ');
Object.entries(to).forEach(([k, v]) => { el.style.setProperty(k, v); }); Object.entries(to).forEach(([k, v]) => {
el.style.setProperty(k, v);
});
const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40); const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40);
runtimeQueueFallbackTimers.set(el, timer); runtimeQueueFallbackTimers.set(el, timer);
}); });
@ -1894,7 +2200,11 @@ onUpdated(() => {
// Clean up any stale ghost with same id // Clean up any stale ghost with same id
const existing = runtimeQueueGhosts.get(id); const existing = runtimeQueueGhosts.get(id);
if (existing) { cancelAnim(existing); existing.remove(); runtimeQueueGhosts.delete(id); } if (existing) {
cancelAnim(existing);
existing.remove();
runtimeQueueGhosts.delete(id);
}
const ghost = src.cloneNode(true) as HTMLElement; const ghost = src.cloneNode(true) as HTMLElement;
const lr = list.getBoundingClientRect(); const lr = list.getBoundingClientRect();
@ -1914,10 +2224,15 @@ onUpdated(() => {
// Schedule ghost sink shares RAF with item animations below // Schedule ghost sink shares RAF with item animations below
const offset = resolveRuntimeQueueOffset(ghost); const offset = resolveRuntimeQueueOffset(ghost);
scheduleTransition(ghost, ['transform'], scheduleTransition(
ghost,
['transform'],
{ transform: 'translateY(0)' }, { transform: 'translateY(0)' },
{ transform: `translateY(${offset}px)` }, { transform: `translateY(${offset}px)` },
() => { runtimeQueueGhosts.delete(id); ghost.remove(); } () => {
runtimeQueueGhosts.delete(id);
ghost.remove();
}
); );
runtimeQueueItemRefs.delete(id); runtimeQueueItemRefs.delete(id);
@ -1932,7 +2247,9 @@ onUpdated(() => {
if (enteringIds.includes(id)) { if (enteringIds.includes(id)) {
// New item: rise from below // New item: rise from below
const offset = resolveRuntimeQueueOffset(el); const offset = resolveRuntimeQueueOffset(el);
scheduleTransition(el, ['transform'], scheduleTransition(
el,
['transform'],
{ transform: `translateY(${offset}px)` }, { transform: `translateY(${offset}px)` },
{ transform: 'translateY(0)' } { transform: 'translateY(0)' }
); );
@ -1945,7 +2262,9 @@ onUpdated(() => {
const next = el.getBoundingClientRect(); const next = el.getBoundingClientRect();
const deltaY = prev.top - next.top; const deltaY = prev.top - next.top;
if (Math.abs(deltaY) < 0.5) return; if (Math.abs(deltaY) < 0.5) return;
scheduleTransition(el, ['transform'], scheduleTransition(
el,
['transform'],
{ transform: `translateY(${deltaY}px)` }, { transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' } { transform: 'translateY(0)' }
); );
@ -1990,10 +2309,23 @@ 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 || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value; return (
hasQueue ||
floatingStatusVisible.value ||
skillSlashOpen.value ||
fileAtOpen.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'
);
const handleSlashMenuTransitionStart = () => { const handleSlashMenuTransitionStart = () => {
slashMenuTransitioning.value = true; slashMenuTransitioning.value = true;
@ -2047,7 +2379,9 @@ const collectComposerVisualHeight = () => {
} }
// //
// .goal-mode-banner--collapsed // .goal-mode-banner--collapsed
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status'); const nodes = root.querySelectorAll(
'.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status'
);
nodes.forEach((node) => { nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return; if (!(node instanceof HTMLElement)) return;
// floatingStatusVisible false DOM 退 // floatingStatusVisible false DOM 退
@ -2175,9 +2509,58 @@ const onInput = (event: Event) => {
nextTick(refreshSkillSlashState); nextTick(refreshSkillSlashState);
}; };
const onFileChange = (event: Event) => { const insertFileAtMentionByPath = (path: string, name: string) => {
const token = findAtToken();
const instance = editor.value;
if (!instance || !token) return;
const mentionType = instance.schema.nodes.mention;
if (!mentionType) return;
const mentionNode = mentionType.create({
id: `file://${path}`,
label: name
});
const { state, view } = instance;
let tr = state.tr.delete(token.start, token.end);
tr = tr.insert(token.start, mentionNode);
const afterMention = token.start + mentionNode.nodeSize;
tr = tr.insertText(' ', afterMention);
tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1)));
view.dispatch(tr);
view.focus();
closeFileAtMenu();
nextTick(() => {
adjustTextareaSize();
});
};
const onFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
emit('file-selected', target?.files || null); const files = target?.files || null;
if (fileAtPickerActive.value && files && files.length > 0) {
fileAtPickerActive.value = false;
const file = files[0];
try {
const response = await fetch(
`/api/project/files/search?q=${encodeURIComponent(file.name)}&limit=10`
);
const data = await response.json().catch(() => ({}));
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
const match = items.find((item: FileAtItem) => item.name === file.name) || items[0];
if (match) {
insertFileAtMention(match);
} else {
insertFileAtMentionByPath(file.name, file.name);
}
} catch (error) {
console.warn('[FileAt] 通过文件选择器查找路径失败:', error);
insertFileAtMentionByPath(file.name, file.name);
}
if (target) target.value = '';
return;
}
emit('file-selected', files);
if (target) { if (target) {
target.value = ''; target.value = '';
} }
@ -2190,7 +2573,9 @@ const getComposerDraftMeta = () => ({
skill_refs: editorJsonToMessage(editor.value?.getJSON() as JSONContent).skillRefs skill_refs: editorJsonToMessage(editor.value?.getJSON() as JSONContent).skillRefs
}); });
const restoreComposerDraftMeta = (meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null) => { const restoreComposerDraftMeta = (
meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null
) => {
const instance = editor.value; const instance = editor.value;
if (!instance) return; if (!instance) return;
if (meta?.editor_json && typeof meta.editor_json === 'object') { if (meta?.editor_json && typeof meta.editor_json === 'object') {
@ -2298,7 +2683,11 @@ watch(
() => props.inputMessage, () => props.inputMessage,
async () => { async () => {
const instance = editor.value; const instance = editor.value;
if (instance && !syncingEditorFromProps && getEditorPlainText() !== (props.inputMessage || '')) { if (
instance &&
!syncingEditorFromProps &&
getEditorPlainText() !== (props.inputMessage || '')
) {
syncingEditorFromProps = true; syncingEditorFromProps = true;
instance.commands.setContent(textToTiptapContent(props.inputMessage || '')); instance.commands.setContent(textToTiptapContent(props.inputMessage || ''));
syncingEditorFromProps = false; syncingEditorFromProps = false;
@ -2510,7 +2899,9 @@ onBeforeUnmount(() => {
min-width: 0; min-width: 0;
padding: 0 9px; padding: 0 9px;
cursor: pointer; cursor: pointer;
transition: background 140ms ease, color 140ms ease; transition:
background 140ms ease,
color 140ms ease;
} }
.floating-project-status__button:hover, .floating-project-status__button:hover,
@ -2554,7 +2945,9 @@ onBeforeUnmount(() => {
line-height: 1.2; line-height: 1.2;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
cursor: pointer; cursor: pointer;
transition: background 140ms ease, color 140ms ease; transition:
background 140ms ease,
color 140ms ease;
} }
.floating-project-status__stats:hover { .floating-project-status__stats:hover {
@ -2586,7 +2979,9 @@ onBeforeUnmount(() => {
font-size: 12px; font-size: 12px;
line-height: 1.2; line-height: 1.2;
cursor: pointer; cursor: pointer;
transition: background 140ms ease, color 140ms ease; transition:
background 140ms ease,
color 140ms ease;
} }
.floating-project-status__terminal:hover { .floating-project-status__terminal:hover {
@ -2689,7 +3084,9 @@ onBeforeUnmount(() => {
background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-text-tertiary) 8%); background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-text-tertiary) 8%);
border: 1px solid var(--theme-chip-border); border: 1px solid var(--theme-chip-border);
user-select: none; user-select: none;
transition: gap 0.2s ease, padding 0.2s ease; transition:
gap 0.2s ease,
padding 0.2s ease;
} }
/* padding /* padding
关键切到 absolute 脱离文档流避免横幅占用一行流高度把队列/skill 菜单顶起来 关键切到 absolute 脱离文档流避免横幅占用一行流高度把队列/skill 菜单顶起来
@ -2708,7 +3105,9 @@ onBeforeUnmount(() => {
white-space: nowrap; white-space: nowrap;
max-width: 200px; max-width: 200px;
opacity: 1; opacity: 1;
transition: max-width 0.2s ease, opacity 0.18s ease; transition:
max-width 0.2s ease,
opacity 0.18s ease;
} }
.goal-mode-banner--collapsed .goal-mode-banner__text { .goal-mode-banner--collapsed .goal-mode-banner__text {
max-width: 0; max-width: 0;
@ -2759,12 +3158,19 @@ onBeforeUnmount(() => {
background: var(--claude-success); background: var(--claude-success);
} }
@keyframes goal-banner-pulse { @keyframes goal-banner-pulse {
0%, 100% { opacity: 1; } 0%,
50% { opacity: 0.35; } 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
} }
.goal-banner-fade-enter-active, .goal-banner-fade-enter-active,
.goal-banner-fade-leave-active { .goal-banner-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease; transition:
opacity 0.18s ease,
transform 0.18s ease;
} }
.goal-banner-fade-enter-from, .goal-banner-fade-enter-from,
.goal-banner-fade-leave-to { .goal-banner-fade-leave-to {

View File

@ -662,7 +662,8 @@
opacity: 0.9; opacity: 0.9;
} }
.user-message .message-text .user-skill-link { .user-message .message-text .user-skill-link,
.user-message .message-text .user-file-link {
color: var(--state-info); color: var(--state-info);
font-weight: 600; font-weight: 600;
display: inline-block; display: inline-block;

File diff suppressed because it is too large Load Diff