优化: 批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:
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对话列表摘要 */
|
/** 对话列表摘要 */
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 默认 15min,0=不限时跳过;
|
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过;
|
||||||
// 启动仅用于计时器注册一致性,保持与 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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 子组件渲染,父级仅保留未选中空态壳) ===== */
|
||||||
|
|||||||
Reference in New Issue
Block a user