diff --git a/src/api/ai.ts b/src/api/ai.ts index 89ba333..5cf42db 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -368,4 +368,22 @@ export const aiApi = { updateConversationGoals(conversationId: string, goals: string[]): Promise { return invoke('ai_update_conversation_goals', { conversationId, goals }) }, + + /** + * 只读窥视子代理轨迹(S3):后端复用 switch 读路径但**不切任何会话状态**。 + * 子会话(origin='sub_agent')不进侧栏、不可 switch,完成卡浮层经此拉轨迹; + * 返回全量消息 JSON 字符串(不分页)。 + */ + peekConversation(conversationId: string): Promise<{ title: string | null; messages: string }> { + return invoke('ai_conversation_peek', { conversationId }) + }, + + /** + * S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。 + * 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE), + * 已过滤子代理会话(origin='sub_agent')并按最近命中倒序去重,v1 只列主会话。 + */ + listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> { + return invoke('list_task_conversations', { taskId }) + }, } diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index 653cda5..bc5dcf5 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -25,7 +25,8 @@ : (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))" rows="1" @keydown="handleKeydown" - @input="autoResize" + @beforeinput="onBeforeInputText" + @input="onInputText" @paste="onPaste" @dragover="onDragOver" @drop="onDrop" @@ -401,6 +402,38 @@ const mentionStart = ref(-1) // 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。 const pendingMentionSpans = ref([]) +// ── UX-P3: mention span 编辑平移 ── +// span.start 是 selectMention 时刻的绝对偏移,用户在 chip 文本之前增删字符后全部 span 错位 +// (MessageList 渲染守卫对不上文本即降级纯文本,chip 全灭)。借 beforeinput(仅用户编辑触发, +// 程序赋值不派发事件)快照旧文本,input 时做公共前后缀差分求出编辑区间,按区间相对各 span +// 的位置处理:编辑点在 span 前→整体平移;在 span 后→不动;落在 span 文本内→丢弃该 span +// (文本已被改写区间失真,宁丢一枚 chip 也不让后端 resolve 到错位区间)。 +let mentionTextBeforeEdit = '' +function onBeforeInputText(): void { + mentionTextBeforeEdit = inputEl.value?.value ?? inputText.value +} +function onInputText(): void { + autoResize() + shiftMentionSpans(mentionTextBeforeEdit, inputEl.value?.value ?? inputText.value) +} +function shiftMentionSpans(oldText: string, newText: string): void { + if (pendingMentionSpans.value.length === 0 || oldText === newText) return + // 公共前后缀夹出旧文本中被编辑的区间(粘贴/剪切的多字符增删同为一段区间,该差分通用) + const minLen = Math.min(oldText.length, newText.length) + let p = 0 + while (p < minLen && oldText[p] === newText[p]) p++ + let s = 0 + while (s < minLen - p && oldText[oldText.length - 1 - s] === newText[newText.length - 1 - s]) s++ + const editStart = p + const editEnd = oldText.length - s + const delta = newText.length - oldText.length + pendingMentionSpans.value = pendingMentionSpans.value.flatMap((sp) => { + if (editEnd <= sp.start) return [{ ...sp, start: sp.start + delta }] + if (editStart >= sp.start + sp.length) return [sp] + return [] + }) +} + // ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── // 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感, // 在输入区下方显式 badge + 可展开的上下文参考面板。 @@ -685,6 +718,14 @@ function handleKeydown(e: KeyboardEvent) { // P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图, // 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。 if (e.isComposing || e.keyCode === 229) return + // UX-P3: AI 输入框聚焦时 Ctrl/Cmd+R 会触发 WebView 刷新(浏览器直开/开发态整页刷新丢会话态), + // 此处 preventDefault 掐掉刷新默认行为。只拦输入框聚焦这一场景(本 handler 仅 textarea 触发, + // 全局其他区域不拦);不 stopPropagation,冒泡到 AiChat onGlobalKeydown 的 Ctrl+R 分支, + // 重新生成语义照常生效。 + if ((e.ctrlKey || e.metaKey) && (e.key === 'r' || e.key === 'R')) { + e.preventDefault() + return + } // 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口, // 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅) // 编辑态 Escape 取消编辑(最高优先级) @@ -732,6 +773,14 @@ function handleKeydown(e: KeyboardEvent) { return } } + // UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通 + // 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。 + if (skillOpen.value && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + e.stopPropagation() + emit('error', { msg: t('aiChat.skillNoMatchQuery', { q: skillQuery.value.trim() }), type: 'warning' }) + return + } // @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥) if (mentionOpen.value && mentionItems.value.length) { if (e.key === 'ArrowDown') { @@ -775,7 +824,12 @@ async function handleSend() { const text = inputText.value.trim() // UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成) if (props.editingMsgId) { - if (!text) return + // UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。 + // 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。 + if (!text) { + emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' }) + return + } inputText.value = '' if (inputEl.value) inputEl.value.style.height = 'auto' try { diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 47047cd..f8b96eb 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -23,6 +23,7 @@ import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations' import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace' import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared' +import { aiApi } from '../../api' import ToolCardList from '../ToolCardList.vue' import MessageItem from './MessageItem.vue' import EmptyState from './EmptyState.vue' @@ -273,9 +274,32 @@ watch(() => store.state.streaming, (s) => { saveSelection() clearStreamingState() nextTick(() => restoreSelection()) + anchorCollapseScroll() } }) +// ── UX-P3: 长回复完成瞬间折叠的滚动锚 ── +// 末条 AI 回复超阈值(LONG_MSG_THRESHOLD)时,完成瞬间 shouldCollapseLong 从 false 翻 true +// (流式中不折叠的约束解除),气泡收缩到 400px;跟随意图下的 scrollToBottom 把视口钉在收缩后 +// 的底部,内容整体上跳,刚流完的答案开头/结尾都看不全。改锚消息顶:本回调在流式结束当拍注册 +// nextTick(此时折叠 class 尚未渲染),渲染拍后把该消息顶对齐视口顶,视口落点确定(答案开头, +// 回底按钮照常出现),不再随内容收缩被浏览器 clamp 到任意位置。 +// 仅跟随意图成立时干预(用户上滑读历史不拉扯);手动展开过的消息不会折叠,无收跳不处理。 +// 注册时序:currentText watch(先注册)的 scrollToBottom nextTick 先入队,本 nextTick 后入队 +// 后执行,锚定是滚动链的最后一手,不被钉底覆盖。 +const COLLAPSE_ANCHOR_PAD_PX = 8 +function anchorCollapseScroll(): void { + if (!isFollowingBottom.value) return + const last = store.state.messages[store.state.messages.length - 1] + if (!last || !shouldCollapseLong(last)) return + nextTick(() => { + const c = messagesContainer.value + const el = c?.querySelector(`[data-msg-id="${last.id}"]`) + if (!c || !el) return + c.scrollTop += el.getBoundingClientRect().top - c.getBoundingClientRect().top - COLLAPSE_ANCHOR_PAD_PX + }) +} + // ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时, // 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。 // 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ── @@ -1083,6 +1107,68 @@ function agentFinSummary(summary: string): string { return s.length > 160 ? s.slice(0, 160) + '…' : s } +// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ── +// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留), +// 不进 aiShared/stores——子会话轨迹纯只读,与主会话状态机(消息流/生成态)完全无关。 +interface AgentPeekMsg { + role: 'user' | 'assistant' | 'tool' | 'system' + text: string + toolNames: string[] +} +const agentPeekVisible = ref(false) +const agentPeekLoading = ref(false) +const agentPeekMsgs = ref([]) +// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底, +// 完整数据仍在库,不做全文渲染(不要求富文本)。 +const AGENT_PEEK_TEXT_MAX = 4000 + +/** + * 完成卡点击:peek 子会话轨迹并开浮层。 + * agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。 + * 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。 + */ +async function openAgentTrace(card: AgentFinishedCard): Promise { + if (agentPeekVisible.value || agentPeekLoading.value) return + agentPeekLoading.value = true + try { + const res = await aiApi.peekConversation(card.agentId) + const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || []) + agentPeekMsgs.value = raw.map((m) => { + const text = String(m.content || '') + const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system' + ? m.role + : 'assistant' + return { + role, + text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text, + toolNames: Array.isArray(m.tool_calls) + ? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean) + : [], + } + }) + agentPeekVisible.value = true + } catch (e) { + console.error('[AI] 拉取子代理轨迹失败:', e) + emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' }) + } finally { + agentPeekLoading.value = false + } +} + +/** 关浮层即弃:清空局部消息态,不缓存轨迹 */ +function closeAgentTrace(): void { + agentPeekVisible.value = false + agentPeekMsgs.value = [] +} + +/** role → 本地化角色名(简版渲染,无头像气泡) */ +function agentPeekRoleText(role: AgentPeekMsg['role']): string { + if (role === 'user') return t('ai.agentTrace.roleUser') + if (role === 'tool') return t('ai.agentTrace.roleTool') + if (role === 'system') return t('ai.agentTrace.roleSystem') + return t('ai.agentTrace.roleAssistant') +} + // UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计) const traceNav = computed(() => renderItems.value.filter((it): it is Extract => it.kind === 'trace') @@ -1230,9 +1316,16 @@ defineExpose({ + 轻量细条范式,非消息数据不进气泡。 + S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
-
+
{{ $t('ai.agentFinished.title') }} {{ agentFinStatusText(item.card.status) }} @@ -1485,6 +1578,35 @@ defineExpose({ + + +
+ +
diff --git a/src/components/project/GitChanges.vue b/src/components/project/GitChanges.vue index 35d5f24..6e68dbc 100644 --- a/src/components/project/GitChanges.vue +++ b/src/components/project/GitChanges.vue @@ -185,8 +185,6 @@ const loading = ref(false) const gitStatus = ref(null) const showHistory = ref(false) const selectedFile = ref('') -const diffContent = ref('') -const diffLoading = ref(false) // 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败) const loadError = ref('') const actionError = ref('') @@ -194,6 +192,12 @@ const actionError = ref('') // (对齐 ProjectDetail taskReqSeq 模式) let statusReqSeq = 0 +// UX走查 P3-4: 点文件 diff 单槽去重缓存 — 重复点击同一文件不再重复发 git diff IPC, +// 直接复用上次结果回填。切模块/refreshKey 重拉状态均经 loadStatus,缓存在彼处失效, +// 防 AI 改文件后旧 diff 被复用(单槽不设 Map,免过期条目堆积) +let lastDiffKey = '' +let lastDiffContent = '' + /** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */ interface CommitItem { hash: string @@ -353,6 +357,10 @@ async function loadStatus() { const mid = props.moduleId if (!mid) return const seq = ++statusReqSeq + // UX走查 P3-4: 状态重拉(切模块/外部 refreshKey,如 AI 改文件)即失效 diff 去重缓存, + // 防改文件后旧 diff 被复用 + lastDiffKey = '' + lastDiffContent = '' loading.value = true loadError.value = '' actionError.value = '' @@ -448,23 +456,35 @@ async function switchToHistory() { async function selectFile(path: string) { selectedFile.value = path - // 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化 + // 先切预览到该文件(内容视图兜底),diff 拉到后回填切 diff 视图,点文件即展示变化 emit('select-file', path) - diffContent.value = '' actionError.value = '' - diffLoading.value = true + // UX走查 P3-4 同参数去重:重复点击同一文件直接复用缓存 diff,不再发 IPC + const key = `${props.moduleId}::${path}` + if (key === lastDiffKey) { + emit('select-file', path, lastDiffContent) + return + } + // 捕获请求时的状态序号:期间 refreshKey 重拉(缓存已失效)则本次结果不入缓存,防改文件前旧 diff 滞留 + const seqAtReq = statusReqSeq + let diff = '' try { const res = await moduleApi.getModuleFileDiff(props.moduleId, path) - diffContent.value = res.diff || '' + diff = res.diff || '' + if (seqAtReq === statusReqSeq) { + lastDiffKey = key + lastDiffContent = diff + } } catch (e) { - diffContent.value = '' + // 失败清缓存使下次点击可重试;不回填 diff,预览停在内容视图(错误经 actionError 条就近提示) + lastDiffKey = '' + lastDiffContent = '' actionError.value = String(e) - } finally { - diffLoading.value = false + return } // 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件 if (selectedFile.value === path) { - emit('select-file', path, diffContent.value) + emit('select-file', path, diff) } } diff --git a/src/components/project/ProjectCard.vue b/src/components/project/ProjectCard.vue index 3cc82ad..f330957 100644 --- a/src/components/project/ProjectCard.vue +++ b/src/components/project/ProjectCard.vue @@ -24,7 +24,10 @@