优化: 批A前端(timer所有权+会话族+审批conv-scoped+FilePreview+失败反馈)

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 错误气泡
This commit is contained in:
lxy
2026-08-05 22:10:50 +08:00
parent 5667da6cf4
commit c480627ba6
14 changed files with 837 additions and 177 deletions
+13
View File
@@ -301,6 +301,19 @@ export const aiApi = {
return invoke('ai_conversation_switch', { conversationId }) 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<void> { deleteConversation(conversationId: string): Promise<void> {
return invoke('ai_conversation_delete', { conversationId }) return invoke('ai_conversation_delete', { conversationId })
+56
View File
@@ -175,10 +175,38 @@ export interface TaskRecord {
output_json?: string output_json?: string
/** 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。未关联时为 undefined。 */ /** 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。未关联时为 undefined。 */
idea_id?: string 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 created_at: string
updated_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 { export interface CreateTaskInput {
project_id: ProjectId project_id: ProjectId
title: string title: string
@@ -188,6 +216,10 @@ export interface CreateTaskInput {
assignee?: string assignee?: string
/** 关联灵感 ID(1对1 单向,可空)。空字符串视为不关联(与后端一致)。 */ /** 关联灵感 ID(1对1 单向,可空)。空字符串视为不关联(与后端一致)。 */
idea_id?: string 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 order_by?: string | null
limit?: number | null limit?: number | null
offset?: 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 prompt_cache_miss_tokens: number
/** 思考(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型 */ /** 思考(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型 */
reasoning_tokens: number 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[] incomplete?: boolean; pinned_goals?: GoalEntry[]
} | { } | {
type: 'AiError'; error: string; error_type?: AiErrorType type: 'AiError'; error: string; error_type?: AiErrorType
@@ -533,6 +575,11 @@ export interface AiMessage {
cache_miss?: number cache_miss?: number
/** 思考 token(隐藏输出) */ /** 思考 token(隐藏输出) */
reasoning?: number reasoning?: number
/**
* G4.3:该轮 token 用量是否估算值(后端 AiCompleted.is_estimated 透传)。
* true=prompt_tokens 缺失按估算展示,MessageList 标注『估算』;undefined=false=真实值。
*/
is_estimated?: boolean
} }
/** /**
* 本轮输入 token(消息级持久化,后端 ChatMessage.prompt_tokens 镜像)。 * 本轮输入 token(消息级持久化,后端 ChatMessage.prompt_tokens 镜像)。
@@ -646,6 +693,15 @@ export interface AiToolCallInfo {
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。 /** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */ * ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
path?: string path?: string
/**
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
* (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。
* cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器,
* 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。
* undefined = 非挂起项或无法归属(保守保留,不清)。对齐 PendingDirAuth.conversationId。
*/
conversationId?: string
} }
/** 对话列表摘要 */ /** 对话列表摘要 */
+3 -1
View File
@@ -213,6 +213,8 @@ const isFailed = computed(() => {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true if (r) return true
} }
// A1-B5:git 只读工具失败返 {success:false,error}(结构化非 Err),通用分支识别
if (r && r.success === false) return true
if (!r) { if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : '' const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false if (!raw) return false
@@ -251,7 +253,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger); 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) -- */ /* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; } .ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
+6 -3
View File
@@ -19,7 +19,7 @@
</button> </button>
</div> </div>
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches {line,content} file) --> <!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches {line,content} file) -->
<!-- BUG-260626-01:行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 --> <!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches"> <div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match"> <div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-main"> <div class="ai-tool-grep-match-main">
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 --> <!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
<!-- BUG-260626-01:文件名/行号/内容同一 flex row,不再 head + content 块分两行 --> <!-- 文件名/行号/内容同一 flex row,不再 head + content 块分两行 -->
<div v-else class="ai-tool-grep-matches"> <div v-else class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match"> <div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-main"> <div class="ai-tool-grep-match-main">
@@ -277,6 +277,9 @@ const isFailed = computed(() => {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0 if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true 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) { if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : '' const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false if (!raw) return false
@@ -415,7 +418,7 @@ watch(() => props.tc.status, (s) => {
.ai-tool-grep-match:last-child { .ai-tool-grep-match:last-child {
border-bottom: none; border-bottom: none;
} }
/* BUG-260626-01:命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排), /* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */ 取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
.ai-tool-grep-match-main { .ai-tool-grep-match-main {
display: flex; display: flex;
+121 -9
View File
@@ -20,6 +20,7 @@ import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown' import { useMarkdown } from '../../composables/useMarkdown'
import { useMessageScroll } from '../../composables/ai/useMessageScroll' import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer' import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import ToolCardList from '../ToolCardList.vue' import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue' import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue' import EmptyState from './EmptyState.vue'
@@ -86,7 +87,7 @@ function isLastAi(msg: AiMessage): boolean {
*/ */
function shouldRenderMsg(msg: AiMessage): boolean { function shouldRenderMsg(msg: AiMessage): boolean {
if (msg.role === 'assistant' if (msg.role === 'assistant'
&& !msg.content && !msg.content?.trim() // :!content whitespace()
&& !msg.isError && !msg.isError
&& !(msg.toolCalls && msg.toolCalls.length) && !(msg.toolCalls && msg.toolCalls.length)
&& !(isLastAi(msg) && store.state.streaming)) { && !(isLastAi(msg) && store.state.streaming)) {
@@ -130,6 +131,12 @@ function tokenOutOf(m: AiMessage): number {
function tokenReasonOf(m: AiMessage): number { function tokenReasonOf(m: AiMessage): number {
return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0 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 数据) */ /** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */
function cacheHitRate(m: AiMessage): number | null { function cacheHitRate(m: AiMessage): number | null {
const hit = tokenCacheOf(m) const hit = tokenCacheOf(m)
@@ -144,7 +151,7 @@ function toggleTokenPopover(m: AiMessage, e: Event): void {
tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id
} }
// / (B-260618-24 , useMessageScroll) // / (, useMessageScroll)
const { const {
showBackToBottom, showBackToBottom,
isFollowingBottom, isFollowingBottom,
@@ -171,6 +178,33 @@ const {
scrollToBottom, 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<void> {
// @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) watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText onContentChange scheduleStreamParse watch( currentText, callback ) // 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 ) // SW-260618-07: currentText watch( :2040 onContentChange + watch scheduleStreamParse )
// :onContentChange() scheduleStreamParse(rAF ), // :onContentChange() scheduleStreamParse(rAF ),
watch(() => store.state.currentText, (text) => { watch(() => store.state.currentText, (text) => {
// B-260618-24: scheduleStreamParse rAF (DOM ), // scheduleStreamParse rAF (DOM ),
// onContentChange nextTick( rAF, scrollHeight ) // onContentChange nextTick( rAF, scrollHeight )
// ; currentText () onContentChange // ; currentText () onContentChange
if (store.state.streaming && text) scheduleStreamParse(text) if (store.state.streaming && text) scheduleStreamParse(text)
@@ -617,6 +651,33 @@ function shouldCollapseLong(msg: AiMessage): boolean {
return true 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<Set<string>>(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 = type RenderItem =
| { kind: 'msg'; key: string; msg: AiMessage } | { kind: 'msg'; key: string; msg: AiMessage }
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } } | { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
@@ -639,7 +700,7 @@ const renderItems = computed<RenderItem[]>(() => {
return items return items
}) })
// UX-09:/;沿/(SW-260618-18/B-260618-24) // UX-09:/;沿/(SW-260618-18)
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 沿 + () // SW-260618-18: 沿 + ()
setFollowing(true) setFollowing(true)
@@ -681,7 +742,7 @@ defineExpose({
<template> <template>
<!-- Messages --> <!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))"> <div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) 提取至 EmptyState.vue --> <!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) 提取至 EmptyState.vue -->
<EmptyState <EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming" v-if="store.state.messages.length === 0 && !store.state.streaming"
@@ -755,9 +816,19 @@ defineExpose({
</div> </div>
<div class="ai-msg-content"> <div class="ai-msg-content">
<!-- 文本内容流式或固定Markdown 渲染 --> <!-- 文本内容流式或固定Markdown 渲染 -->
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 --> <!-- JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354):
assistant content 为纯 JSON 工具结果时弱化为灰小字折叠块(.ai-msg-json),
不渲染 markdown 大气泡刷屏仅完成/历史态生效:isLastAi && streaming content
是流式累积的截断 JSON(JSON.parse 必失败/误判),仍走下方流式分块渲染
UX-2025-01:流式分块v-for,已完成块DOM稳定不重建选文字保持 -->
<div <div
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)" v-if="isToolResultJson(item.msg.content) && !(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-json"
:class="{ 'ai-msg-json--expanded': expandedJsonIds.has(item.msg.id) }"
@click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></div>
<div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md" class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }" :class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)" :key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
@@ -827,7 +898,7 @@ defineExpose({
:aria-expanded="tokenPopoverMsgId === item.msg.id" :aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)" @click="toggleTokenPopover(item.msg, $event)"
> >
<span>{{ formatTokens(tokenInOf(item.msg)) }} in</span> <span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></span>
<span class="ai-token-sep">·</span> <span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span> <span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
<span class="ai-token-sep">·</span> <span class="ai-token-sep">·</span>
@@ -843,7 +914,7 @@ defineExpose({
class="ai-token-popover" class="ai-token-popover"
@click.stop @click.stop
> >
<div class="ai-token-popover-title">Token 用量详情</div> <div class="ai-token-popover-title">Token 用量详情<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></div>
<div class="ai-token-popover-row"> <div class="ai-token-popover-row">
<span class="ai-token-popover-label">输入(未命中,全价)</span> <span class="ai-token-popover-label">输入(未命中,全价)</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span> <span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
@@ -868,6 +939,10 @@ defineExpose({
<span class="ai-token-popover-label">模型</span> <span class="ai-token-popover-label">模型</span>
<span class="ai-token-popover-val">{{ item.msg.model }}</span> <span class="ai-token-popover-val">{{ item.msg.model }}</span>
</div> </div>
<div v-if="tokenEstimated(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">用量说明</span>
<span class="ai-token-popover-val">prompt_tokens 缺失,按估算展示</span>
</div>
</div> </div>
</div> </div>
@@ -1066,6 +1141,38 @@ defineExpose({
background: var(--df-bg-card-hover); background: var(--df-bg-card-hover);
} }
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
不渲染 markdown 大气泡刷屏默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容 */
.ai-msg-json {
max-height: 80px;
overflow-y: auto;
padding: 6px 10px;
border-radius: var(--df-radius);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.5;
color: #b0b0b0;
cursor: pointer;
user-select: none;
}
.ai-msg-json pre {
margin: 0;
font: inherit;
white-space: pre-wrap;
word-break: break-all;
}
.ai-msg-json--expanded {
max-height: none;
overflow: visible;
}
.ai-msg-json:hover {
border-color: var(--df-accent);
}
/* Input Augmentation: mention chip(, ChatInput .ai-skill-chip-name ) /* Input Augmentation: mention chip(, ChatInput .ai-skill-chip-name )
用户气泡背景 = --df-accent(主题强调色,如深色蓝/),chip 用半透明白底+深字 保证对比度; 用户气泡背景 = --df-accent(主题强调色,如深色蓝/),chip 用半透明白底+深字 保证对比度;
kind 区分颜色变体(project/task/idea/skill), @ mention 浮层 .ai-mention-item-type--* 视觉呼应 */ kind 区分颜色变体(project/task/idea/skill), @ mention 浮层 .ai-mention-item-type--* 视觉呼应 */
@@ -1261,6 +1368,11 @@ defineExpose({
.ai-token-sep { .ai-token-sep {
opacity: 0.5; opacity: 0.5;
} }
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
.ai-token-est {
color: var(--df-warning, #f0c75e);
opacity: 0.85;
}
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */ /* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
.ai-token-popover { .ai-token-popover {
position: absolute; position: absolute;
+73 -6
View File
@@ -72,8 +72,8 @@
</div> </div>
</div> </div>
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) --> <!-- Markdown 渲染(marked + DOMPurify + 代码高亮;mermaid 块渲染后转成图) -->
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div> <div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div>
<!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) --> <!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) -->
<div v-else class="preview-code-scroll"> <div v-else class="preview-code-scroll">
@@ -87,7 +87,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onUnmounted } from 'vue' import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import { moduleApi } from '@/api/module' import { moduleApi } from '@/api/module'
import hljs from 'highlight.js/lib/common' import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown' import { useMarkdown, useRendered } from '@/composables/useMarkdown'
@@ -127,6 +127,14 @@ const showDiff = ref(false)
const diffContent = ref('') const diffContent = ref('')
const diffLoading = ref(false) const diffLoading = ref(false)
/** :loadFile/loadDiff ,await " seq",
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)
* :loadFile loadDiff 分用两个计数器 若共用一个,loadDiff 自增会让在途的 loadFile
* 变为 stale, finally 不再复位 loading,导致内容加载态卡死分开后各自独立互不干扰;
* 文件切换(watch)时额外自增 diffReqSeq,使在途 diff 请求对旧文件失效 */
let fileReqSeq = 0 // loadFile ()
let diffReqSeq = 0 // loadDiff ()
interface DiffLine { interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr' type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string prefix: string
@@ -179,14 +187,17 @@ function toggleDiff() {
async function loadDiff() { async function loadDiff() {
if (!props.moduleId || !props.filePath) return if (!props.moduleId || !props.filePath) return
const seq = ++diffReqSeq //
diffLoading.value = true diffLoading.value = true
try { try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath) const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
if (seq !== diffReqSeq) return // ,
diffContent.value = res.diff || '' diffContent.value = res.diff || ''
} catch { } catch {
if (seq !== diffReqSeq) return
diffContent.value = '' diffContent.value = ''
} finally { } finally {
diffLoading.value = false if (seq === diffReqSeq) diffLoading.value = false
} }
} }
@@ -222,6 +233,48 @@ const isMarkdown = computed(() => {
/** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */ /** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */
const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value) const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value)
// Mermaid :markdown ```mermaid ( import, bundle)
const previewMdRef = ref<HTMLElement | null>(null)
let mermaidInstance: any = null
let mermaidSeq = 0
async function renderMermaidBlocks() {
const el = previewMdRef.value
if (!el) return
const targetPath = props.filePath // ,
const blocks = el.querySelectorAll<HTMLElement>('pre code.language-mermaid')
if (blocks.length === 0) return
if (!mermaidInstance) {
const mod = await import('mermaid')
mermaidInstance = mod.default
// securityLevel 'strict':mermaid ( raw HTML/URL ), XSS
// :node HTML label( <br/>/HTML ) 'strict' ,
// 'loose' (,)
mermaidInstance.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'strict' })
}
// mermaid ( DOM ),
if (props.filePath !== targetPath) return
for (const block of Array.from(blocks)) {
if (block.closest('.mermaid-rendered')) continue
const code = (block.textContent ?? '').trim()
if (!code) continue
try {
const { svg } = await mermaidInstance.render(`mermaid-file-${++mermaidSeq}`, code)
if (props.filePath !== targetPath) return // ,
const holder = document.createElement('div')
holder.className = 'mermaid-rendered'
holder.innerHTML = svg
block.closest('pre')?.replaceWith(holder)
} catch (e) {
if (props.filePath !== targetPath) return
console.error('[FilePreview] mermaid 渲染失败:', e)
}
}
}
watch(renderedMd, async () => {
await nextTick()
await renderMermaidBlocks()
}, { immediate: true })
const isImage = computed(() => { const isImage = computed(() => {
if (!props.filePath) return false if (!props.filePath) return false
const lower = props.filePath.toLowerCase() const lower = props.filePath.toLowerCase()
@@ -230,6 +283,7 @@ const isImage = computed(() => {
/** 拉文件内容 + 高亮渲染。 */ /** 拉文件内容 + 高亮渲染。 */
async function loadFile() { async function loadFile() {
const seq = ++fileReqSeq // (使)
if (!props.filePath) { if (!props.filePath) {
content.value = '' content.value = ''
htmlContent.value = '' htmlContent.value = ''
@@ -248,6 +302,7 @@ async function loadFile() {
} }
try { try {
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath) const res = await moduleApi.readModuleFile(props.moduleId, props.filePath)
if (seq !== fileReqSeq) return // ,
fileSize.value = res.size fileSize.value = res.size
truncated.value = res.truncated truncated.value = res.truncated
isBinary.value = res.is_binary isBinary.value = res.is_binary
@@ -261,10 +316,11 @@ async function loadFile() {
htmlContent.value = '' htmlContent.value = ''
try { try {
const { convertFileSrc } = await import('@tauri-apps/api/core') const { convertFileSrc } = await import('@tauri-apps/api/core')
if (seq !== fileReqSeq) return // import ,
const abs = joinPath(props.moduleRootPath, props.filePath) const abs = joinPath(props.moduleRootPath, props.filePath)
imageUrl.value = convertFileSrc(abs) imageUrl.value = convertFileSrc(abs)
} catch { } catch {
imageUrl.value = null if (seq === fileReqSeq) imageUrl.value = null
} }
} else { } else {
content.value = res.content content.value = res.content
@@ -283,9 +339,10 @@ async function loadFile() {
} }
} }
} catch (e) { } catch (e) {
if (seq !== fileReqSeq) return //
error.value = e instanceof Error ? e.message : String(e) error.value = e instanceof Error ? e.message : String(e)
} finally { } finally {
loading.value = false if (seq === fileReqSeq) loading.value = false
} }
} }
@@ -321,8 +378,10 @@ function gitStatusLabel(status: string): string {
} }
watch(() => [props.moduleId, props.filePath], () => { watch(() => [props.moduleId, props.filePath], () => {
diffReqSeq++ // ,使 diff ( diff )
showDiff.value = false showDiff.value = false
diffContent.value = '' diffContent.value = ''
diffLoading.value = false
loadFile() loadFile()
}, { immediate: true }) }, { immediate: true })
@@ -438,6 +497,14 @@ onUnmounted(() => {
border-radius: var(--df-radius-sm, 4px); border-radius: var(--df-radius-sm, 4px);
} }
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
.preview-md {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 16px;
}
.preview-code { .preview-code {
margin: 0; margin: 0;
padding: 14px 16px; padding: 14px 16px;
+280 -120
View File
@@ -65,9 +65,22 @@ async function loadConversations() {
/** 新建空对话并切过去 */ /** 新建空对话并切过去 */
async function newConversation() { async function newConversation() {
const result = await aiApi.createConversation() // G3.4:收敛进会话操作族 withConvOp——原裸 await 失败抛 unhandled rejection 用户无感,
// 现失败推错误气泡 + 保持当前视图。新建无乐观本地变化(后端生成 id,失败无副作用)。
const result = await withConvOp(
() => {},
() => aiApi.createConversation(),
() => {},
'newConvFail',
)
if (!result) return // 失败已推气泡,保持当前视图
state.activeConversationId = result.id state.activeConversationId = result.id
void appSettings.set('df-ai-active-conv', result.id) void appSettings.set('df-ai-active-conv', result.id)
// G3.5:新对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = result.id
loadMoreCursor.loading = false
state.messages = [] state.messages = []
state.currentText = '' state.currentText = ''
state.pendingApprovals = [] state.pendingApprovals = []
@@ -87,6 +100,138 @@ async function newConversation() {
// 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1) // 切换 token:快速连点 A→B 时,后返回的 A 响应按 token 丢弃,防 messages 错配(FR-R1)
let _latestSwitchId = 0 let _latestSwitchId = 0
// ============================================================
// G3.5 load_more 分页(滚顶加载更早历史)
// ============================================================
//
// 后端 ai_conversation_switch 已返 has_more/earliest_seq(最近 50 条之外的更早历史游标),
// 本模块持游标;MessageList 滚顶阈值触发 loadMoreHistory → IPC → prepend + 按 id 去重。
// 普通对象(非响应式):仅滚动事件触发时读,无需渲染追踪。
const loadMoreCursor: {
hasMore: boolean
earliestSeq: number | null
loading: boolean
convId: string | null
} = { hasMore: false, earliestSeq: null, loading: false, convId: null }
/** 读 load_more 游标(MessageList 滚动处理器用;返回同一引用,读到的即最新态) */
function getLoadMoreState() {
return loadMoreCursor
}
/**
* ChatMessage[] AiMessage[](switch + load_more )
* / switchConversation (tool + truncated
* token parts tool_calls tool_result)
* id:优先后端真实消息 id(DB ,prepend v-for key ), `${fallbackPrefix}-${i}`
* ( id / messages JSON )fallbackPrefix switch('loaded') load_more('older'),
* id prepend
*/
function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[] {
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
const toolResultMap = new Map<string, string>()
for (const m of rawMsgs) {
if (m.role === 'tool' && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, m.content || '')
}
}
return rawMsgs
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({
id: (m.id && m.id !== '') ? m.id : `${fallbackPrefix}-${i}`,
role: m.role,
content: m.content || '',
model: m.model,
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
// 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null
? {
prompt: m.prompt_tokens,
completion: m.completion_tokens ?? 0,
cache_hit: m.prompt_cache_hit_tokens,
cache_miss: m.prompt_cache_miss_tokens,
reasoning: m.reasoning_tokens,
}
: undefined,
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
prompt_cache_hit_tokens: m.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: m.prompt_cache_miss_tokens,
reasoning_tokens: m.reasoning_tokens,
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>。
parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined,
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
// 供 AiChat.vue 按 status 折叠分组渲染。
status: m.status,
toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
let args: unknown = {}
const rawArgs = tc.function?.arguments
if (typeof rawArgs === 'string') {
try {
args = rawArgs ? JSON.parse(rawArgs) : {}
} catch {
args = {}
}
} else if (rawArgs && typeof rawArgs === 'object') {
args = rawArgs
}
return {
id: tc.id,
name: tc.function?.name || '',
args,
status: 'completed' as const,
result: toolResultMap.get(tc.id),
}
}),
}))
}
/**
* G3.5:加载更早历史消息并 prepend
* (hasMore/loading/earliestSeq/convId)+ (convId active )
* (MessageList scrollTop)
*/
async function loadMoreHistory(): Promise<boolean> {
const cur = loadMoreCursor
if (!cur.convId || cur.earliestSeq == null || cur.loading || !cur.hasMore) return false
cur.loading = true
try {
const res = await aiApi.loadMoreMessages(cur.convId, cur.earliestSeq)
// 过期丢弃:等待期间用户切走/删除会话,游标已指向别的 conv → 不 prepend 错视图
if (cur.convId !== state.activeConversationId) return false
const rawMsgs = typeof res.messages === 'string' ? JSON.parse(res.messages) : res.messages
const older = parseConvMessages(rawMsgs, 'older')
// 按 id 去重(防御:游标边界变化 / 与已加载消息重复)
const existing = new Set(state.messages.map(m => m.id))
const fresh = older.filter(m => !existing.has(m.id))
if (fresh.length === 0) {
// 无新消息(极端重复):推进游标但不算"插入",防 MessageList 恢复滚动误判
cur.hasMore = res.has_more ?? false
if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq
return false
}
state.messages = [...fresh, ...state.messages]
cur.hasMore = res.has_more ?? false
if (res.earliest_seq != null) cur.earliestSeq = res.earliest_seq
return true
} catch (e) {
console.error('[AI] 加载更多历史消息失败:', e)
return false
} finally {
cur.loading = false
}
}
/** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */ /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */
export async function switchConversation(id: string) { export async function switchConversation(id: string) {
// AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。 // AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。
@@ -97,22 +242,45 @@ export async function switchConversation(id: string) {
let detail: AiConversationDetail let detail: AiConversationDetail
try { try {
detail = await aiApi.switchConversation(id) detail = await aiApi.switchConversation(id)
} catch { } catch (e) {
// 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代 // G3.3:区分 Err 形态——仅"对话不存在"(已删除/未落库的虚 ID)才 create-new 兜底;
console.warn('[AI] switchConversation 失败,创建新对话:', id) // 瞬态 IPC 失败(网络抖动/后端异常)保留原视图 + 推错误气泡,不再吞当前视图。
const created = await aiApi.createConversation() const errMsg = e instanceof Error ? e.message : String(e)
if (errMsg.includes('对话不存在')) {
console.warn('[AI] switchConversation 失败(对话不存在),创建新对话:', id)
const created = await aiApi.createConversation()
if (mySwitchId !== _latestSwitchId) return
void appSettings.set('df-ai-active-conv', created.id)
// 用新对话 id 重走后续逻辑
detail = { id: created.id as ConvId, title: null, messages: '[]' }
// G3.5:新建对话无历史,load_more 游标复位
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = created.id
loadMoreCursor.loading = false
state.messages = []
notifyConversationChanged()
void loadConversations()
return
}
// 瞬态失败:保留当前视图 + 错误气泡(复用 M30 pushConvOpFail 模式);过期响应丢弃。
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return
void appSettings.set('df-ai-active-conv', created.id) pushConvOpFail('switchConvFail')
// 用新对话 id 重走后续逻辑
detail = { id: created.id as ConvId, title: null, messages: '[]' }
state.messages = []
notifyConversationChanged()
void loadConversations() void loadConversations()
return return
} }
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return
state.activeConversationId = id state.activeConversationId = id
// G3.5:透传 has_more/earliest_seq 游标(前端滚顶加载更多历史)。
// 后端在 generating/空对话等场景返 false/null,此处默认兜底。
const switchDetailAny = detail as any
loadMoreCursor.hasMore = switchDetailAny.has_more ?? false
loadMoreCursor.earliestSeq = switchDetailAny.earliest_seq ?? null
loadMoreCursor.convId = id
loadMoreCursor.loading = false
void appSettings.set('df-ai-active-conv', id) void appSettings.set('df-ai-active-conv', id)
// P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算, // P1#6 技术债审查(2026-06-21):streaming 是全局单值,切到非生成会话需按目标 conv 生成态重算,
// 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId // 否则残留 stop 按钮(ChatInput.vue:88 v-if=streaming)→ 点击 store.stopChat 传 activeConversationId
@@ -128,75 +296,10 @@ export async function switchConversation(id: string) {
const rawMsgs = typeof detail.messages === 'string' const rawMsgs = typeof detail.messages === 'string'
? JSON.parse(detail.messages) ? JSON.parse(detail.messages)
: detail.messages : detail.messages
// G3.5:消息映射收敛进 parseConvMessages(switch + load_more 共用,过滤/映射语义一致)。
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果 // 相比原内联逻辑唯一行为变化:id 由 `loaded-${i}` 改为优先后端真实消息 id
const toolResultMap = new Map<string, string>() // (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
for (const m of rawMsgs) { state.messages = parseConvMessages(rawMsgs, 'loaded')
if (m.role === 'tool' && m.tool_call_id) {
toolResultMap.set(m.tool_call_id, m.content || '')
}
}
state.messages = rawMsgs
// 过滤 tool 消息 + truncated 软删消息(UX-09:编辑某条 user 后其后消息标 truncated,
// 保留 DB 可追溯但从视图移除;前端无 status 字段故按原始 JSON 字段过滤)
.filter((m: any) => m.role !== 'tool' && m.status !== 'truncated')
.map((m: any, i: number) => ({
id: `loaded-${i}`,
role: m.role,
content: m.content || '',
model: m.model,
// 用后端持久化的真实时间(BUG-260618-01);老数据无 timestamp 字段回退 Date.now()
timestamp: typeof m.timestamp === 'number' ? m.timestamp : Date.now(),
// 消息级 token 回显:assistant 消息若 DB 持久化了 prompt_tokens/completion_tokens
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
// 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null
? {
prompt: m.prompt_tokens,
completion: m.completion_tokens ?? 0,
cache_hit: m.prompt_cache_hit_tokens,
cache_miss: m.prompt_cache_miss_tokens,
reasoning: m.reasoning_tokens,
}
: undefined,
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
prompt_cache_hit_tokens: m.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: m.prompt_cache_miss_tokens,
reasoning_tokens: m.reasoning_tokens,
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
// 故历史 parts 通常仅含 url 模式 Image 或纯 Text)。
parts: Array.isArray(m.parts) && m.parts.length > 0 ? m.parts : undefined,
// F-15 阶段2: 透传 status(archived_segment/compressed/null|active),
// 供 AiChat.vue 按 status 折叠分组渲染。types.ts 未含此字段(不在本任务白名单),
// 经 as any 透传,消费方 AiChat.vue 同样 cast 读取,类型闭环在两端。
status: m.status,
toolCalls: m.tool_calls?.map((tc: any): AiToolCallInfo => {
// 逐条容错:单条坏 arguments 仅降级为空对象,不影响整条对话回填
let args: unknown = {}
const rawArgs = tc.function?.arguments
if (typeof rawArgs === 'string') {
try {
args = rawArgs ? JSON.parse(rawArgs) : {}
} catch {
args = {}
}
} else if (rawArgs && typeof rawArgs === 'object') {
args = rawArgs
}
return {
id: tc.id,
name: tc.function?.name || '',
args,
status: 'completed' as const,
result: toolResultMap.get(tc.id),
}
}),
}))
} catch (e) { } catch (e) {
// UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。 // UX-260617-08:历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。 // 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
@@ -260,6 +363,9 @@ export async function switchConversation(id: string) {
path: tc.path, path: tc.path,
dir: tc.dir, dir: tc.dir,
reason: tc.reason, reason: tc.reason,
// A2-B10 conv-scoped 审批收尾:恢复的历史挂起带目标会话 id,
// cleanupTerminatedConversation 按此仅清本会话的待审批项(不连累并发会话)。
conversationId: id,
}) })
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过; // 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
@@ -276,27 +382,66 @@ export async function switchConversation(id: string) {
} }
} }
/** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */ /** ;+ id
* G3.4:收敛进 withConvOp( + + ), await unhandled rejection */
async function deleteConversation(id: string) { async function deleteConversation(id: string) {
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + // AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
// 错误气泡推错视图)。 // 错误气泡推错视图)。
clearAllApprovalTimers() clearAllApprovalTimers()
await aiApi.deleteConversation(id) const conv = state.conversations.find(c => c.id === id)
const prevIndex = conv ? state.conversations.indexOf(conv) : -1
const wasActive = state.activeConversationId === id
const prevActive = state.activeConversationId
const prevMessages = state.messages
const ok = await withConvOp(
() => {
// 乐观:从列表移除 + 若删的是活跃会话则清空视图
state.conversations = state.conversations.filter(c => c.id !== id)
if (wasActive) {
state.activeConversationId = null
state.messages = []
}
},
() => aiApi.deleteConversation(id),
() => {
// 回滚:恢复列表原位置 + 恢复活跃会话与消息视图(失败保留原视图)
if (conv) {
const list = [...state.conversations]
list.splice(prevIndex === -1 ? list.length : Math.min(prevIndex, list.length), 0, conv)
state.conversations = list
}
if (wasActive) {
state.activeConversationId = prevActive
state.messages = prevMessages
}
},
'deleteConvFail',
)
// 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败)
if (ok === null) return // 失败已回滚 + 推气泡,保持原视图
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长 // F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。 // (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
clearConvStreamState(id) clearConvStreamState(id)
if (state.activeConversationId === id) { if (wasActive) {
state.activeConversationId = null
void appSettings.remove('df-ai-active-conv') void appSettings.remove('df-ai-active-conv')
state.messages = [] // G3.5:删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = null
loadMoreCursor.loading = false
} }
await loadConversations() await loadConversations()
notifyConversationChanged() notifyConversationChanged()
} }
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */
type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
/** M30: IPC ( loadConversations/switchConversation ), /** M30: IPC ( loadConversations/switchConversation ),
* (, store try unhandledrejection) */ * (, store try unhandledrejection) */
function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail', params?: Record<string, string | number>) { function pushConvOpFail(key: ConvOpFailKey, params?: Record<string, string | number>) {
state.messages.push({ state.messages.push({
id: `conv-op-fail-${nextMsgId()}`, id: `conv-op-fail-${nextMsgId()}`,
role: 'assistant', role: 'assistant',
@@ -307,56 +452,70 @@ function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail
notifyConversationChanged() notifyConversationChanged()
} }
/** helper(G3.4): + +
* =(rename/archive/pin/new/delete),
* apply:乐观本地变更(,);op:后端 IPC;rollback:失败恢复 apply ;
* failKey:错误气泡 i18n key;params:i18n
* op , null(,) */
async function withConvOp<T>(
apply: () => void,
op: () => Promise<T>,
rollback: () => void,
failKey: ConvOpFailKey,
params?: Record<string, string | number>,
): Promise<T | null> {
apply()
notifyConversationChanged()
try {
return await op()
} catch (e) {
console.error('[AI] 会话操作失败:', e)
rollback()
notifyConversationChanged()
pushConvOpFail(failKey, params)
return null
}
}
/** ( + ) /** ( + )
* M30:乐观更新( IPC + ) IPC ,IPC * M30/G3.4:乐观更新 + + ( withConvOp) */
* store try/catch → unhandledrejection,用户无感。改乐观更新让失败可见可回滚。 */
async function renameConversation(id: string, title: string) { async function renameConversation(id: string, title: string) {
const conv = state.conversations.find(c => c.id === id) const conv = state.conversations.find(c => c.id === id)
const prevTitle = conv?.title ?? null const prevTitle = conv?.title ?? null
if (conv) conv.title = title await withConvOp(
notifyConversationChanged() () => { if (conv) conv.title = title },
try { () => aiApi.renameConversation(id, title),
await aiApi.renameConversation(id, title) () => { if (conv) conv.title = prevTitle },
} catch (e) { 'renameConvFail',
console.error('[AI] 重命名会话失败:', e) )
if (conv) conv.title = prevTitle
notifyConversationChanged()
pushConvOpFail('renameConvFail')
}
} }
/** /( + ) /** /( + )
* M30:乐观更新 + + ( renameConversation) */ * M30/G3.4:乐观更新 + + ( withConvOp) */
async function archiveConversation(id: string, archived: boolean) { async function archiveConversation(id: string, archived: boolean) {
const conv = state.conversations.find(c => c.id === id) const conv = state.conversations.find(c => c.id === id)
const prevArchived = conv?.archived ?? false const prevArchived = conv?.archived ?? false
if (conv) conv.archived = archived await withConvOp(
notifyConversationChanged() () => { if (conv) conv.archived = archived },
try { () => aiApi.archiveConversation(id, archived),
await aiApi.archiveConversation(id, archived) () => { if (conv) conv.archived = prevArchived },
} catch (e) { 'archiveConvFail',
console.error('[AI] 归档会话失败:', e) { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') },
if (conv) conv.archived = prevArchived )
notifyConversationChanged()
pushConvOpFail('archiveConvFail', { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') })
}
} }
/** /( + ;UX-17) /** /( + ;UX-17)
* M30:乐观更新 + + ( renameConversation) */ * M30/G3.4:乐观更新 + + ( withConvOp) */
async function setPinnedConversation(id: string, pinned: boolean) { async function setPinnedConversation(id: string, pinned: boolean) {
const conv = state.conversations.find(c => c.id === id) const conv = state.conversations.find(c => c.id === id)
const prevPinned = conv?.pinned ?? false const prevPinned = conv?.pinned ?? false
if (conv) conv.pinned = pinned await withConvOp(
notifyConversationChanged() () => { if (conv) conv.pinned = pinned },
try { () => aiApi.setPinnedConversation(id, pinned),
await aiApi.setPinnedConversation(id, pinned) () => { if (conv) conv.pinned = prevPinned },
} catch (e) { 'pinConvFail',
console.error('[AI] 置顶会话失败:', e) { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') },
if (conv) conv.pinned = prevPinned )
notifyConversationChanged()
pushConvOpFail('pinConvFail', { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') })
}
} }
/** 折叠/展开归档分组 */ /** 折叠/展开归档分组 */
@@ -386,10 +545,11 @@ export function useAiConversations() {
renameConversation, renameConversation,
archiveConversation, archiveConversation,
setPinnedConversation, setPinnedConversation,
loadMoreHistory,
toggleArchivedFold, toggleArchivedFold,
toggleGroupFold, toggleGroupFold,
toggleSidebar, toggleSidebar,
} }
} }
export { loadConversations } export { loadConversations, loadMoreHistory, getLoadMoreState }
+54 -10
View File
@@ -208,7 +208,14 @@ export function friendlyError(raw: string): string {
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */ /** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */
export function flushCurrentText() { export function flushCurrentText() {
if (!state.currentText) return // 根因修复(空气泡):只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格,
// 累积成 whitespace-only currentText 后写进占位 content,前端 !content 真值判断漏过
// → 渲染带边框空气泡。trim 兜底空白,空白流式文本不回填(无实质内容)。
if (!state.currentText || !state.currentText.trim()) {
state.currentText = ''
_lastDelta = '' // 同步复位 delta 跟踪(防下一轮首个 delta 误判重复)
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡 // 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡
// (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。 // (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = state.messages.length - 1; i >= 0; i--) {
@@ -416,6 +423,8 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
kind: 'path', kind: 'path',
dir: event.dir, dir: event.dir,
path: event.path, path: event.path,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
// path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案); // path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案);
// 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。 // 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }), reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
@@ -525,6 +534,9 @@ function handleToolEvent(event: AiChatEvent): boolean {
// tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall, // tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall,
// 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。 // 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。
reason: event.reason, reason: event.reason,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
// 事件必带 conversation_id(缺省兜底当前活跃会话——AiApprovalRequired 仅当 isCurrent 才到达此处)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
} }
state.pendingApprovals.push(info) state.pendingApprovals.push(info)
const tc = findToolCall(event.id) const tc = findToolCall(event.id)
@@ -605,6 +617,16 @@ function handleConvStateEvent(event: AiChatEvent): boolean {
* 返回值:true= AiUserMessage;false= */ * 返回值:true= AiUserMessage;false= */
function handleUserMessageEvent(event: AiChatEvent): boolean { function handleUserMessageEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiUserMessage') return false if (event.type !== 'AiUserMessage') return false
// 多会话并行隔离(2026-08-05 BUG-260805-02):AiUserMessage 必带 conversation_id(后端已 resolve)。
// - 当前会话(或 null)→ push user 气泡(微信端当前会话消息,桌面若同会话直接展示)
// - 非当前会话 → 不污染当前视图(桌面在并行看别的会话),仅刷新会话列表
// (侧栏显示该会话有新消息;用户切过去 switchConversation 从 DB 加载完整历史)。
// 旧实现无条件 push 到 state.messages → 非当前会话的 user 消息混入当前视图(历史内容被"污染")。
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations()
return true
}
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。 // 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
const last = state.messages[state.messages.length - 1] const last = state.messages[state.messages.length - 1]
if (last && last.role === 'user' && last.content === event.message) { if (last && last.role === 'user' && last.content === event.message) {
@@ -622,11 +644,12 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
/** /**
* ( #6 DRY ):AiCompleted / AiError / AiHelpRequired * ( #6 DRY ):AiCompleted / AiError / AiHelpRequired
* ///currentText/agentRound/per-conv * ///currentText/agentRound/per-conv
* (localStorage + pendingMaxRounds + pendingDirAuths + convStates) * (localStorage + pendingMaxRounds + pendingDirAuths + convStates +
* A2-B10 conv-scoped 审批收尾:pendingApprovals + convId )
* *
* (): * ():
* - AiCompleted: 调用后追加 incomplete / token / drain * - AiCompleted: 调用后追加 incomplete / token / drain
* - AiError: 调用后追加错误气泡 + clearAllApprovalTimers + queue/pendingApprovals * - AiError: 调用后追加错误气泡 + queue( cleanup conv-scoped )
* - AiHelpRequired: 调用后翻 pendingHelp * - AiHelpRequired: 调用后翻 pendingHelp
*/ */
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') { function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
@@ -635,6 +658,15 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留) clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText() flushCurrentText()
state.currentText = '' state.currentText = ''
// 清「重试中」过渡气泡(成功后残留根治,与 miniapp clearRetryBubbles 对齐)。
// AiStreamRetry 推的 retry 气泡(isError,content 含「正在重试(」)是过渡态,终态不清会
// 永久残留列表(flushCurrentText 只写非 error 气泡)。这里按 i18n 文案前缀清除。
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) {
state.messages.splice(i, 1)
}
}
setStreaming(false, { convId: convId || null, reason }) setStreaming(false, { convId: convId || null, reason })
state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条) state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条)
@@ -657,7 +689,18 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
} }
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。 // TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。 // F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === convId) // A2-B10 修正:终止语义=清本 conv(该会话已结束),保其他 conv;无归属旧项保守保留。
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// A2-B10 conv-scoped 审批收尾:仅清目标 conv 的待审批项 + 对称清其审批超时计时器。
// 对齐上方 pendingDirAuths per-conv filter 范例。conversationId 缺失的旧项(无法归属)
// 保守保留——误清其他 conv 正在审批的卡 = 回归;仅清 conversationId===convId 的项。
// 与旧全局清(AiError/AiHelpRequired 分支 state.pendingApprovals = [] + clearAllApprovalTimers)
// 的差异:此处按 convId 收敛,其他会话的审批卡/计时器不受影响。
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
// 对称清本 conv 被移除项的审批超时计时器(id 集合),防到点回调改已终止会话 state。
for (const p of removedApprovals) clearApprovalTimer(p.id)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key) // F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
if (convId) { if (convId) {
@@ -721,6 +764,7 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
cache_hit: event.prompt_cache_hit_tokens, cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens, cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens, reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
} }
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
@@ -737,16 +781,15 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
case 'AiError': { case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError') cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 // A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
// cleanupTerminatedConversation 按 convId 收敛(不再全局 clearAllApprovalTimers /
// state.pendingApprovals = [],避免连累并发会话正在审批的卡)。
// 只清除出错会话的队列项,不误伤其他会话的排队消息 // 只清除出错会话的队列项,不误伤其他会话的排队消息
if (event.conversation_id) { if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else { } else {
state.queue = [] state.queue = []
} }
// UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval,
// 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。
state.pendingApprovals = []
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。 // UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展; // AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。 // 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
@@ -770,8 +813,9 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard) // 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。 // 显 reason + options 按钮供用户选。
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired') cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。 // A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
state.pendingApprovals = [] // cleanupTerminatedConversation 按 convId 收敛(不再全局 state.pendingApprovals = [],
// 避免连累并发会话正在审批的卡)。
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。 // 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
pendingHelp.value = { pendingHelp.value = {
reason: event.reason, reason: event.reason,
+60
View File
@@ -0,0 +1,60 @@
//! 每实例独立的定时器所有权 composable。
//!
//! 背景:模块级/组件级 let timer 单例共享(如 useToast 原模块级 _timer)会跨实例串扰 ——
//! 组件 B 的 showToast/onUnmounted 清掉组件 A 的 timer,致 A 的 toast 永不隐藏;
//! 组件卸载后 timer 仍可能触发(向单例 store 写入,setState-after-unmount)。
//!
//! 分工:本 composable 为每个调用实例持有独立的 timeout/interval 注册表,
//! onUnmounted 自动全清,互不影响。setOwnTimeout 触发后自动从注册表移除(一次性)。
//! 适用:toast 自动隐藏 / keyword 防抖 / FilePreview 等后续 debounce 场景。
//!
//! 注意:仅在组件 setup 内调用(内部依赖 onUnmounted 生命周期钩子)。
import { onUnmounted } from 'vue'
export function useTimerOwnership() {
const timeouts = new Set<ReturnType<typeof setTimeout>>()
const intervals = new Set<ReturnType<typeof setInterval>>()
/** 注册一次性 timeout;触发后自动从注册表移除。返回可交给 clearOwn 的 id。 */
function setOwnTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => {
timeouts.delete(id)
fn()
}, ms)
timeouts.add(id)
return id
}
/** 注册重复 interval;onUnmounted 自动清理。返回可交给 clearOwnInterval 的 id。 */
function setOwnInterval(fn: () => void, ms: number) {
const id = setInterval(fn, ms)
intervals.add(id)
return id
}
/** 清理指定 timeout(id 为 null/undefined 时 no-op)。 */
function clearOwn(id?: ReturnType<typeof setTimeout> | null) {
if (id != null) {
clearTimeout(id)
timeouts.delete(id)
}
}
/** 清理指定 interval(id 为 null/undefined 时 no-op)。 */
function clearOwnInterval(id?: ReturnType<typeof setInterval> | null) {
if (id != null) {
clearInterval(id)
intervals.delete(id)
}
}
onUnmounted(() => {
timeouts.forEach(clearTimeout)
intervals.forEach(clearInterval)
timeouts.clear()
intervals.clear()
})
return { setOwnTimeout, setOwnInterval, clearOwn, clearOwnInterval }
}
+13 -13
View File
@@ -11,7 +11,8 @@
* showToast('导入失败', 'error', 4000) * showToast('导入失败', 'error', 4000)
* // template: <div v-if="toast.visible" class="toast ...">{{ toast.msg }}</div> * // template: <div v-if="toast.visible" class="toast ...">{{ toast.msg }}</div>
*/ */
import { reactive, onUnmounted } from 'vue' import { reactive } from 'vue'
import { useTimerOwnership } from './useTimerOwnership'
// P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确) // P0-2: 加 'success'(保存成功用绿色 toast,此前成功/中性都用 info 致反馈不明确)
export type ToastType = 'info' | 'error' | 'warning' | 'success' export type ToastType = 'info' | 'error' | 'warning' | 'success'
@@ -22,8 +23,6 @@ export interface ToastState {
type: ToastType type: ToastType
} }
let _timer: ReturnType<typeof setTimeout> | null = null
export function useToast(defaultDurationMs = 3000) { export function useToast(defaultDurationMs = 3000) {
const toast = reactive<ToastState>({ const toast = reactive<ToastState>({
visible: false, visible: false,
@@ -31,30 +30,31 @@ export function useToast(defaultDurationMs = 3000) {
type: 'info', type: 'info',
}) })
// G5.4:原模块级 `let _timer` 单例被所有 useToast() 实例共享 —— 组件 B showToast/onUnmounted
// 会清掉组件 A 的 timer,致 A 的 toast 永不隐藏(跨实例 bug)。
// 已下沉为「每实例独立 timer」:useTimerOwnership 每实例注册表 + onUnmounted 只清自己。
// 不再保留模块级兜底 —— 模块级共享正是串扰根源,每实例自清理即正确语义。
const { setOwnTimeout, clearOwn } = useTimerOwnership()
let _timer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: ToastType = 'info', durationMs?: number) { function showToast(msg: string, type: ToastType = 'info', durationMs?: number) {
toast.msg = msg toast.msg = msg
toast.type = type toast.type = type
toast.visible = true toast.visible = true
if (_timer) clearTimeout(_timer) if (_timer) clearOwn(_timer)
_timer = setTimeout(() => { _timer = setOwnTimeout(() => {
_timer = null
toast.visible = false toast.visible = false
}, durationMs ?? defaultDurationMs) }, durationMs ?? defaultDurationMs)
} }
function hideToast() { function hideToast() {
if (_timer) { if (_timer) {
clearTimeout(_timer) clearOwn(_timer)
_timer = null _timer = null
} }
toast.visible = false toast.visible = false
} }
onUnmounted(() => {
if (_timer) {
clearTimeout(_timer)
_timer = null
}
})
return { toast, showToast, hideToast } return { toast, showToast, hideToast }
} }
+3
View File
@@ -45,6 +45,9 @@ export default {
renameConvFail: 'Failed to rename. Please retry.', renameConvFail: 'Failed to rename. Please retry.',
archiveConvFail: 'Failed to {action}. Please retry.', archiveConvFail: 'Failed to {action}. Please retry.',
pinConvFail: 'Failed to {action}. Please retry.', pinConvFail: 'Failed to {action}. Please retry.',
// G3.4: new/delete conversation failure feedback (was bare await → unhandledrejection, user no feedback)
newConvFail: 'Failed to create a new conversation. Please retry.',
deleteConvFail: 'Failed to delete conversation. Please retry.',
archiveAction: 'archive', archiveAction: 'archive',
unarchiveAction: 'unarchive', unarchiveAction: 'unarchive',
pinAction: 'pin', pinAction: 'pin',
+3
View File
@@ -45,6 +45,9 @@ export default {
renameConvFail: '重命名失败,请重试', renameConvFail: '重命名失败,请重试',
archiveConvFail: '{action}失败,请重试', archiveConvFail: '{action}失败,请重试',
pinConvFail: '{action}失败,请重试', pinConvFail: '{action}失败,请重试',
// G3.4:新建/删除对话失败反馈(原裸 await → unhandledrejection,用户无感)
newConvFail: '新建对话失败,请重试',
deleteConvFail: '删除对话失败,请重试',
archiveAction: '归档', archiveAction: '归档',
unarchiveAction: '取消归档', unarchiveAction: '取消归档',
pinAction: '置顶', pinAction: '置顶',
+1 -1
View File
@@ -12,7 +12,7 @@
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat //! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar //! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
//! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain //! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain
//! - useAiPanel togglePanel/toggleMaximize/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState //! - useAiPanel togglePanel/toggleMaximize/cyclePanelMode/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState
//! //!
//! 注意: //! 注意:
//! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings), //! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings),
+151 -14
View File
@@ -45,6 +45,27 @@
<div class="ideas-layout"> <div class="ideas-layout">
<!-- 左侧灵感列表 --> <!-- 左侧灵感列表 -->
<section class="idea-list-panel"> <section class="idea-list-panel">
<div class="idea-list-header">
<label class="idea-batch-check" v-if="filteredIdeas.length > 0">
<input
type="checkbox"
:checked="selectedAll"
:indeterminate="selectedAny && !selectedAll"
@change="toggleSelectAll"
/>
<span v-if="selectedAny" class="batch-selected-note">{{ $t('ideas.selectedCount', { n: selectedIds.size }) }}</span>
</label>
<div class="idea-list-actions">
<button
v-if="selectedAny"
class="btn btn-danger btn-sm"
:disabled="batchDeleting"
@click="confirmBatchDelete"
>
{{ batchDeleting ? $t('ideas.deleting') : $t('ideas.batchDelete') }}
</button>
</div>
</div>
<div class="idea-list"> <div class="idea-list">
<!-- 三态:加载/错误/(对齐 Tasks.vue:43-45) --> <!-- 三态:加载/错误/(对齐 Tasks.vue:43-45) -->
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div> <div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
@@ -58,14 +79,24 @@
:class="{ selected: selectedId === idea.id }" :class="{ selected: selectedId === idea.id }"
@click="selectedId = idea.id" @click="selectedId = idea.id"
> >
<div class="idea-card-header"> <div class="idea-card-check">
<span class="idea-title">{{ idea.title }}</span> <input
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span> type="checkbox"
:checked="selectedIds.has(idea.id)"
@click.stop
@change="toggleSelect(idea.id)"
/>
</div> </div>
<p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p> <div class="idea-card-body">
<div class="idea-card-footer"> <div class="idea-card-header">
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span> <span class="idea-title">{{ idea.title }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span> <span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
</div>
<p class="idea-desc-preview">{{ descPreviews.get(idea.id) || '' }}</p>
<div class="idea-card-footer">
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -93,6 +124,7 @@
@status-change="onStatusChange" @status-change="onStatusChange"
@update-desc="onUpdateDesc" @update-desc="onUpdateDesc"
@update-related="onUpdateRelated" @update-related="onUpdateRelated"
@update-tags="onUpdateTags"
/> />
<!-- 未选择 --> <!-- 未选择 -->
@@ -148,7 +180,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch, type Ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
@@ -161,6 +193,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
import IdeaDetail from '../components/ideas/IdeaDetail.vue' import IdeaDetail from '../components/ideas/IdeaDetail.vue'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { usePersistedRef } from '../composables/usePersistedRef' import { usePersistedRef } from '../composables/usePersistedRef'
import { useTimerOwnership } from '../composables/useTimerOwnership'
import Paginator from '../components/Paginator.vue' import Paginator from '../components/Paginator.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -178,8 +211,47 @@ type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
const activeFilter = usePersistedRef<FilterKey>('ideas.activeFilter', 'all') const activeFilter = usePersistedRef<FilterKey>('ideas.activeFilter', 'all')
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const searchQuery = ref('') const searchQuery = ref('')
// :score / time ( score,) //
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'score') const selectedIds: Ref<Set<string>> = ref(new Set()) as Ref<Set<string>>
const batchDeleting = ref(false)
const selectedAll = computed(
() => filteredIdeas.value.length > 0 && selectedIds.value.size === filteredIdeas.value.length,
)
const selectedAny = computed(() => selectedIds.value.size > 0)
function toggleSelect(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id); else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
if (selectedAll.value) {
selectedIds.value = new Set()
} else {
selectedIds.value = new Set(filteredIdeas.value.map(i => i.id))
}
}
async function confirmBatchDelete() {
if (selectedIds.value.size === 0) return
if (!await confirmDialog(t('ideas.confirmBatchDelete', { n: selectedIds.value.size }), t('common.delete'))) return
batchDeleting.value = true
const ids = [...selectedIds.value]
try {
for (const id of ids) {
await store.deleteIdea(id)
}
selectedIds.value = new Set()
selectedId.value = null
Message.success(t('ideas.batchDeleteSuccess', { n: ids.length }))
await store.loadIdeas(buildIdeaQuery())
} catch (e) {
Message.error(t('ideas.deleteFailed'))
console.error('[Ideas] 批量删除失败:', e)
} finally {
batchDeleting.value = false
}
}
// :time / score ( time,)
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'time')
// (F-260621-02 P3):pageSize=0 (,); N // (F-260621-02 P3):pageSize=0 (,); N
const page = usePersistedRef<number>('ideas.page', 1) const page = usePersistedRef<number>('ideas.page', 1)
@@ -242,17 +314,23 @@ function buildIdeaQuery(): IdeaQuery {
// //( keyword ,/) // //( keyword ,/)
// store.loading ( Tasks.vue ) // store.loading ( Tasks.vue )
// G5.3:keyword timer useTimerOwnership + onUnmounted ,
// 300ms timer store.loadIdeas store(setState-after-unmount)
const { setOwnTimeout, clearOwn } = useTimerOwnership()
let keywordDebounce: ReturnType<typeof setTimeout> | null = null let keywordDebounce: ReturnType<typeof setTimeout> | null = null
async function reloadIdeas(immediate = false) { async function reloadIdeas(immediate = false) {
if (keywordDebounce) { if (keywordDebounce) {
clearTimeout(keywordDebounce) clearOwn(keywordDebounce)
keywordDebounce = null keywordDebounce = null
} }
const run = () => store.loadIdeas(buildIdeaQuery()) const run = () => store.loadIdeas(buildIdeaQuery())
if (immediate) { if (immediate) {
await run() await run()
} else { } else {
keywordDebounce = setTimeout(() => { void run() }, 300) keywordDebounce = setOwnTimeout(() => {
keywordDebounce = null
void run()
}, 300)
} }
} }
@@ -276,6 +354,16 @@ const filteredIdeas = computed(() => {
return ideas return ideas
}) })
// ( stripMd )
const descPreviews = computed(() => {
const map = new Map<string, string>()
for (const idea of filteredIdeas.value) {
const stripped = stripMd(idea.description)
map.set(idea.id, stripped.length > 60 ? stripped.slice(0, 60) + '...' : stripped)
}
return map
})
// (F-260621-02 P3):pageSize=0 (); // (F-260621-02 P3):pageSize=0 ();
const pagedIdeas = computed(() => { const pagedIdeas = computed(() => {
if (pageSize.value <= 0) return filteredIdeas.value if (pageSize.value <= 0) return filteredIdeas.value
@@ -415,6 +503,12 @@ async function onUpdateRelated(ids: string[]) {
await store.relateIdeas(currentIdea.value.id, ids) await store.relateIdeas(currentIdea.value.id, ids)
} }
// IdeaDetail emit 'update-tags'()
async function onUpdateTags(tags: string) {
if (!currentIdea.value) return
await store.updateIdea(currentIdea.value.id, 'tags', tags)
}
onMounted(async () => { onMounted(async () => {
await store.loadIdeas(buildIdeaQuery()) await store.loadIdeas(buildIdeaQuery())
// /ideas/:id 访 B-260615-36 // /ideas/:id 访 B-260615-36
@@ -470,14 +564,36 @@ watch(() => route.params.id, (id) => {
overflow-y: auto; overflow-y: auto;
} }
.idea-card { .idea-card {
display: flex;
gap: 10px;
padding: 14px; padding: 14px;
border-radius: var(--df-radius); border-radius: var(--df-radius);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
margin-bottom: 4px; margin-bottom: 4px;
} }
.idea-card:hover { background: rgba(108, 99, 255, 0.06); } .idea-card-body {
.idea-card.selected { background: rgba(108, 99, 255, 0.12); border: 0.5px solid var(--df-accent); } flex: 1;
min-width: 0;
/* 批量操作重构后 desc/footer 移入 body,须纵向堆叠(原 .idea-card 直接子元素横排 bug) */
display: flex;
flex-direction: column;
}
.idea-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.idea-card-check {
display: flex;
align-items: flex-start;
padding-top: 2px;
}
.idea-card-check input { cursor: pointer; }
.idea-card:hover { background: var(--df-accent-bg); }
.idea-card.selected { background: var(--df-accent-soft); border: 0.5px solid var(--df-accent); }
.idea-card-header { .idea-card-header {
display: flex; display: flex;
@@ -506,6 +622,27 @@ watch(() => route.params.id, (id) => {
} }
.idea-date { font-size: 11px; color: var(--df-text-dim); } .idea-date { font-size: 11px; color: var(--df-text-dim); }
/* ===== 批量操作头 ===== */
.idea-list-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px 8px;
border-bottom: 0.5px solid var(--df-border);
margin-bottom: 4px;
}
.idea-batch-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
}
.idea-batch-check input { cursor: pointer; }
.batch-selected-note { font-size: 11px; color: var(--df-text-dim); white-space: nowrap; }
.idea-list-actions { margin-left: auto; }
/* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */ /* ===== 状态标签已提取到 styles/components.css 全局(.status-tag 系列) ===== */
/* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */ /* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */