diff --git a/docs/todo.md b/docs/todo.md index 95f5661..b41443e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1058,7 +1058,7 @@ graph TD ## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md) - [x] B1(S):H1 汇报 git 实测附录 ✅(2026-10-06:deliver_completion_report_if_waited 单点 append_git_facts——branch/HEAD/未提交计数三行,非 git 仓静默跳过,超长报告不追,git.rs 增 git_exec 只读薄封装)+ 探活告警口径修正 ✅(同日 18 批:inflight 计数点前移到 handle_message 白名单通过处,覆盖占位卡 HTTP+per-chat 锁排队窗口,旧计数点删除防双计)- [ ] B2(M):H2 token 三闸(抄 sub_agent round_budget_stop)+ H4 产出审计 subscriber -- [ ] B3(M):V49 防覆盖定稿+单测 + ai_list_recoverable IPC + 桌面「可恢复」入口 +- [x] B3(M):V49 防覆盖定稿+单测 ✅(本会话前批:#11 update_full 剥离 loop_state 列,df-storage conversation 8 passed)+ ai_list_recoverable IPC ✅(2026-10-06:conversation.rs parked 专项完备真相清单 id/title/model/updated_at,不受侧栏 limit 截断,lib.rs 已注册)+ 桌面「可恢复」入口 ✅(2026-10-06 本批:TopBar recoverableTasks 双轨数据源——conversations loop_state 实时投影+ai_list_recoverable IPC 完备真相合并去重,面板展开触发 IPC 拉取,失败退回投影;ai.ts listRecoverable 封装)。验收:cargo check EXIT 0 + vue-tsc EXIT 0。 - [ ] B4(L):ai_recover_parked 重启恢复 + H3 外部干预感知(git HEAD 比对→Parked) ## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行) diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 797ef4d..d810ec8 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -278,6 +278,30 @@ pub async fn ai_conversation_list( Ok(active) } +/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。 +/// +/// V49 断点续跑的完备真相查询:重启时 mark_running_as_parked 改写的会话在此列出, +/// 桌面任务卡「可恢复」分区与飞书投影共用本 IPC(单一事实源)。与 ai_conversation_list +/// 的 loop_state 透出互补——list 是侧栏全量摘要(截断 limit 内),本 IPC 是 parked 专项 +/// 全量清单(不受侧栏 limit 截断),且附任务上下文(标题+最后更新时间)供恢复决策。 +#[tauri::command] +pub async fn ai_list_recoverable( + state: State<'_, AppState>, +) -> Result, String> { + let records = state.ai_conversations.list_parked().await.map_err(err_str)?; + Ok(records + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id, + "title": r.title, + "model": r.model, + "updated_at": r.updated_at, + }) + }) + .collect()) +} + /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) #[tauri::command] pub async fn ai_conversation_switch( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c22954..e83c58b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -449,6 +449,7 @@ pub fn run() { // AI 对话管理 commands::ai::ai_conversation_create, commands::ai::ai_conversation_list, + commands::ai::ai_list_recoverable, commands::ai::ai_conversation_switch, // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) commands::ai::ai_conversation_peek, diff --git a/src/api/ai.ts b/src/api/ai.ts index 623e954..e0d9911 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -327,6 +327,11 @@ export const aiApi = { }) }, + /** B3 可恢复会话清单(loop_state='parked' 完备真相,不受侧栏 limit 截断) */ + listRecoverable(): Promise<{ id: string; title: string | null; model: string | null; updated_at: string }[]> { + return invoke('ai_list_recoverable') + }, + /** 切换到指定对话 */ switchConversation(conversationId: string): Promise { return invoke('ai_conversation_switch', { conversationId }) diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 2e5ab59..3eb768c 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -456,14 +456,31 @@ const runningTasks = computed(() => { } return items }) -// V49 可恢复入口(断点续跑读侧):conversations 中 loop_state='parked' 的会话(重启中断待续), -// 独立分区展示在任务面板下方,点击切回(继续与否由用户在会话内决定,不做自动续跑) -const recoverableTasks = computed(() => { +// V49 可恢复入口(B3):数据源双轨——conversations 中 loop_state='parked'(实时投影, +// 侧栏 limit 内)+ ai_list_recoverable 完备真相 IPC(面板展开时拉取,补侧栏截断漏项)。 +// 合并去重按 id;点击切回,继续与否由用户在会话内决定,不做自动续跑 +const recoverableFromList = computed(() => { return store.state.conversations .filter(c => c.loop_state === 'parked' && c.id !== store.state.activeConversationId) - .slice(0, 10) .map(c => ({ id: c.id, title: c.title || t('aiChat.untitledConversation') })) }) +const recoverableFromIpc = ref<{ id: string; title: string | null }[]>([]) +async function refreshRecoverable() { + try { + const items = await aiApi.listRecoverable() + recoverableFromIpc.value = items.map(i => ({ id: i.id, title: i.title })) + } catch { /* IPC 失败退回 list 投影,不阻断面板 */ } +} +const recoverableTasks = computed(() => { + const seen = new Set() + const merged: { id: string; title: string }[] = [] + for (const item of [...recoverableFromList.value, ...recoverableFromIpc.value]) { + if (item.id === store.state.activeConversationId || seen.has(item.id)) continue + seen.add(item.id) + merged.push({ id: item.id, title: item.title || t('aiChat.untitledConversation') }) + } + return merged.slice(0, 10) +}) async function switchToRunning(id: string) { runningTasksExpanded.value = false if (id !== store.state.activeConversationId) { @@ -593,6 +610,10 @@ function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'runni const willOpen = !refs[panel].value goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false refs[panel].value = willOpen + // B3:任务面板展开时拉一次可恢复清单完备真相(补侧栏 limit 截断漏项);失败退回 list 投影 + if (panel === 'running' && willOpen) { + void refreshRecoverable() + } } const contextInfo = computed(() => {