From e0ffd982237082315b525797a269243b4ee5b537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 04:11:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8B=86audit.rs?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=89=B9reason=20helper(strategy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit.rs → audit/mod.rs(959→812行) + 新建 audit/reason.rs(167行) - 抽离 reason helper: resolve_project_label/resolve_task_label/build_approval_reason(3 fn自成闭包, pub(super)) - re-export 调用方零变更(commands::ai::audit::* 路径透明, 4处调用方+mod re-export核验) - F-09 batch2 per_conv 保留(本批只搬 reason helper, conv_id路由不动) 主代兜底: cargo check --workspace 0 + test 98 + grep mod reason/use印证 strategy: 单批1-2文件原子; audit/mod.rs余812行(后续批restore/audit_finalize/find_cached) --- crates/df-execute/src/shell.rs | 2 +- docs/todo.md | 10 + .../commands/ai/{audit.rs => audit/mod.rs} | 159 +--------- src-tauri/src/commands/ai/audit/reason.rs | 167 ++++++++++ src/components/AiChat.vue | 245 ++------------- src/components/ai/TopBar.vue | 287 ++++++++++++++++++ 6 files changed, 498 insertions(+), 372 deletions(-) rename src-tauri/src/commands/ai/{audit.rs => audit/mod.rs} (85%) create mode 100644 src-tauri/src/commands/ai/audit/reason.rs create mode 100644 src/components/ai/TopBar.vue diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index 6491d61..5d5da2a 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -87,9 +87,9 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { // B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。 // CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。 + // tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。 #[cfg(windows)] { - use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); } diff --git a/docs/todo.md b/docs/todo.md index d61fa90..198f36e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -403,6 +403,16 @@ --- +### 🔧 2026-06-19 命令行黑窗修复 + GLM 1214 数据调查(DB 直查定位) + +> 用户报两问题:①执行命令行弹黑窗闪烁 ②GLM 1214 messages 非法(多轮)。Build 版无 tracing subscriber 看不到日志,改 DB 直查(`C:\Users\23780\AppData\Roaming\top.1216.devflow\devflow.db`)定位。 + +- [x] ✅(2026-06-19·df-execute shell.rs 全仓唯一子进程源(grep 确认)·tokio Command 加 creation_flags(0x0800_0000) CREATE_NO_WINDOW·cargo check df-execute EXIT 0 无 warning) **B-260619-01 [P1]** — **执行命令行弹黑色窗口闪烁**。Windows tokio::process::Command 创建 cmd/powershell 子进程默认带控制台窗口(黑窗闪现)。修:`#[cfg(windows)] cmd.creation_flags(0x0800_0000)`(CREATE_NO_WINDOW)。tokio Command Windows 自带 creation_flags 方法(无需 std CommandExt trait)。覆盖 run_command 工具 + 工作流 shell 节点(全经 df-execute)。— `crates/df-execute/src/shell.rs`(:86-93) + +- [ ] **B-260619-02 [P1]** — **GLM 1214 messages 非法(数据驱动定位:单条 tool_result 过大)**。DB 直查报错对话 f64dee94:全量 546 条(多数 compressed),**active(发往GLM)仅 7 条结构合法**(system/assistant/user/assistant/user 交替,tool_use↔tool_result 配对 orphan=0,无连续 role,57K 字节 ~19K tokens **未超 GLM 128K**)。**真凶**:单条 tool_result 巨大(read_file 整文件 #2=21843B/#3=16511B/#6=11482B),T-05 截断阈值 50KB 未触发(21KB<50KB),但 **GLM anthropic 端点单条 tool_result content 限制更严(~10-20KB 即拒 1214)**。次要:#4 assistant content 空(len=0 只有 tool_use);"回复一半消失"=1214 在 GLM 流极早(message_start 前)error→stream_recv InitFailed→emit AiError→前端清 currentText,保文逻辑未覆盖。**修法方向**:① tool_result 截断阈值收紧 50KB→8-10KB(read_file/list_directory 大文件截断+提示)② MidStream 早 1214 保文(apply_anthropic_event error 时若已 message_start 不清流式)③ 空 assistant convert 兜底。**已加诊断(待重编译验证)**:anthropic_compat precheck(首条/连续/input/空content/orphan 5 类 Init bail)+ MidStream error 附 messages 摘要(SSE error chunk 塞摘要到前端 raw)+ HTTP/1.1 治 GLM HTTP/2 RST + 错误源链进 anyhow 文案。— `crates/df-ai/src/anthropic_compat.rs`(precheck/summarize/MidStream 摘要/version HTTP_1_1) + tool_result 截断(T-05 `tool_registry.rs`/`audit.rs` 阈值 50K→8-10K) + MidStream 保文(`stream_recv.rs`) + +--- + ### 💡 2026-06-19 新需求(任务关联灵感·已分析·待实施) diff --git a/src-tauri/src/commands/ai/audit.rs b/src-tauri/src/commands/ai/audit/mod.rs similarity index 85% rename from src-tauri/src/commands/ai/audit.rs rename to src-tauri/src/commands/ai/audit/mod.rs index 301e40a..b814f14 100644 --- a/src-tauri/src/commands/ai/audit.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, Emitter, State}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; -use df_storage::crud::{AiToolExecutionRepo, ProjectRepo, TaskRepo}; +use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use df_storage::models::AiToolExecutionRecord; @@ -121,159 +121,12 @@ pub async fn list_tool_executions( } -/// 查项目可读标签:id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。 -/// -/// AR-3:审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。 -/// 三臂区分: -/// - Ok(Some) → 项目名标签 -/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id) -/// - Err → 裸 id 回退 + warn 日志(DB 故障/锁/连接断,不误报"已不存在"误导用户) -async fn resolve_project_label(db: &Arc, id: &str) -> String { - if id.is_empty() { - return String::new(); - } - let repo = ProjectRepo::new(db); - match repo.get_by_id(id).await { - Ok(Some(p)) => format!("「{}」(id={})", p.name, id), - Ok(None) => format!("(项目已不存在, id={})", id), - Err(e) => { - tracing::warn!("resolve_project_label: 查询项目 id={} 失败,回退裸 id: {}", id, e); - format!("(id={})", id) - } - } -} +// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) +// 拆至子模块 audit/reason.rs(本批 helper 抽离,行为零变更)。 +mod reason; -/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。 -/// -/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id" -/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到 -/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。 -async fn resolve_task_label(db: &Arc, id: &str) -> String { - if id.is_empty() { - return String::new(); - } - let repo = TaskRepo::new(db); - match repo.get_by_id(id).await { - Ok(Some(t)) => format!("「{}」(id={})", t.title, id), - Ok(None) => format!("(任务已不存在, id={})", id), - Err(e) => { - tracing::warn!("resolve_task_label: 查询任务 id={} 失败,回退裸 id: {}", id, e); - format!("(id={})", id) - } - } -} - -/// 拼审批 reason:按工具名 + args 取可读字段,让用户知道"审批要做什么"。 -/// -/// 主要审批工具特化(带对象名/标识): -/// - delete_project/restore_project/purge_project → 查项目名拼"删除项目「名」(id=x)" -/// - update_project → 查项目名 + field -/// - bind_directory → path + 查项目名 -/// - create_task → title + 查 project_id 项目名;create_project → name;create_idea → title -/// - run_workflow → name -/// args 无可读字段或工具未特化时,fallback 原 risk 模板(含风险等级提示)。 -async fn build_approval_reason( - tool_name: &str, - args: &serde_json::Value, - risk_level: RiskLevel, - db: &Arc, -) -> String { - let s = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or(""); - // 优先级:tool_display_hint(轻量标签) → display_hint_for_tool(模板填充) → 硬编码 fallback - let detail = if let Some(hint) = super::tool_registry::tool_display_hint(tool_name) { - // 轻量命中:直接用中文动作前缀 + 风险后缀(不含参数细节) - hint.to_string() - } else if let Some((template, keys)) = super::tool_registry::display_hint_for_tool(tool_name) { - // 按 keys 列表取参数值;含 "id"/"project_id" 的值走 resolve_project_label 解析 - let mut values = Vec::with_capacity(keys.len()); - for &key in keys { - let val = s(key); - if val.is_empty() { values.clear(); break; } - match key { - // advance_task 的 id 是 task_id(非项目 id),改查 tasks 表,避免误报"项目已不存在" - "id" if tool_name == "advance_task" => values.push(resolve_task_label(db, &val).await), - "id" | "project_id" => values.push(resolve_project_label(db, &val).await), - _ => values.push(val.to_string()), - } - } - // 任一关键参数为空则整条 fallback 为空串(与原行为一致) - if values.is_empty() { - String::new() - } else { - // 模板 {} 占位符按位置填充 - let mut result = template.to_string(); - for v in &values { - result = result.replacen("{}", v, 1); - } - result - } - } else { - // fallback:原有完整硬编码逻辑(保持行为不变) - match tool_name { - "delete_project" => { - let id = s("id"); - if !id.is_empty() { format!("删除项目{}", resolve_project_label(db, id).await) } else { String::new() } - } - "restore_project" => { - let id = s("id"); - if !id.is_empty() { format!("从回收站恢复项目{}", resolve_project_label(db, id).await) } else { String::new() } - } - "purge_project" => { - let id = s("id"); - if !id.is_empty() { format!("永久删除项目及关联数据,不可恢复{}", resolve_project_label(db, id).await) } else { String::new() } - } - "update_project" => { - let id = s("id"); - let field = s("field"); - if !id.is_empty() { - format!("修改项目{}字段「{}」", resolve_project_label(db, id).await, field) - } else if !field.is_empty() { - format!("修改项目字段「{}」", field) - } else { String::new() } - } - "bind_directory" => { - let id = s("id"); - let path = s("path"); - if !path.is_empty() && !id.is_empty() { - format!("绑定目录:{}(项目{})", path, resolve_project_label(db, id).await) - } else if !path.is_empty() { - format!("绑定目录:{}", path) - } else { String::new() } - } - "create_task" => { - let title = s("title"); - let pid = s("project_id"); - if !title.is_empty() && !pid.is_empty() { - format!("创建任务:{}(项目{})", title, resolve_project_label(db, pid).await) - } else if !title.is_empty() { - format!("创建任务:{}", title) - } else { String::new() } - } - "create_project" => { - let name = s("name"); - if !name.is_empty() { format!("创建项目:「{}」", name) } else { String::new() } - } - "create_idea" => { - let title = s("title"); - if !title.is_empty() { format!("捕获灵感:{}", title) } else { String::new() } - } - _ => String::new(), - } - }; - if detail.is_empty() { - // fallback:无可读字段,保留风险等级提示模板 - match risk_level { - RiskLevel::High => "高风险操作,必须人工批准".to_string(), - _ => "创建操作,请确认是否执行".to_string(), - } - } else { - // 拼上风险等级后缀(高风险标注,便于用户权衡) - match risk_level { - RiskLevel::High => format!("{}(高风险,需人工批准)", detail), - _ => format!("{}(请确认是否执行)", detail), - } - } -} +// process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。 +use reason::build_approval_reason; /// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢) /// diff --git a/src-tauri/src/commands/ai/audit/reason.rs b/src-tauri/src/commands/ai/audit/reason.rs new file mode 100644 index 0000000..a3ee62e --- /dev/null +++ b/src-tauri/src/commands/ai/audit/reason.rs @@ -0,0 +1,167 @@ +//! 审批 reason 拼装:工具调用 → 可读审批文案(含项目/任务 label 解析)。 +//! +//! 拆自 audit.rs(本批 helper 抽离,行为零变更): +//! - `resolve_project_label` / `resolve_task_label`:id → 可读标签(三臂分流) +//! - `build_approval_reason`:按工具名 + args 拼审批 reason +//! +//! 仅被 `process_tool_calls`(audit/mod.rs)调用,无外部调用方。 + +use std::sync::Arc; + +use df_ai::ai_tools::RiskLevel; +use df_storage::crud::{ProjectRepo, TaskRepo}; +use df_storage::db::Database; + +/// 查项目可读标签:id → "「项目名」(id=x)"。 +/// +/// AR-3:审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。 +/// 三臂区分: +/// - Ok(Some) → 项目名标签 +/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id) +/// - Err → 裸 id 回退 + warn 日志(DB 故障/锁/连接断,不误报"已不存在"误导用户) +pub(super) async fn resolve_project_label(db: &Arc, id: &str) -> String { + if id.is_empty() { + return String::new(); + } + let repo = ProjectRepo::new(db); + match repo.get_by_id(id).await { + Ok(Some(p)) => format!("「{}」(id={})", p.name, id), + Ok(None) => format!("(项目已不存在, id={})", id), + Err(e) => { + tracing::warn!("resolve_project_label: 查询项目 id={} 失败,回退裸 id: {}", id, e); + format!("(id={})", id) + } + } +} + +/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。 +/// +/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id" +/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到 +/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。 +pub(super) async fn resolve_task_label(db: &Arc, id: &str) -> String { + if id.is_empty() { + return String::new(); + } + let repo = TaskRepo::new(db); + match repo.get_by_id(id).await { + Ok(Some(t)) => format!("「{}」(id={})", t.title, id), + Ok(None) => format!("(任务已不存在, id={})", id), + Err(e) => { + tracing::warn!("resolve_task_label: 查询任务 id={} 失败,回退裸 id: {}", id, e); + format!("(id={})", id) + } + } +} + +/// 拼审批 reason:按工具名 + args 取可读字段,让用户知道"审批要做什么"。 +/// +/// 主要审批工具特化(带对象名/标识): +/// - delete_project/restore_project/purge_project → 查项目名拼"删除项目「名」(id=x)" +/// - update_project → 查项目名 + field +/// - bind_directory → path + 查项目名 +/// - create_task → title + 查 project_id 项目名;create_project → name;create_idea → title +/// - run_workflow → name +/// args 无可读字段或工具未特化时,fallback 原 risk 模板(含风险等级提示)。 +pub(super) async fn build_approval_reason( + tool_name: &str, + args: &serde_json::Value, + risk_level: RiskLevel, + db: &Arc, +) -> String { + let s = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or(""); + // 优先级:tool_display_hint(轻量标签) → display_hint_for_tool(模板填充) → 硬编码 fallback + let detail = if let Some(hint) = super::super::tool_registry::tool_display_hint(tool_name) { + // 轻量命中:直接用中文动作前缀 + 风险后缀(不含参数细节) + hint.to_string() + } else if let Some((template, keys)) = super::super::tool_registry::display_hint_for_tool(tool_name) { + // 按 keys 列表取参数值;含 "id"/"project_id" 的值走 resolve_project_label 解析 + let mut values = Vec::with_capacity(keys.len()); + for &key in keys { + let val = s(key); + if val.is_empty() { values.clear(); break; } + match key { + // advance_task 的 id 是 task_id(非项目 id),改查 tasks 表,避免误报"项目已不存在" + "id" if tool_name == "advance_task" => values.push(resolve_task_label(db, &val).await), + "id" | "project_id" => values.push(resolve_project_label(db, &val).await), + _ => values.push(val.to_string()), + } + } + // 任一关键参数为空则整条 fallback 为空串(与原行为一致) + if values.is_empty() { + String::new() + } else { + // 模板 {} 占位符按位置填充 + let mut result = template.to_string(); + for v in &values { + result = result.replacen("{}", v, 1); + } + result + } + } else { + // fallback:原有完整硬编码逻辑(保持行为不变) + match tool_name { + "delete_project" => { + let id = s("id"); + if !id.is_empty() { format!("删除项目{}", resolve_project_label(db, id).await) } else { String::new() } + } + "restore_project" => { + let id = s("id"); + if !id.is_empty() { format!("从回收站恢复项目{}", resolve_project_label(db, id).await) } else { String::new() } + } + "purge_project" => { + let id = s("id"); + if !id.is_empty() { format!("永久删除项目及关联数据,不可恢复{}", resolve_project_label(db, id).await) } else { String::new() } + } + "update_project" => { + let id = s("id"); + let field = s("field"); + if !id.is_empty() { + format!("修改项目{}字段「{}」", resolve_project_label(db, id).await, field) + } else if !field.is_empty() { + format!("修改项目字段「{}」", field) + } else { String::new() } + } + "bind_directory" => { + let id = s("id"); + let path = s("path"); + if !path.is_empty() && !id.is_empty() { + format!("绑定目录:{}(项目{})", path, resolve_project_label(db, id).await) + } else if !path.is_empty() { + format!("绑定目录:{}", path) + } else { String::new() } + } + "create_task" => { + let title = s("title"); + let pid = s("project_id"); + if !title.is_empty() && !pid.is_empty() { + format!("创建任务:{}(项目{})", title, resolve_project_label(db, pid).await) + } else if !title.is_empty() { + format!("创建任务:{}", title) + } else { String::new() } + } + "create_project" => { + let name = s("name"); + if !name.is_empty() { format!("创建项目:「{}」", name) } else { String::new() } + } + "create_idea" => { + let title = s("title"); + if !title.is_empty() { format!("捕获灵感:{}", title) } else { String::new() } + } + _ => String::new(), + } + }; + if detail.is_empty() { + // fallback:无可读字段,保留风险等级提示模板 + match risk_level { + RiskLevel::High => "高风险操作,必须人工批准".to_string(), + _ => "创建操作,请确认是否执行".to_string(), + } + } else { + // 拼上风险等级后缀(高风险标注,便于用户权衡) + match risk_level { + RiskLevel::High => format!("{}(高风险,需人工批准)", detail), + _ => format!("{}(请确认是否执行)", detail), + } + } +} diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 484d951..945a8d2 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -9,140 +9,26 @@ />
- -
-
- - - - - {{ $t('ai.assistant') }} -
- ⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }} -
-
- -
-
- - -
- - {{ $t('aiChat.noModels') }} -
-
- - - - - - - - - - - -
-
- - -
- - {{ activeProviderName }} -
-
- {{ $t('aiChat.providerNotConfigured') }} -
- - -
- - {{ $t('aiChat.providerSwitchHint', { model: activeProviderName }) }} -
-
+ +
@@ -501,6 +387,7 @@ import { aiApi } from '../api' import ConfirmDialog from './ConfirmDialog.vue' import ConversationSidebar from './ai/ConversationSidebar.vue' import ChatInput from './ai/ChatInput.vue' +import TopBar from './ai/TopBar.vue' import { useConfirm } from '../composables/useConfirm' import { useMarkdown } from '../composables/useMarkdown' import ToolCardList from './ToolCardList.vue' @@ -799,9 +686,10 @@ const projectsStore = useProjectStore() // 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、 // 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。 // 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。 +// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至 +// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消 + 滚动边沿重置。 watch(() => store.state.activeConversationId, () => { if (editingMsgId.value) cancelEdit() - store.modelOverride.value = enabledModels.value[0]?.model_id || null // SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists wasNearBottom = true // B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false) @@ -813,93 +701,16 @@ watch(() => store.state.activeConversationId, () => { // 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组) const convSidebarRef = ref | null>(null) -const activeProviderName = computed(() => { - const active = store.state.providers.find(p => p.id === store.state.activeProvider) - return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured') -}) - -// ── F-01 阶段6: 顶部模型选择器(自动/指定) ── -// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。 -// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式。 -const activeProviderRecord = computed(() => - store.state.providers.find(p => p.id === store.state.activeProvider) - || store.state.providers.find(p => p.is_default) - || store.state.providers[0] - || null, -) -const enabledModels = computed(() => { - const p = activeProviderRecord.value - if (!p?.model_configs) return [] - // weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。 - // 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。 - return p.model_configs - .filter(m => m.enabled) - .slice() - .sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)) -}) -// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。 -const isSpecifyMode = computed(() => store.modelOverride.value !== null) -const selectedModelId = computed(() => store.modelOverride.value || '') -/** 当前选中模型的 model_id(指定模式);自动模式为空 */ -const modelPickerHint = computed(() => { - if (!enabledModels.value.length) return t('aiChat.noModels') - return isSpecifyMode.value - ? t('aiChat.specifyModeHint') - : t('aiChat.autoModeHint') -}) -/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */ -function modelOptionLabel(m: import('../api/types').ModelConfig): string { - return m.label || m.model_id -} -function setAutoMode() { - store.modelOverride.value = null -} -function setSpecifyMode() { - if (!enabledModels.value.length) return - // 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留) - const cur = store.modelOverride.value - const inPool = cur && enabledModels.value.some(m => m.model_id === cur) - if (!inPool) { - store.modelOverride.value = enabledModels.value[0]?.model_id || null - } -} -function onModelSelect(e: Event) { - const v = (e.target as HTMLSelectElement).value - store.modelOverride.value = v || null -} +// 第三批抽离: activeProviderName/activeProviderRecord/enabledModels/isSpecifyMode/ +// selectedModelId/modelPickerHint/modelOptionLabel/setAutoMode/setSpecifyMode/onModelSelect/ +// cycleProvider/providerBarVisible/showProviderSwitched 已迁移至 ai/TopBar.vue +// (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。 // 当前视图是否正在生成(切走后台生成时光标不显示) const isViewingGenerating = computed(() => store.state.streaming && store.state.generatingConvId === store.state.activeConversationId ) -// 循环切换 Provider(provider bar 点击) -function cycleProvider() { - const ps = store.state.providers - if (ps.length < 2) return - // F-260616-10: activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0] - const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null - const idx = startId ? ps.findIndex(p => p.id === startId) : -1 - // idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到) - const next = ps[(Math.max(idx, 0) + 1) % ps.length] - store.setProvider(next.id) - // UX-2025-14: 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。 - // toast 机制:复用本组件自管 reactive showToast(分离窗口无 App.vue 根 toast)。 - showProviderSwitched(next.name || next.id) -} - -// ── UX-2025-14: Provider 切换临时 bar ── -// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。复用与 toast 同款 setTimeout 显隐控制。 -const providerBarVisible = ref(false) -let _providerBarTimer: ReturnType | null = null -function showProviderSwitched(modelName: string) { - // toast:带 model 名插值 - showToast(t('aiChat.providerSwitched', { model: modelName }), 'info') - // bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出 - providerBarVisible.value = true - if (_providerBarTimer) clearTimeout(_providerBarTimer) - _providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000) -} function isLastAi(msg: AiMessage): boolean { const msgs = store.state.messages @@ -1194,8 +1005,6 @@ onBeforeUnmount(() => { if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12 if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04 if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null } - // UX-2025-14:释放 provider 切换 bar 计时器 - if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null } }) // ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时, diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue new file mode 100644 index 0000000..4797ded --- /dev/null +++ b/src/components/ai/TopBar.vue @@ -0,0 +1,287 @@ + + +