feat(input): support file mentions
This commit is contained in:
parent
ae5676cd30
commit
359caca672
144
server/files.py
144
server/files.py
@ -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"]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>();
|
||||
|
||||
234
static/src/components/input/FileAtMenu.vue
Normal file
234
static/src/components/input/FileAtMenu.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user