From 62776a30f12d3d57205c17c7f4bd0b72acada77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 25 Oct 2026 22:32:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20ai-chat=20=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BD=93=E9=AA=8C=E6=89=B9(=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E5=A4=87=E4=BB=BD/=E5=9B=BE=E7=89=87=E6=94=BE=E5=A4=A7/?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=AE=8C=E6=88=90=E6=80=81/=E6=B7=98?= =?UTF-8?q?=E6=B1=B0=E8=BE=B9=E7=95=8C=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示 - 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾 - 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询 - 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚) - 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分 --- .../src/commands/ai/commands/conversation.rs | 14 +- src/api/ai.ts | 6 +- src/components/AiChat.vue | 6 + src/components/ToolCard.vue | 16 +- src/components/ai/CIStatus.vue | 123 -- src/components/ai/ChatInput.vue | 309 +++- src/components/ai/ConflictResolver.vue | 191 -- src/components/ai/ContextBriefsCard.vue | 5 +- src/components/ai/ConversationSidebar.vue | 918 +--------- src/components/ai/EnrichmentPanel.vue | 2 +- src/components/ai/ImageInput.vue | 40 +- src/components/ai/MentionPopover.vue | 14 +- src/components/ai/MessageItem.vue | 57 +- src/components/ai/MessageList.vue | 177 +- src/components/ai/OutboxDrawer.vue | 73 +- src/components/ai/SkillMention.vue | 13 +- src/components/ai/TopBar.vue | 1560 ++--------------- .../conversation-sidebar/ConversationList.vue | 151 +- src/components/ai/top-bar/ContextPanel.vue | 154 ++ src/components/ai/top-bar/GoalsPanel.vue | 182 ++ src/components/ai/top-bar/HeaderLeft.vue | 195 +++ src/components/ai/top-bar/HistoryPanel.vue | 149 ++ src/components/ai/top-bar/MoreMenu.vue | 165 ++ src/components/ai/top-bar/ProviderCenter.vue | 220 +++ .../ai/top-bar/ProviderSwitchBar.vue | 55 + .../ai/top-bar/RunningTasksPanel.vue | 314 ++++ src/components/ai/top-bar/SummaryPanel.vue | 139 ++ .../ai/top-bar/TaskProgressBadge.vue | 226 +++ src/components/ai/top-bar/WindowControls.vue | 101 ++ src/components/ai/top-bar/types.ts | 17 + .../tool-result-body/FileBrowseResult.vue | 334 ++++ .../tool-result-body/WriteRunResult.vue | 448 +++++ src/components/tool-result-body/path-bar.css | 70 + src/components/tool-result-body/shared.ts | 63 + src/composables/ai/useAiConversations.ts | 20 +- src/i18n/en/aiChat.ts | 43 +- src/i18n/zh-CN/aiChat.ts | 43 +- src/stores/ai.ts | 13 +- 38 files changed, 3848 insertions(+), 2778 deletions(-) delete mode 100644 src/components/ai/CIStatus.vue delete mode 100644 src/components/ai/ConflictResolver.vue create mode 100644 src/components/ai/top-bar/ContextPanel.vue create mode 100644 src/components/ai/top-bar/GoalsPanel.vue create mode 100644 src/components/ai/top-bar/HeaderLeft.vue create mode 100644 src/components/ai/top-bar/HistoryPanel.vue create mode 100644 src/components/ai/top-bar/MoreMenu.vue create mode 100644 src/components/ai/top-bar/ProviderCenter.vue create mode 100644 src/components/ai/top-bar/ProviderSwitchBar.vue create mode 100644 src/components/ai/top-bar/RunningTasksPanel.vue create mode 100644 src/components/ai/top-bar/SummaryPanel.vue create mode 100644 src/components/ai/top-bar/TaskProgressBadge.vue create mode 100644 src/components/ai/top-bar/WindowControls.vue create mode 100644 src/components/ai/top-bar/types.ts create mode 100644 src/components/tool-result-body/FileBrowseResult.vue create mode 100644 src/components/tool-result-body/WriteRunResult.vue create mode 100644 src/components/tool-result-body/path-bar.css create mode 100644 src/components/tool-result-body/shared.ts diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index d1646b3..e9a7c29 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -1141,17 +1141,17 @@ pub async fn ai_conversation_set_pinned( /// /// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。 /// goals 是完整替换(非增量),前端增/删后传全量 new Vec。 +// 跨端修复(走查批E):签名 Vec → Vec。原实现把前端载荷逐条 +// GoalEntry::new 重置 active,已完成目标(status="completed")的完成态被抹。 +// 前端 GoalsPanel 同步改传完整 GoalEntry[](text/status,snake_case),反序列化缺 +// status 时 serde default 补 active(见 mod.rs GoalEntry 定义),旧载荷兼容。 #[tauri::command] pub async fn ai_update_conversation_goals( state: State<'_, AppState>, conversation_id: String, - goals: Vec, + goals: Vec, ) -> Result<(), String> { - // 前端传回的 string[] 转为 GoalEntry(假设全部 active) - let goal_entries: Vec = goals.into_iter() - .map(|g| crate::commands::ai::GoalEntry::new(g)) - .collect(); - let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?; + let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?; // 写 DB state.ai_conversations .update_field(&conversation_id, "pinned_goals", &goals_json) @@ -1160,7 +1160,7 @@ pub async fn ai_update_conversation_goals( // 同步内存 per_conv(若已加载) let mut session = state.ai_session.lock().await; if let Some(conv) = session.per_conv.get_mut(&conversation_id) { - conv.pinned_goals = goal_entries; + conv.pinned_goals = goals; } Ok(()) } diff --git a/src/api/ai.ts b/src/api/ai.ts index d69eee3..bfb7097 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn } from '@tauri-apps/api/event' -import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types' +import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, GoalEntry, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types' export const aiApi = { /** @@ -387,8 +387,10 @@ export const aiApi = { /** * 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。 * 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。 + * 传完整 GoalEntry[](含 status):后端按原样存储,降级 string[] 会被逐条 + * GoalEntry::new 重置 active,已完成目标完成态被抹(2026-10-24 跨端修复)。 */ - updateConversationGoals(conversationId: string, goals: string[]): Promise { + updateConversationGoals(conversationId: string, goals: GoalEntry[]): Promise { return invoke('ai_update_conversation_goals', { conversationId, goals }) }, diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 4d03d10..dc3c330 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -332,6 +332,9 @@ function onGlobalKeydown(e: KeyboardEvent) { const msg = e instanceof Error ? e.message : String(e) showToast(t('aiChat.regenerateFailed', { msg }), 'error') }) + } else { + // 走查:生成中按键被 streaming 守卫静默吞掉,用户以为快捷键坏了;补一句轻提示 + showToast(t('aiChat.generatingBusyHint'), 'warning') } return } @@ -340,6 +343,9 @@ function onGlobalKeydown(e: KeyboardEvent) { e.preventDefault() if (!store.state.streaming) { confirmClearChat() + } else { + // 走查:同 Ctrl+R——守卫吞键须有反馈 + showToast(t('aiChat.generatingBusyHint'), 'warning') } return } diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 1d833b3..63efe87 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -48,8 +48,8 @@ -
+ 尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染;新行追加自动滚尾 --> +
{{ ln }}
{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}
@@ -187,7 +187,7 @@ - - diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index cf50344..2885b3e 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -21,7 +21,7 @@ ref="inputEl" v-model="inputText" class="ai-input" - :class="{ 'ai-input--editing': !!editingMsgId }" + :class="{ 'ai-input--editing': !!editingMsgId, 'ai-input--dragover': dragHover }" :placeholder="editingMsgId ? $t('aiChat.placeholderEditing') : (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))" @@ -30,7 +30,12 @@ @beforeinput="onBeforeInputText" @input="onInputText" @paste="onPaste" + @blur="onInputBlur" + @click="onCaretMove" + @keyup="onCaretMove" @dragover="onDragOver" + @dragenter="onDragEnter" + @dragleave="onDragLeave" @drop="onDrop" > @@ -168,6 +173,8 @@ import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeCo import { extractImageUrlParts } from '../../composables/ai/utils' import { uploadFile } from '../../composables/ai/fileUpload' import { open } from '@tauri-apps/plugin-dialog' +// 2026-10-19 拖拽/粘贴非图片文件落盘:ai_save_dropped_file IPC(后端 chat.rs:503) +import { invoke } from '@tauri-apps/api/core' import SkillMention from './SkillMention.vue' import ImageInput from './ImageInput.vue' import MentionPopover from './MentionPopover.vue' @@ -210,13 +217,16 @@ const outboxOpen = ref(false) // 约束: // - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。 // - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。 -// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。 +// - 仅接受 image/* MIME;非图片文件 2026-10-19 起走 addAttachmentFile 落盘引用行 +// (不再放行原生塞文本),纯文本粘贴仍走原生不拦。 // - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL // (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。 // pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。 const MAX_PENDING_IMAGES = 6 const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限) -const pendingImages = ref<{ id: string; dataUrl?: string; url?: string; mediaType: string }[]>([]) +// uploading=true 为上传中的占位 chip(url/dataUrl 未定,渲染 loading 态不入发送快照) +interface PendingImage { id: string; dataUrl?: string; url?: string; mediaType: string; uploading?: boolean } +const pendingImages = ref([]) /** 读 File → base64 data URL(供 预览 + ContentPart.image base64 字段) */ function readImageFile(file: File): Promise { @@ -228,28 +238,40 @@ function readImageFile(file: File): Promise { }) } -/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。 */ +/** 把 image File 加入 pendingImages;超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。 + * 走查 P1:入列先同步 push「上传中」占位 chip(复用缩略图盒+loading 态),完成原位替换/失败移除, + * 治大图/慢网上传期间零反馈像「没贴上」;数量上限复核移到实际落定前——上传是异步窗口,两批 + * 并发粘贴都能过入口检查而双双穿透,在落定点复核才治本。上传失败回退本地引用时轻提示告知。 */ async function addImageFile(file: File): Promise { if (!file.type.startsWith('image/')) return if (file.size > MAX_IMAGE_BYTES) { emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' }) return } - if (pendingImages.value.length >= MAX_PENDING_IMAGES) { - emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' }) - return - } const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + pendingImages.value.push({ id, mediaType: file.type, uploading: true }) + // 占位 chip 原位落定(用户中途点 × 移除则静默丢弃);落定前复核上限(占位也计数) + const settle = (img: PendingImage): void => { + if (!pendingImages.value.some(p => p.id === id)) return + if (pendingImages.value.length >= MAX_PENDING_IMAGES) { + removePendingImage(id) + emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' }) + return + } + pendingImages.value[pendingImages.value.findIndex(p => p.id === id)] = img + } try { // 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。 // 未配置或失败回退 base64(dataUrl),不阻断图片输入。 const { url } = await uploadFile(file) - pendingImages.value.push({ id, url, mediaType: file.type }) + settle({ id, url, mediaType: file.type }) } catch { try { const dataUrl = await readImageFile(file) - pendingImages.value.push({ id, dataUrl, mediaType: file.type }) + settle({ id, dataUrl, mediaType: file.type }) + emit('error', { msg: t('aiChat.imageUploadFallback'), type: 'info' }) } catch (e) { + removePendingImage(id) emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' }) console.error('[AI] 图片读取失败:', e) } @@ -316,6 +338,36 @@ async function insertTextFileDraft(path: string): Promise { } } +/** 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治): + * 前端预拦 20MB(省一次 IPC,后端 chat.rs 同上限兜底)→ 字节经 ai_save_dropped_file + * IPC 落盘 attachments/(hash 去重+原名保真,media_store::store_attachment)→ 换回本机 + * 绝对路径插引用行进草稿(与 I9 insertTextFileDraft 同款追加+光标回末尾手法)。 + * AI 经 read_file 按需读取,与飞书文件入站同范式。引用行走 i18n 模板,`[附件]` 不带 + * `:名` 结构且 mention span 仅由 selectMention 显式记录(无文本正则解析),不会误捕。 */ +const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 // 对齐后端 ai_save_dropped_file 上限 +async function addAttachmentFile(file: File): Promise { + if (file.size > MAX_ATTACHMENT_BYTES) { + emit('error', { msg: t('aiChat.attachmentTooLarge'), type: 'warning' }) + return + } + try { + // data 传 JSON number 数组(后端 Vec 可反序列化) + const buf = await file.arrayBuffer() + const data = Array.from(new Uint8Array(buf)) + const savedPath = await invoke('ai_save_dropped_file', { fileName: file.name, data }) + inputText.value += '\n' + t('aiChat.attachmentRefLine', { name: file.name, path: savedPath }) + '\n' + await nextTick() + inputEl.value?.focus() + const el = inputEl.value + if (el) el.selectionStart = el.selectionEnd = el.value.length + } catch (e) { + // 落盘失败(空文件/超限/IO 错):后端错误字符串入 {msg} 细节,不阻断后续文件 + const msg = e instanceof Error ? e.message : String(e) + emit('error', { msg: t('aiChat.attachmentSaveFailed', { msg }), type: 'error' }) + console.error('[AI] 附件落盘失败:', file.name, e) + } +} + /** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */ async function onPickImage(): Promise { const selected = await open({ @@ -355,48 +407,102 @@ function removePendingImage(id: string): void { pendingImages.value = pendingImages.value.filter(p => p.id !== id) } -/** 粘贴:ClipboardEvent 检测 image 类型条目,加入待发送列表(不插入文本占位) */ +/** 粘贴:ClipboardEvent 收集全部 file 条目——图片走 addImageFile 进待发送列表, + * 非图片走 addAttachmentFile 落盘插引用行(2026-10-19 根治「发文件变一堆文本」); + * 剪贴板无文件(纯文本/字符串)不拦,原生插入行为不变。 + * 走查:富文本「图+文」混合剪贴板(如网页图文一起复制)原实现只收文件、preventDefault + * 后把字符串条目静默丢弃,文案凭空丢失。现补收 text/plain 条目,拦下默认行为后手动 + * 插回光标处(text/html 是同内容富文本重复源,不收防双插)。 */ async function onPaste(e: ClipboardEvent): Promise { const items = e.clipboardData?.items if (!items) return - const files: File[] = [] + const images: File[] = [] + const attachments: File[] = [] + const textChunks: string[] = [] for (let i = 0; i < items.length; i++) { const it = items[i] - if (it.kind === 'file' && it.type.startsWith('image/')) { - const f = it.getAsFile() - if (f) files.push(f) + // 字符串条目:只取 text/plain(走文件接管链时手动补插);getAsString 异步,包 Promise 收集 + if (it.kind === 'string') { + if (it.type === 'text/plain') { + const s = await new Promise((resolve) => { it.getAsString(resolve) }) + if (s) textChunks.push(s) + } + continue } + // kind==='file' 才是文件条目;MIME 以 File.type 为准 + if (it.kind !== 'file') continue + const f = it.getAsFile() + if (!f) continue + if (f.type.startsWith('image/')) images.push(f) + else attachments.push(f) } - if (files.length === 0) return - // 含图片:阻止默认粘贴(避免把图片的二进制当文本塞进 textarea),逐个加入 + if (images.length === 0 && attachments.length === 0) return + // 含文件:阻止默认粘贴(防原生把文本文件内容/图片二进制塞进 textarea,病灶根因),逐个处理 e.preventDefault() - for (const f of files) await addImageFile(f) + if (textChunks.length > 0) insertTextAtCursor(textChunks.join('\n')) + for (const f of images) await addImageFile(f) + for (const f of attachments) await addAttachmentFile(f) } -/** 拖拽进入:仅允许图片,preventDefault 让 drop 事件触发 */ +/** 光标处插入文本并置尾(原生粘贴被 preventDefault 接管后补插文本条目用); + * 程序赋值不派发 input,mention span 须手动平移(与 applyListEdit 同款手法) */ +function insertTextAtCursor(text: string): void { + const old = inputText.value + const el = inputEl.value + if (!el) { + inputText.value = old + text + return + } + const start = el.selectionStart ?? old.length + const end = el.selectionEnd ?? start + inputText.value = old.slice(0, start) + text + old.slice(end) + shiftMentionSpans(old, inputText.value) + nextTick(() => { + el.focus() + const pos = start + text.length + el.setSelectionRange(pos, pos) + autoResize() + }) +} + +/** 拖拽悬停:drag 项含文件即 preventDefault 让 drop 事件触发(copy/move 光标提示)。 + * dragover 阶段 types 只给 'Files' 不给具体 MIME,文件(无论图片/非图片)统一接管; + * 2026-10-19 起非图片文件也走落盘引用,不再放行原生塞文本。 */ function onDragOver(e: DragEvent): void { if (!e.dataTransfer) return - // 判断当前是否含 image/* 类型(copy/move 光标提示) - const hasImage = Array.from(e.dataTransfer.types).includes('Files') - if (hasImage) { + const hasFiles = Array.from(e.dataTransfer.types).includes('Files') + if (hasFiles) { e.preventDefault() e.dataTransfer.dropEffect = 'copy' } } -/** 拖拽放下:取出图片 File,加入待发送 */ -async function onDrop(e: DragEvent): Promise { - const files = e.dataTransfer?.files - if (!files || files.length === 0) return - // 仅处理图片文件(非图片拖入走原生不拦,但这里 preventDefault 已无副作用) - const images = Array.from(files).filter(f => f.type.startsWith('image/')) - if (images.length === 0) return - e.preventDefault() - for (const f of images) await addImageFile(f) +// 走查:拖拽悬停落点高亮——文件拖入输入框时描边提示可放;textarea 无子元素, +// dragenter/dragleave 成对触发无冒泡误清问题,离开/落下即撤 +const dragHover = ref(false) +function onDragEnter(e: DragEvent): void { + if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) dragHover.value = true +} +function onDragLeave(): void { + dragHover.value = false } -/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */ -const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value) +/** 拖拽放下:有文件即 preventDefault 接管——图片走 addImageFile,非图片走 addAttachmentFile。 + * 病灶:此前非图片直接 return 不拦,WebView2 原生行为把文本文件内容整段塞进输入框 + * (2026-10-19 用户反馈「发送文件变成一堆文本」),现统一落盘为附件引用行。 */ +async function onDrop(e: DragEvent): Promise { + const files = e.dataTransfer?.files + dragHover.value = false + if (!files || files.length === 0) return + e.preventDefault() + for (const f of Array.from(files)) { + if (f.type.startsWith('image/')) await addImageFile(f) + else await addAttachmentFile(f) + } +} + +/** 发送可用:有文本或已就绪图片(上传中占位不算,无 url/dataUrl 入列会产出空 image 片)或已选技能(纯技能调用允许空文本) */ +const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.some(p => !p.uploading) || !!pendingSkill.value) // 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红); // 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false, @@ -431,7 +537,11 @@ const currentModelSupportsVision = computed(() => { if (!chosen) return true return (chosen.modalities ?? []).includes('vision') }) -const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value) +// 走查:警示不只覆盖粘贴图——输入文本里的图片 URL 发送时同样转 image 片被非视觉模型忽略, +// 一并纳入(判定复用发送链同款 extractImageUrlParts,口径单一) +const showImageIgnoreWarn = computed(() => + !currentModelSupportsVision.value + && (pendingImages.value.length > 0 || extractImageUrlParts(inputText.value).length > 0)) // ── 技能 `/` 联想 ── const skillOpen = ref(false) @@ -498,6 +608,23 @@ function detectMentionTrigger() { mentionStart.value = -1 } +// 走查:@ 浮层收口——textarea 失焦即关(浮层项点击已在 MentionPopover 用 mousedown.prevent +// 防「mousedown 先于 blur」竞态:焦点不离开 textarea,click 选择照常触发),点外部区域/Tab +// 离开不再悬挂浮层 +function onInputBlur(): void { + mentionOpen.value = false + mentionStart.value = -1 +} + +// 走查:光标移出 @ 触发区间即关——方向键/点击移动光标不触发 input,detectMentionTrigger +// 不会被复检;keyup/click 后复核。浮层导航键(↑↓ 在 keydown 改高亮,keyup 复检会把 +// mentionIndex 重置回 0)与提交键跳过,防导航失灵 +function onCaretMove(e: Event): void { + if (!mentionOpen.value) return + if (e.type === 'keyup' && ['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'Tab'].includes((e as KeyboardEvent).key)) return + detectMentionTrigger() +} + function selectSkill(s: SkillInfo) { pendingSkill.value = s inputText.value = '' @@ -691,26 +818,25 @@ const enrichmentDetailText = computed(() => { function dismissEnrichment() { const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project') if (projectSpans.length === 0) return - // 从 inputText 中移除所有 [@项目: xxx] 标记 + // 走查:按 span 区间从后往前删——原实现循环内 text.replace,首个标记删除后文本变短, + // 后续 span.start 还是原始偏移,再 replace 可能匹配到错位内容误删相邻文本。 + // span.start 是 selectMention 时刻的原始偏移,倒序删除不影响前方偏移,逐段精确裁掉。 let text = inputText.value - for (const span of projectSpans) { - const label = text.slice(span.start, span.start + span.length) - // 替换标记 + 前后可能的空格为单个空格 - // M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母), - // 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。 - const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`) - text = text.replace(pattern, ' ').trim() + const spans = [...projectSpans].sort((a, b) => b.start - a.start) + for (const span of spans) { + // 标记两侧紧邻空白一并吞掉(对齐原 \s*…\s* → ' ' 语义),换成单个空格保持词距 + let start = span.start + let end = span.start + span.length + while (start > 0 && /\s/.test(text[start - 1])) start-- + while (end < text.length && /\s/.test(text[end])) end++ + text = text.slice(0, start) + ' ' + text.slice(end) } - inputText.value = text + inputText.value = text.trim() // 清除 project 类型的 mentionSpan pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project') enrichmentExpanded.value = false } -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - // P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。 // 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。 watch( @@ -922,12 +1048,8 @@ function handleKeydown(e: KeyboardEvent) { selectSkill(filteredSkills.value[skillIndex.value]) return } - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - skillOpen.value = false - return - } + // (走查:此处原有一个 Escape 分支——上方 skillOpen+Escape 通用分支已先行消费并 return, + // 本分支不可达,删除。@ 浮层的同名分支可达,保留。) } // UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通 // 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。 @@ -962,6 +1084,14 @@ function handleKeydown(e: KeyboardEvent) { return } } + // 走查:@ 实体引用浮层开着但无匹配时,Enter 原会穿透到下方发送分支把 "@xxx" 原文当消息 + // 发出。对齐技能侧 skillNoMatchQuery 范式:不发送 + toast 提示;确要发原文先 Esc 关浮层。 + if (mentionOpen.value && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + e.stopPropagation() + emit('error', { msg: t('aiChat.mentionNoMatchQuery', { q: mentionQuery.value.trim() }), type: 'warning' }) + return + } // 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。 // 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前; // 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。 @@ -990,8 +1120,8 @@ function handleKeydown(e: KeyboardEvent) { } } -/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致, - * 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */ +/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致已统一 200。 + * 走查:resize:vertical 已随塌回根治移除,本上限即 auto-grow 唯一天花板) */ const INPUT_MAX_HEIGHT = 200 function autoResize() { @@ -1016,6 +1146,8 @@ async function handleSend() { if (inputEl.value) inputEl.value.style.height = 'auto' try { await store.editMessage(text) + // 编辑成功即消费草稿备份:cancel-edit 链的 clearInput 照旧清空,不回填编辑前的旧稿 + editBackupDraft = null emit('cancel-edit') } catch (e) { console.error('[AI] 编辑失败:', e) @@ -1029,18 +1161,20 @@ async function handleSend() { return } const skill = pendingSkill.value - // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用) - if (!text && !skill && pendingImages.value.length === 0) return + // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)。 + // 走查:上传中的占位 chip 不计入(无 url/dataUrl,入快照会产出空 image 片) + const settledImages = pendingImages.value.filter(p => !p.uploading) + if (!text && !skill && settledImages.length === 0) return // B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮), // 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状 - if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) { + if (!text && !skill && settledImages.length > 0 && !currentModelSupportsVision.value) { emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' }) return } // F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。 // 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的 // user 消息渲染多模态图,后端请求仍走 content 文本路径。 - const snapshotImgs = pendingImages.value.slice() + const snapshotImgs = settledImages // Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。 // 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim() // 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。 @@ -1094,7 +1228,8 @@ async function handleSend() { // F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿) persistDraftNow() pendingSkill.value = null - pendingImages.value = [] + // 走查:发送只消费已就绪图片,上传中的占位 chip 保留在列(落定后随下一条消息发送) + pendingImages.value = pendingImages.value.filter(p => p.uploading) // Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步) pendingMentionSpans.value = [] skillOpen.value = false @@ -1111,9 +1246,9 @@ async function handleSend() { console.error('[AI] 发送失败:', e) inputText.value = text pendingSkill.value = skill - // 发送失败:回填图片供重试(快照原样还原) + // 发送失败:回填图片供重试(快照原样还原;上传中占位已在列,追加不重复) if (parts) { - pendingImages.value = snapshotImgs + pendingImages.value = [...pendingImages.value, ...snapshotImgs] } // 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐) if (snapshotSpans.length) { @@ -1144,6 +1279,11 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm() // ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ── +// 走查 P1:进入编辑态前的草稿备份(null=无编辑进行中/备份已消费)。 +// startEditFocus 写入,cancelEdit 链回填,编辑成功发送时清空(照旧清空不回填)。 +// 记 convId:取消可能由切会话触发,落盘归属须按备份时的会话判定(见 clearInput) +let editBackupDraft: { convId: string; text: string } | null = null + /** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */ function startEditFocus(msg: AiMessage): void { // UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换 @@ -1155,6 +1295,15 @@ function startEditFocus(msg: AiMessage): void { emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' }) return } + // 走查 P1:覆盖为消息原文前,先按既有 per-conv 草稿范式(内存 Map + 落盘镜像)备份当前 + // 未发送草稿,取消编辑时回填——否则草稿被消息原文顶掉且 300ms 防抖把它落盘覆盖, + // 一次 Esc 就丢稿。已有备份(编辑态内再点另一条编辑)不覆盖,保住用户最初的草稿。 + const convId = store.state.activeConversationId + if (editBackupDraft === null && convId) { + editBackupDraft = { convId, text: inputText.value } + setConvDraft(convId, inputText.value) + persistDraft(convId, inputText.value) + } inputText.value = msg.content nextTick(() => { inputEl.value?.focus() @@ -1170,18 +1319,26 @@ function startEditFocus(msg: AiMessage): void { }) } -/** UX-09 取消编辑态:清空输入框 + 重置高度(父置 editingMsgId=null 后调) */ +/** UX-09 取消编辑态:回填进入编辑前的草稿备份(无备份=照旧清空)+ 重置高度(父置 editingMsgId=null 后调)。 + * 走查 P1:原实现无条件清空落盘,把用户未发送草稿一并抹掉;备份在编辑成功发送时已消费, + * 成功链到这里照旧清空落盘,行为不变。切会话触发的取消(activeConversationId 已变为新会话) + * 此处落盘会把旧会话草稿错写进新会话键下——跳过,由本组件 conv watcher 以旧会话 id 落盘。 */ function clearInput(): void { - inputText.value = '' + const backup = editBackupDraft + editBackupDraft = null + inputText.value = backup?.text ?? '' pendingSkill.value = null pendingImages.value = [] pendingMentionSpans.value = [] skillOpen.value = false mentionOpen.value = false mentionStart.value = -1 - if (inputEl.value) inputEl.value.style.height = 'auto' - // F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启) - persistDraftNow() + autoResize() + // F2-2:落盘收口(取消编辑=回填备份稿;成功发送=空串删项,不残留旧稿到下次重启)。 + // 会话切换场景:备份归属会话 ≠ 当前活跃会话,交给 conv watcher 落盘防错键 + if (!backup || backup.convId === store.state.activeConversationId) { + persistDraftNow() + } } /** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。 @@ -1213,8 +1370,8 @@ function focus(): void { // ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ── // 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像: // 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的 -// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入, -// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。 +// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 回填草稿备份 +// (见 clearInput),本 watcher 后执行,旧会话保存到的即恢复后的草稿,草稿跨编辑态不丢。 // F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条, // 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为, @@ -1376,7 +1533,10 @@ defineExpose({ /* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */ /* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式); - autosize 高度参数(min/height/max-height)为行为契约,禁动 */ + autosize 高度参数(min/height/max-height)为行为契约,禁动。 + 走查:resize:vertical 已移除(显式 none 兜 UA 默认 both)——autoResize 随内容重算会把 + 手工拉伸高度塌回,两案(记住拉伸高度需 ResizeObserver 辨识拖拽 vs 去掉 resize)取改动 + 小者:单一 auto-grow 语义(20→200px),杜绝塌回 */ .ai-input { width: 100%; background: transparent; @@ -1386,7 +1546,7 @@ defineExpose({ font-size: 13px; font-family: var(--df-font-sans); line-height: 1.5; - resize: vertical; + resize: none; min-height: 20px; height: 20px; max-height: 200px; @@ -1396,6 +1556,13 @@ defineExpose({ color: var(--df-text-dim); } +/* 走查:拖拽悬停落点高亮(outline 不占布局防 0.5px 抖动;accent-soft 弱底+accent 虚线描边) */ +.ai-input--dragover { + outline: 1px dashed var(--df-accent); + outline-offset: -1px; + background: var(--df-accent-soft); +} + /* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */ .ai-input-row { display: flex; diff --git a/src/components/ai/ConflictResolver.vue b/src/components/ai/ConflictResolver.vue deleted file mode 100644 index f896300..0000000 --- a/src/components/ai/ConflictResolver.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - diff --git a/src/components/ai/ContextBriefsCard.vue b/src/components/ai/ContextBriefsCard.vue index 7ae8521..9cf504f 100644 --- a/src/components/ai/ContextBriefsCard.vue +++ b/src/components/ai/ContextBriefsCard.vue @@ -116,11 +116,12 @@ async function openSource(b: ContextBrief) { } /** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并), - * 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */ + * 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 + * 草稿模板句走 i18n(原硬编码中文,en 环境不再漏出中文) */ function bringIn(g: ProjectBriefs, b: ContextBrief) { const convId = store.state.activeConversationId if (!convId) return - pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`) + pushConvDraftAppend(convId, t('aiChat.contextBriefsDraft', { project: g.source_project, title: b.title, summary: b.summary })) } diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index 95d34b6..b9f9696 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -1,5 +1,6 @@