From ad1821bc14aa818d3efb6d753db2b54d433087ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 28 Jun 2026 13:09:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E4=B8=8D=E8=B6=85=E6=97=B6+=E9=87=8D=E5=90=AF=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20+=20=E5=85=B6=E4=BB=96=E5=B0=8F=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时) - V33 迁移: ai_conversations 加 pending_approvals 列 - save_conversation 持久化 pending_approvals - ai_conversation_switch 从 DB 恢复 pending_approvals - switchConversation 加 try/catch(对话不存在→建新) - @项目 关联添加取消按钮(×) - TopBar 底部无标题时图标右对齐 - TaskDetail.vue 删除重复 case --- .../df-storage/src/crud/conversation_repo.rs | 11 +++--- crates/df-storage/src/migrations.rs | 27 ++++++++++++- crates/df-storage/src/models.rs | 1 + .../src/commands/ai/commands/conversation.rs | 23 +++++++++++ src-tauri/src/commands/ai/conversation.rs | 15 +++++++ src-tauri/src/commands/ai/mod.rs | 6 +-- src/components/ai/ChatInput.vue | 39 +++++++++++++++++++ src/components/ai/TopBar.vue | 8 ++-- src/composables/ai/aiShared.ts | 2 +- src/composables/ai/useAiConversations.ts | 24 ++++++++++-- src/views/TaskDetail.vue | 3 +- 11 files changed, 140 insertions(+), 19 deletions(-) diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 8024451..a96f48c 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -85,6 +85,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result |row| ai_conversation_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.pinned_goals, rec.created_at, rec.updated_at + rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at ], ) }, update => |conn, rec| { conn.execute( - "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12", + "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13", params![ rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.pinned_goals, rec.updated_at, rec.id + rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id ], ) } diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 06a7b62..28a66f2 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -43,7 +43,9 @@ pub fn run(conn: &Connection) -> Result<()> { // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表, // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 - let steps: [(i32, fn(&Connection) -> Result<()>); 32] = [ + // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, + // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 + let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -76,6 +78,7 @@ pub fn run(conn: &Connection) -> Result<()> { (30, migrate_v30), (31, migrate_v31), (32, migrate_v32), + (33, migrate_v33), ]; for (version, migrate_fn) in steps { @@ -917,6 +920,28 @@ fn migrate_v32(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_v33(conn: &Connection) -> Result<()> { + // 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式) + let has_col: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'", + [], + |row| row.get(0), + ) + .unwrap_or(false); + if !has_col { + conn.execute_batch( + "ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';" + )?; + tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)"); + } else { + tracing::info!("v33: pending_approvals 列已存在,跳过"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?; + tracing::info!("迁移 v33 完成"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index f4c9eaa..703a92f 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -338,6 +338,7 @@ pub struct AiConversationRecord { pub prompt_tokens: Option, // 输入 token 累计(流式 usage 落库) pub completion_tokens: Option, // 输出 token 累计(流式 usage 落库) pub pinned_goals: Option, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]') + pub pending_approvals: Option, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}') pub created_at: String, pub updated_at: String, } diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index c529e0e..59b1516 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -332,6 +332,29 @@ pub async fn ai_conversation_switch( super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换"); // 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); + + // 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals) + // 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。 + if let Some(pending_json) = &record.pending_approvals { + if pending_json != "{}" && !pending_json.is_empty() { + if let Ok(restored) = serde_json::from_str::< + std::collections::HashMap + >(pending_json) { + let restored_count = restored.len(); + // 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的) + session.pending_approvals.extend(restored); + tracing::info!( + "切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)", + conversation_id, restored_count + ); + } else { + tracing::warn!( + "切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}", + conversation_id, &pending_json.chars().take(200).collect::() + ); + } + } + } // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) drop(session); diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 5389c4c..4c5bbb1 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -189,6 +189,18 @@ pub(crate) async fn save_conversation( ) }; + + // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目, + // 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。 + let pending_approvals_json = { + let session = session_arc.lock().await; + let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals + .iter() + .filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id)) + .collect(); + serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string()) + }; + // F-260619-03 批次 B:映射 Vec → Vec(带 seq 索引 + conv_id) // 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。 // created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。 @@ -227,6 +239,8 @@ pub(crate) async fn save_conversation( } // G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())); + // 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照 + rec.pending_approvals = Some(pending_approvals_json.clone()); // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at if let Err(e) = conv_repo.update_full(&rec).await { tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); @@ -254,6 +268,7 @@ pub(crate) async fn save_conversation( prompt_tokens: usage.map(|u| u.prompt_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64), pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())), + pending_approvals: Some(pending_approvals_json.clone()), created_at: conv_created.clone(), updated_at: now, }; diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 5274aec..e47647e 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -847,7 +847,7 @@ impl Default for PerConvState { /// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 + /// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir /// 各只消费自己 kind,入口校验防误调)。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingApproval { pub tool_call_id: String, pub tool_name: String, @@ -880,7 +880,7 @@ pub struct PendingApproval { /// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。 /// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览; /// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum ApprovalKind { /// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。 Path(PathAuthRequest), @@ -891,7 +891,7 @@ pub enum ApprovalKind { } /// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PathAuthRequest { /// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。 /// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index d9d7841..cac8e44 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -134,6 +134,7 @@ {{ enrichmentBadgeText }} +
{{ $t('aiChat.enrichmentLabel') }}
{{ enrichmentDetailText }}
@@ -485,6 +486,28 @@ const enrichmentDetailText = computed(() => { return lines.join('\n') }) +/** 取消 @项目 关联:清除 mentionSpan + 移除输入框中的 [@项目: xxx] 标记 */ +function dismissEnrichment() { + const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project') + if (projectSpans.length === 0) return + // 从 inputText 中移除所有 [@项目: xxx] 标记 + let text = inputText.value + for (const span of projectSpans) { + const label = text.slice(span.start, span.start + span.length) + // 替换标记 + 前后可能的空格为单个空格 + const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`) + text = text.replace(pattern, ' ').trim() + } + inputText.value = text + // 清除 project 类型的 mentionSpan + pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project') + enrichmentExpanded.value = false +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + const mentionGroupLabel = computed(() => ({ project: t('aiChat.mentionGroupProject'), task: t('aiChat.mentionGroupTask'), @@ -1078,6 +1101,8 @@ defineExpose({ /* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */ .ai-enrichment-bar { + display: flex; + align-items: center; margin-top: 6px; border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent); border-radius: var(--df-radius); @@ -1111,6 +1136,20 @@ defineExpose({ .ai-enrichment-toggle--expanded .ai-enrichment-chevron { transform: rotate(180deg); } +.ai-enrichment-dismiss { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--df-text-dim); + cursor: pointer; + font-size: 14px; + padding: 0 4px; + line-height: 1; + transition: color 0.15s; +} +.ai-enrichment-dismiss:hover { + color: var(--df-danger); +} .ai-enrichment-badge { flex: 1; min-width: 0; diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 16b2389..b973383 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -538,26 +538,26 @@ onBeforeUnmount(() => { .ai-bottom-tools { display: flex; align-items: center; - justify-content: space-between; + padding: 0 14px 4px; } .ai-bottom-title { font-size: 11px; color: var(--df-text-secondary); min-width: 0; - flex: 1; - margin-right: 8px; + margin-right: auto; + } .ai-bottom-tools-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; + margin-left: auto; } /* ═══ Provider Bar (header 中间区) ═══ */ .ai-provider-bar { display: inline-flex; - align-items: center; gap: 4px; font-size: 11px; color: var(--df-text-secondary); diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index 3e58ce7..c10f212 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -71,7 +71,7 @@ export function findToolCall(id: string): AiToolCallInfo | undefined { // ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ── /// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝 -const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 +const APPROVAL_TIMEOUT_MS = Infinity // 已决策:不做超时,用户不审批就一直等 /** toolCallId → 审批超时计时器 */ const _approvalTimers = new Map>() diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 57e4f8d..6d238a5 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -10,9 +10,9 @@ import { state } from '@/stores/ai' import { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState, clearConvStreamState } from './aiShared' +import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared' import { t } from '@/i18n/i18n-helpers' -import type { AiMessage, AiToolCallInfo } from '@/api/types' +import type { AiConversationDetail, AiMessage, AiToolCallInfo } from '@/api/types' const appSettings = useAppSettingsStore() @@ -92,7 +92,22 @@ let _latestSwitchId = 0 export async function switchConversation(id: string) { const mySwitchId = ++_latestSwitchId // 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图 - const detail = await aiApi.switchConversation(id) + let detail: AiConversationDetail + try { + detail = await aiApi.switchConversation(id) + } catch { + // 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代 + 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, title: null, messages: '[]' } + state.messages = [] + notifyConversationChanged() + void loadConversations() + return + } // 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B) if (mySwitchId !== _latestSwitchId) return state.activeConversationId = id @@ -226,6 +241,9 @@ export async function switchConversation(id: string) { dir: tc.dir, reason: tc.reason, }) + // 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝, + // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) + startApprovalTimer(tc.id, tc.name, kind) } } } diff --git a/src/views/TaskDetail.vue b/src/views/TaskDetail.vue index 20a0cba..0b994f4 100644 --- a/src/views/TaskDetail.vue +++ b/src/views/TaskDetail.vue @@ -396,8 +396,7 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) { break } case 'workflow_failed': - case 'node_failed': - case 'workflow_failed': { + case 'node_failed': { if (evt?.node_id) { const node = String(evt.node_id) wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }