From c480627ba6f32c331053a1481bd070f944506401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:10:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E6=89=B9A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF(timer=E6=89=80=E6=9C=89=E6=9D=83+=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=97=8F+=E5=AE=A1=E6=89=B9conv-scoped+FilePreview+=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8F=8D=E9=A6=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1-B1 timer所有权:新建 useTimerOwnership composable(每实例独立ref+onUnmounted清理)+ useToast _timer 下沉 + Ideas debounce 补清理(治跨实例串扰) A1-B2 FilePreview:reqSeq 双计数器守卫(loadFile/loadDiff 最新seq才写,防乱序覆盖)+ mermaid securityLevel strict + filePath 比对(防跨文件SVG注入) A1-B3 会话族:load_more 滚顶加载接线(switch透传has_more/earliest_seq+prepend去重+scrollTop恢复)+ switch失败保留视图+报错(仅对话不存在才create-new)+ new/delete失败反馈(withConvOp helper收敛)+ delete清ai_messages孤儿 A1-B5前端契约:isToolFailure 三处加 success===false 判定(useToolCard/ToolResultBody/ToolCard,git只读失败不再绿框) A2-B10 审批conv-scoped:pendingApprovals 补conversationId + cleanup 按convId filter + 移除全局清(AiError不再误清其他conv)+ :676 dir auth filter方向修 usage打标前端:is_estimated 字段+事件透传+MessageList『估算』角标(详情面板说明) A2-B9前端:clearChat try/catch 错误气泡 --- src/api/ai.ts | 13 + src/api/types.ts | 56 ++++ src/components/ToolCard.vue | 4 +- src/components/ToolResultBody.vue | 9 +- src/components/ai/MessageList.vue | 130 +++++++- src/components/project/FilePreview.vue | 79 ++++- src/composables/ai/useAiConversations.ts | 400 ++++++++++++++++------- src/composables/ai/useAiEvents.ts | 64 +++- src/composables/useTimerOwnership.ts | 60 ++++ src/composables/useToast.ts | 26 +- src/i18n/en/ai.ts | 3 + src/i18n/zh-CN/ai.ts | 3 + src/stores/ai.ts | 2 +- src/views/Ideas.vue | 165 +++++++++- 14 files changed, 837 insertions(+), 177 deletions(-) create mode 100644 src/composables/useTimerOwnership.ts diff --git a/src/api/ai.ts b/src/api/ai.ts index f6a85d1..0d66ab6 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -301,6 +301,19 @@ export const aiApi = { return invoke('ai_conversation_switch', { conversationId }) }, + /** + * 加载更早历史消息(G3.5 滚顶分页)。 + * beforeSeq=当前已加载最早消息的 seq(游标,后端返 earliest_seq),返回更早的 PAGE_SIZE 条。 + * 返 messages JSON 数组 + has_more(是否还有更早)+ earliest_seq(新游标,null=已到尽头)。 + */ + loadMoreMessages(conversationId: string, beforeSeq: number): Promise<{ + messages: string + has_more: boolean + earliest_seq: number | null + }> { + return invoke('ai_conversation_load_more', { conversationId, beforeSeq }) + }, + /** 删除对话 */ deleteConversation(conversationId: string): Promise { return invoke('ai_conversation_delete', { conversationId }) diff --git a/src/api/types.ts b/src/api/types.ts index 9b75ffe..8afeb77 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -175,10 +175,38 @@ export interface TaskRecord { output_json?: string /** 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。未关联时为 undefined。 */ idea_id?: string + /** + * 管理维度池标记(知识图谱 Phase 1,V29 列)。与 status(执行维度)正交。 + * 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。 + * 后端 DEFAULT 'todo' 保证老任务迁移后取值确定,前端始终收到该字段。 + */ + queue: string + /** 父任务 ID(1 级嵌套,无孙任务,由 IPC 校验)。顶层任务(无父)为 null/undefined。 */ + parent_id?: string | null + /** + * 结构化需求规格 JSON 字符串(知识图谱 Phase 1,V29 列)。 + * 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。 + * 老任务无结构化规格时为 undefined。 + */ + content_json?: string created_at: string updated_at: string } +export interface TaskTreeNode { + /** 父任务记录(含 parent_id/queue 等全字段) */ + parent: TaskRecord + /** 父任务的直接子任务(1 级嵌套,无孙任务) */ + children: TaskRecord[] +} + +/** delete_task 新返回结构(破坏性变更):删除父任务时级联软删其子任务 */ +export interface TaskDeleteResult { + ok: boolean + /** 级联软删的子任务数 */ + cascaded: number +} + export interface CreateTaskInput { project_id: ProjectId title: string @@ -188,6 +216,10 @@ export interface CreateTaskInput { assignee?: string /** 关联灵感 ID(1对1 单向,可空)。空字符串视为不关联(与后端一致)。 */ idea_id?: string + /** 管理池标记(backlog/todo/decision/active/done,可空)。不传后端默认 todo。 */ + queue?: string + /** 父任务 ID(1 级嵌套,可空)。顶层任务不传;空字符串后端视为 None。 */ + parent_id?: string | null } /** @@ -214,6 +246,10 @@ export interface TaskQuery { order_by?: string | null limit?: number | null offset?: number | null + /** 管理池标记精确匹配(backlog/todo/decision/active/done,可空) */ + queue?: string | null + /** 父任务 ID 精确匹配(取某父任务的全部子任务,可空) */ + parent_id?: string | null } // ============================================================ @@ -400,6 +436,12 @@ export type AiChatEvent = ({ prompt_cache_miss_tokens: number /** 思考(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型 */ reasoning_tokens: number + /** + * G4.3:该轮 token 用量是否估算值(后端 agentic 运行时兜底:round_usage.prompt_tokens==0 + * 时用 estimated_prompt 填充并打标)。true=per-response 估算(prompt 非真实,provider 未报), + * undefined/false=真实值。前端 MessageList 据此标注『估算』区分 estimated vs real。 + */ + is_estimated?: boolean incomplete?: boolean; pinned_goals?: GoalEntry[] } | { type: 'AiError'; error: string; error_type?: AiErrorType @@ -533,6 +575,11 @@ export interface AiMessage { cache_miss?: number /** 思考 token(隐藏输出) */ reasoning?: number + /** + * G4.3:该轮 token 用量是否估算值(后端 AiCompleted.is_estimated 透传)。 + * true=prompt_tokens 缺失按估算展示,MessageList 标注『估算』;undefined=false=真实值。 + */ + is_estimated?: boolean } /** * 本轮输入 token(消息级持久化,后端 ChatMessage.prompt_tokens 镜像)。 @@ -646,6 +693,15 @@ export interface AiToolCallInfo { /** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。 * ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */ path?: string + /** + * A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。 + * 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id + * (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。 + * cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器, + * 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。 + * undefined = 非挂起项或无法归属(保守保留,不清)。对齐 PendingDirAuth.conversationId。 + */ + conversationId?: string } /** 对话列表摘要 */ diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 3bbb994..9f9915c 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -213,6 +213,8 @@ const isFailed = computed(() => { if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r) return true } + // A1-B5:git 只读工具失败返 {success:false,error}(结构化非 Err),通用分支识别 + if (r && r.success === false) return true if (!r) { const raw = typeof tc.result === 'string' ? tc.result : '' if (!raw) return false @@ -251,7 +253,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff)) display: flex; align-items: center; gap: 6px; padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger); } -.ai-tool-rejected-icon { font-weight: 700; } +.ai-tool-rejected-icon { font-weight: 500; } /* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */ .ai-tool-card--collapsed .ai-tool-header { cursor: pointer; } diff --git a/src/components/ToolResultBody.vue b/src/components/ToolResultBody.vue index bfd2bfa..27e0571 100644 --- a/src/components/ToolResultBody.vue +++ b/src/components/ToolResultBody.vue @@ -19,7 +19,7 @@ - +
@@ -98,7 +98,7 @@
- +
@@ -277,6 +277,9 @@ const isFailed = computed(() => { if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r) return true } + // A1-B5: 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返 + // {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功 + if (r && r.success === false) return true if (!r) { const raw = typeof tc.result === 'string' ? tc.result : '' if (!raw) return false @@ -415,7 +418,7 @@ watch(() => props.tc.status, (s) => { .ai-tool-grep-match:last-child { border-bottom: none; } -/* BUG-260626-01:命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排), +/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排), 取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */ .ai-tool-grep-match-main { display: flex; diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 3632e59..9a43caf 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -20,6 +20,7 @@ import { useAiStore } from '../../stores/ai' import { useMarkdown } from '../../composables/useMarkdown' import { useMessageScroll } from '../../composables/ai/useMessageScroll' import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' +import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations' import ToolCardList from '../ToolCardList.vue' import MessageItem from './MessageItem.vue' import EmptyState from './EmptyState.vue' @@ -86,7 +87,7 @@ function isLastAi(msg: AiMessage): boolean { */ function shouldRenderMsg(msg: AiMessage): boolean { if (msg.role === 'assistant' - && !msg.content + && !msg.content?.trim() // 根因修复:!content 只拦空串漏 whitespace(空气泡) && !msg.isError && !(msg.toolCalls && msg.toolCalls.length) && !(isLastAi(msg) && store.state.streaming)) { @@ -130,6 +131,12 @@ function tokenOutOf(m: AiMessage): number { function tokenReasonOf(m: AiMessage): number { return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0 } +/** G4.3:该消息 token 用量是否为估算值(后端 AiCompleted.is_estimated 透传: + * round_usage.prompt_tokens==0 时用 estimated_prompt 兜底)。true 时主显示/详情标注『估算』, + * 让用户区分估算值 vs 真实用量。 */ +function tokenEstimated(m: AiMessage): boolean { + return m.tokenUsage?.is_estimated === true +} /** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */ function cacheHitRate(m: AiMessage): number | null { const hit = tokenCacheOf(m) @@ -144,7 +151,7 @@ function toggleTokenPopover(m: AiMessage, e: Event): void { tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id } -// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ── +// ── 滚动跟随 / 回到底部(跟随意图锁存,已抽取至 useMessageScroll) ── const { showBackToBottom, isFollowingBottom, @@ -171,6 +178,33 @@ const { scrollToBottom, }) +// ── G3.5:滚顶加载更早历史(load_more 分页) ── +// 后端 ai_conversation_switch 已返 has_more/earliest_seq 游标(useAiConversations 模块持有, +// 本组件经 getLoadMoreState 读同一引用)。滚顶到阈值触发 loadMoreHistory → IPC → prepend + +// 按 id 去重 + scrollTop 恢复(视口停留在原消息处不闪跳)。最小接线,不重构本组件。 +const loadMoreCursor = getLoadMoreState() +const LOAD_MORE_TOP_PX = 60 + +async function handleMessagesScroll(): Promise { + // 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持 + onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages))) + // G3.5:滚顶阈值触发加载更早历史 + const cur = loadMoreCursor + if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return + const el = messagesContainer.value + if (!el) return + if (el.scrollTop > LOAD_MORE_TOP_PX) return + const prevScrollHeight = el.scrollHeight + const inserted = await loadMoreHistory() + if (!inserted) return + const container = messagesContainer.value + if (!container) return + await nextTick() + // 恢复滚动:内容增高差 = 新 scrollHeight - 旧 scrollHeight,视口停留在原消息处不闪跳 + const diff = container.scrollHeight - prevScrollHeight + container.scrollTop = Math.max(diff, 0) +} + watch(() => store.state.messages.length, onContentChange) // SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行) @@ -182,7 +216,7 @@ watch(() => store.state.messages.length, onContentChange) // SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。 // 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。 watch(() => store.state.currentText, (text) => { - // B-260618-24: 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚), + // 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚), // 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。 // 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。 if (store.state.streaming && text) scheduleStreamParse(text) @@ -617,6 +651,33 @@ function shouldCollapseLong(msg: AiMessage): boolean { return true } +/** + * JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354 + * isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse + * 成功)时,弱化为灰小字折叠块(.ai-msg-json),不渲染 markdown 大气泡刷屏。 + * 仅完成/历史态生效:isLastAi && streaming 时 content 是流式累积的截断 JSON(JSON.parse + * 必失败/误判),仍走 currentText 分块渲染,本检测对流式态短路。 + */ +function isToolResultJson(content: string | undefined | null): boolean { + if (!content) return false + const t = content.trim() + if (!(t.startsWith('{') || t.startsWith('['))) return false + try { + JSON.parse(t) + return true + } catch { + return false + } +} +/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动) +const expandedJsonIds = ref>(new Set()) +function toggleJson(id: string): void { + const next = new Set(expandedJsonIds.value) + if (next.has(id)) next.delete(id) + else next.add(id) + expandedJsonIds.value = next +} + type RenderItem = | { kind: 'msg'; key: string; msg: AiMessage } | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } @@ -639,7 +700,7 @@ const renderItems = computed(() => { return items }) -// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。 +// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18)。 watch(() => store.state.activeConversationId, () => { // SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部) setFollowing(true) @@ -681,7 +742,7 @@ defineExpose({