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文件管理相关路由。"""
from __future__ import annotations
import os
import re
import zipfile
from io import BytesIO
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 werkzeug.utils import secure_filename
@ -311,4 +312,145 @@ def gui_text_entry(terminal, workspace, username):
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"]

View File

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

View File

@ -933,16 +933,35 @@ const escapeUserHtml = (value: string): string =>
.replace(/'/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 {
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 cursor = 0;
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 += ` <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;
}
html += escapeUserHtml(source.slice(cursor));
@ -1040,17 +1059,6 @@ watch(
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());
let timerHandle: number | null = null;
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"
:aria-label="slashMenuAriaLabel"
>
<div
ref="skillSlashList"
class="skill-slash-menu"
@scroll="onSlashScroll"
>
<div ref="skillSlashList" class="skill-slash-menu" @scroll="onSlashScroll">
<button
v-for="(item, index) in activeSlashMenuItems"
:key="item.id"
@ -73,24 +69,30 @@
{{ slashMenuEmptyText }}
</div>
</div>
<div
class="skill-slash-menu__highlight"
:style="slashHighlightStyle"
/>
<div class="skill-slash-menu__highlight" :style="slashHighlightStyle" />
</div>
</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">
<div
v-if="floatingStatusVisible"
class="floating-project-status"
@click.stop
>
<div v-if="floatingStatusVisible" class="floating-project-status" @click.stop>
<span v-if="projectGitSummaryForRender" class="floating-project-status__left">
<span class="floating-project-status__menu-wrap">
<button
type="button"
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')"
>
{{ projectGitSummaryForRender.projectName }}
@ -100,7 +102,9 @@
class="floating-project-status__submenu floating-project-status__submenu--project"
@click.stop
>
<button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button>
<button type="button" @click.stop="openProjectInFileManager">
在文件管理器中打开
</button>
<button type="button" @click.stop="copyProjectPath">复制地址</button>
</div>
</span>
@ -108,7 +112,9 @@
<button
type="button"
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')"
>
{{ projectGitSummaryForRender.branch }}
@ -124,7 +130,9 @@
</span>
<span class="floating-project-status__right">
<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
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
type="button"
@ -147,8 +155,12 @@
class="floating-project-status__stats"
@click.stop="$emit('open-git-changes-panel')"
>
<span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span>
<span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span>
<span class="floating-project-status__add"
>+<RollingNumber :value="projectGitSummaryForRender.additions"
/></span>
<span class="floating-project-status__del"
>-<RollingNumber :value="projectGitSummaryForRender.deletions"
/></span>
</button>
</span>
</div>
@ -227,7 +239,11 @@
v-if="isVoiceInputSupported"
type="button"
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"
:disabled="!isConnected || inputLocked"
:title="voiceButtonTitle"
@ -264,9 +280,9 @@
@click="$emit('send-or-stop')"
:disabled="sendButtonDisabled"
>
<span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
<span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
</div>
</div>
</div>
@ -445,6 +461,7 @@ import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.vue';
import FileAtMenu, { type FileAtItem } from '@/components/input/FileAtMenu.vue';
import RollingNumber from '@/components/input/RollingNumber.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';
@ -561,6 +578,7 @@ const props = defineProps<{
goalProgress?: Record<string, any> | null;
hasPendingRuntimeGuidance?: boolean;
terminalCount?: number;
hostMode?: boolean;
}>();
const inputStore = useInputStore();
@ -572,7 +590,14 @@ const stadiumInput = ref<HTMLTextAreaElement | null>(null);
const fileUploadInput = ref<HTMLInputElement | null>(null);
const baselineComposerVisualHeight = ref<number>(0);
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 = {
id: string;
label: string;
@ -603,15 +628,27 @@ let slashAnimating = false;
let lastSlashAnimatedScroll: number | null = null;
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(() => {
if (slashManualScrolled.value) return { display: 'none' };
const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0;
return {
top: `${slashHighlightTop.value}px`,
...(isFirst ? {
borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))',
} : {}),
...(isFirst
? {
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(() => {
if (skillSlashMenuOpen.value || props.quickMenuOpen) return false;
if (skillSlashMenuOpen.value || fileAtOpen.value || props.quickMenuOpen) return false;
// /git /
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) return false;
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance)
return false;
return (
!!projectGitSummaryForRender.value ||
!!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 = () => {
if (slashAnimId !== null) {
cancelAnimationFrame(slashAnimId);
@ -799,6 +856,192 @@ const closeSlashMenu = () => {
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 token = findSlashToken();
const instance = editor.value;
@ -822,12 +1065,15 @@ const scrollSkillSlashSelectionIntoMiddle = () => {
const styles = getComputedStyle(list);
const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height'));
const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap'));
const rowHeight = (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) +
(Number.isFinite(cssGap) ? cssGap : 5);
const rowHeight =
(Number.isFinite(cssRowHeight) ? cssRowHeight : 38) + (Number.isFinite(cssGap) ? cssGap : 5);
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));
const target = Math.max(
0,
Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight)
);
animateSlash(list, target);
});
};
@ -836,7 +1082,11 @@ 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.filter((skill) =>
String(skill.name || '')
.toLowerCase()
.includes(query)
)
: list;
return filtered.slice(0, 100);
});
@ -938,7 +1188,11 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{
id: 'goal',
label: '目标模式',
description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式',
description: props.goalRunning
? '目标模式运行中'
: props.goalModeArmed
? '目标模式已就绪'
: '切换目标模式',
disabled: !props.isConnected,
action: () => emit('toggle-goal-mode')
},
@ -979,13 +1233,15 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
mode: 'permission'
},
...(props.executionModeEnabled
? [{
id: 'execution',
label: '执行环境',
description: '切换 sandbox / direct',
disabled: !props.isConnected || props.streamingMessage,
mode: 'execution'
} as SlashMenuItem]
? [
{
id: 'execution',
label: '执行环境',
description: '切换 sandbox / direct',
disabled: !props.isConnected || props.streamingMessage,
mode: 'execution'
} as SlashMenuItem
]
: []),
{
id: 'versioning',
@ -997,7 +1253,8 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{
id: 'git-bar',
label: 'Git 状态栏',
description: (personalizationStore?.form?.show_git_status_bar !== false) ? '当前:显示' : '当前:隐藏',
description:
personalizationStore?.form?.show_git_status_bar !== false ? '当前:显示' : '当前:隐藏',
action: () => {
const currentValue = personalizationStore?.form?.show_git_status_bar !== false;
const newValue = !currentValue;
@ -1066,7 +1323,7 @@ const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
});
const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = (props.permissionOptions || []);
const options = props.permissionOptions || [];
const current = String(props.currentPermissionMode || '');
return options.map((opt) => ({
id: `perm:${opt.value}`,
@ -1077,7 +1334,7 @@ const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
});
const executionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = (props.executionModeOptions || []);
const options = props.executionModeOptions || [];
const current = String(props.currentExecutionMode || '');
return options.map((opt) => ({
id: `exec:${opt.value}`,
@ -1156,14 +1413,17 @@ const slashMenuAriaLabel = 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 === 'execution') return '无可用执行环境';
if (slashMenuMode.value === 'model') 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 () => {
if (skillsLoaded.value || skillsLoading.value) {
@ -1210,7 +1470,10 @@ const refreshSkillSlashState = () => {
if (slashMenuMode.value === 'skills') {
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();
};
@ -1327,10 +1590,16 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => {
};
const onKeydown = (event: KeyboardEvent): boolean => {
if (handleFileAtKeydown(event)) {
return true;
}
if (handleSlashMenuKeydown(event)) {
return true;
}
requestAnimationFrame(refreshSkillSlashState);
requestAnimationFrame(() => {
refreshSkillSlashState();
refreshFileAtState();
});
return false;
};
@ -1338,6 +1607,7 @@ const onInputBlur = () => {
emit('input-blur');
window.setTimeout(() => {
closeSlashMenu();
closeFileAtMenu();
}, 120);
};
@ -1365,8 +1635,15 @@ const editorJsonToMessage = (json?: JSONContent | null) => {
if (node.type === 'text') return node.text || '';
if (node.type === 'mention') {
const attrs = node.attrs || {};
const name = String(attrs.label || attrs.id || '').trim();
const path = String(attrs.path || attrs.id || '').trim();
const id = String(attrs.id || '');
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)) {
skillRefs.push({ name, path });
}
@ -1376,7 +1653,10 @@ const editorJsonToMessage = (json?: JSONContent | null) => {
return Array.isArray(node.content) ? node.content.map(readInline).join('') : '';
};
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 };
};
@ -1406,12 +1686,19 @@ const editor = useEditor({
},
renderHTML({ node, 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 [
'span',
{
...attrs,
class: `${attrs.class || ''} skill-md-link`.trim(),
'data-skill-path': node.attrs.path || node.attrs.id || ''
class: className,
[dataAttr]: dataValue
},
String(node.attrs.label || node.attrs.id || '')
];
@ -1442,6 +1729,7 @@ const editor = useEditor({
nextTick(() => {
adjustTextareaSize();
refreshSkillSlashState();
refreshFileAtState();
});
},
onFocus() {
@ -1492,12 +1780,19 @@ const setupVoiceBridgeCallbacks = () => {
return;
}
bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字');
bridge?.debugLog('__onVoiceResult: voiceAnchorPos=' + voiceAnchorPos + ' lastVoiceTextLength=' + lastVoiceTextLength);
bridge?.debugLog(
'__onVoiceResult: voiceAnchorPos=' +
voiceAnchorPos +
' lastVoiceTextLength=' +
lastVoiceTextLength
);
const { state, view } = ed;
const docSize = state.doc.content.size;
const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, 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 {
let tr = state.tr;
if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd);
@ -1575,7 +1870,7 @@ const isWebSpeechSupported = computed(() => {
const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => {
if (typeof window === 'undefined') return 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;
};
@ -1623,7 +1918,11 @@ const initSpeechRecognition = () => {
let fullText = '';
for (let i = 0; i < event.results.length; 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;
}
@ -1806,7 +2105,10 @@ const keepRuntimeQueueTransitionCollapsed = () => {
const cancelAnim = (el: HTMLElement) => {
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('transform');
el.style.removeProperty('opacity');
@ -1831,14 +2133,18 @@ const scheduleTransition = (
};
// Apply from state immediately (no transition)
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
// All scheduleTransition calls in the same microtask share this RAF
requestAnimationFrame(() => {
el.style.transition = props
.map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`)
.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);
runtimeQueueFallbackTimers.set(el, timer);
});
@ -1894,7 +2200,11 @@ onUpdated(() => {
// Clean up any stale ghost with same 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 lr = list.getBoundingClientRect();
@ -1914,10 +2224,15 @@ onUpdated(() => {
// Schedule ghost sink shares RAF with item animations below
const offset = resolveRuntimeQueueOffset(ghost);
scheduleTransition(ghost, ['transform'],
scheduleTransition(
ghost,
['transform'],
{ transform: 'translateY(0)' },
{ transform: `translateY(${offset}px)` },
() => { runtimeQueueGhosts.delete(id); ghost.remove(); }
() => {
runtimeQueueGhosts.delete(id);
ghost.remove();
}
);
runtimeQueueItemRefs.delete(id);
@ -1932,7 +2247,9 @@ onUpdated(() => {
if (enteringIds.includes(id)) {
// New item: rise from below
const offset = resolveRuntimeQueueOffset(el);
scheduleTransition(el, ['transform'],
scheduleTransition(
el,
['transform'],
{ transform: `translateY(${offset}px)` },
{ transform: 'translateY(0)' }
);
@ -1945,7 +2262,9 @@ onUpdated(() => {
const next = el.getBoundingClientRect();
const deltaY = prev.top - next.top;
if (Math.abs(deltaY) < 0.5) return;
scheduleTransition(el, ['transform'],
scheduleTransition(
el,
['transform'],
{ transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' }
);
@ -1990,10 +2309,23 @@ 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 || 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 = () => {
slashMenuTransitioning.value = true;
@ -2047,7 +2379,9 @@ const collectComposerVisualHeight = () => {
}
//
// .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) => {
if (!(node instanceof HTMLElement)) return;
// floatingStatusVisible false DOM 退
@ -2175,9 +2509,58 @@ const onInput = (event: Event) => {
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;
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) {
target.value = '';
}
@ -2190,7 +2573,9 @@ const getComposerDraftMeta = () => ({
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;
if (!instance) return;
if (meta?.editor_json && typeof meta.editor_json === 'object') {
@ -2298,7 +2683,11 @@ watch(
() => props.inputMessage,
async () => {
const instance = editor.value;
if (instance && !syncingEditorFromProps && getEditorPlainText() !== (props.inputMessage || '')) {
if (
instance &&
!syncingEditorFromProps &&
getEditorPlainText() !== (props.inputMessage || '')
) {
syncingEditorFromProps = true;
instance.commands.setContent(textToTiptapContent(props.inputMessage || ''));
syncingEditorFromProps = false;
@ -2510,7 +2899,9 @@ onBeforeUnmount(() => {
min-width: 0;
padding: 0 9px;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
transition:
background 140ms ease,
color 140ms ease;
}
.floating-project-status__button:hover,
@ -2554,7 +2945,9 @@ onBeforeUnmount(() => {
line-height: 1.2;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
transition:
background 140ms ease,
color 140ms ease;
}
.floating-project-status__stats:hover {
@ -2586,7 +2979,9 @@ onBeforeUnmount(() => {
font-size: 12px;
line-height: 1.2;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
transition:
background 140ms ease,
color 140ms ease;
}
.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%);
border: 1px solid var(--theme-chip-border);
user-select: none;
transition: gap 0.2s ease, padding 0.2s ease;
transition:
gap 0.2s ease,
padding 0.2s ease;
}
/* padding
关键切到 absolute 脱离文档流避免横幅占用一行流高度把队列/skill 菜单顶起来
@ -2708,7 +3105,9 @@ onBeforeUnmount(() => {
white-space: nowrap;
max-width: 200px;
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 {
max-width: 0;
@ -2759,12 +3158,19 @@ onBeforeUnmount(() => {
background: var(--claude-success);
}
@keyframes goal-banner-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.goal-banner-fade-enter-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-leave-to {

View File

@ -662,7 +662,8 @@
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);
font-weight: 600;
display: inline-block;

File diff suppressed because it is too large Load Diff