重构: 拆audit.rs第一批reason helper(strategy)
- 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)
This commit is contained in:
@@ -87,9 +87,9 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
|
|
||||||
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||||||
|
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
cmd.creation_flags(0x0800_0000);
|
cmd.creation_flags(0x0800_0000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
docs/todo.md
10
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 新需求(任务关联灵感·已分析·待实施)
|
### 💡 2026-06-19 新需求(任务关联灵感·已分析·待实施)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
|
|
||||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
use df_ai::provider::ChatMessage;
|
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::db::Database;
|
||||||
use df_storage::models::AiToolExecutionRecord;
|
use df_storage::models::AiToolExecutionRecord;
|
||||||
|
|
||||||
@@ -121,159 +121,12 @@ pub async fn list_tool_executions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 查项目可读标签:id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。
|
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||||||
///
|
// 拆至子模块 audit/reason.rs(本批 helper 抽离,行为零变更)。
|
||||||
/// AR-3:审批卡片需显示对象名而非裸 id(用户反馈"只返回 ID 不知道是什么数据")。
|
mod reason;
|
||||||
/// 三臂区分:
|
|
||||||
/// - Ok(Some) → 项目名标签
|
|
||||||
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id)
|
|
||||||
/// - Err → 裸 id 回退 + warn 日志(DB 故障/锁/连接断,不误报"已不存在"误导用户)
|
|
||||||
async fn resolve_project_label(db: &Arc<Database>, 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 三臂语义。
|
// process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。
|
||||||
///
|
use reason::build_approval_reason;
|
||||||
/// 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<Database>, 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<Database>,
|
|
||||||
) -> 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||||
///
|
///
|
||||||
167
src-tauri/src/commands/ai/audit/reason.rs
Normal file
167
src-tauri/src/commands/ai/audit/reason.rs
Normal file
@@ -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<Database>, 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<Database>, 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<Database>,
|
||||||
|
) -> 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,140 +9,26 @@
|
|||||||
/>
|
/>
|
||||||
<!-- ═══ 聊天区域 ═══ -->
|
<!-- ═══ 聊天区域 ═══ -->
|
||||||
<div class="ai-chat-area">
|
<div class="ai-chat-area">
|
||||||
<!-- Header -->
|
<!-- ═══ 第三批抽离至 ai/TopBar.vue(顶部工具栏:provider/model 选择器 + 标题 + 审批角标 + 顶部操作 + provider bar,零行为变更) ═══ -->
|
||||||
<div class="ai-header">
|
<TopBar
|
||||||
<div class="ai-header-left">
|
:detached="props.detached"
|
||||||
<button class="ai-btn-icon" @click="store.toggleSidebar()" :title="$t('aiChat.conversationList')">
|
:has-active-messages="hasActiveMessages"
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
:pending-approval-count="pendingApprovalCount"
|
||||||
</button>
|
:always-on-top="alwaysOnTop"
|
||||||
<span class="ai-header-icon">
|
@toggle-sidebar="store.toggleSidebar()"
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
@new-conversation="confirmNewConversation"
|
||||||
</span>
|
@clear-context="handleClearContext"
|
||||||
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
|
@compress-context="handleCompressContext"
|
||||||
<div
|
@clear-chat="confirmClearChat"
|
||||||
v-if="pendingApprovalCount > 0"
|
@scroll-to-pending="scrollToFirstPending"
|
||||||
class="ai-approval-badge"
|
@toggle-maximize="store.toggleMaximize()"
|
||||||
:title="$t('aiChat.pendingApprovalHint')"
|
@detach="store.detachPanel()"
|
||||||
@click="scrollToFirstPending"
|
@close-panel="store.togglePanel()"
|
||||||
>
|
@dock="store.dockDetached()"
|
||||||
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
@toggle-always-on-top="toggleAlwaysOnTop"
|
||||||
</div>
|
@close-detached="store.closeDetachedWindow()"
|
||||||
</div>
|
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
||||||
<!-- F-01 阶段6: 模型选择器(自动/指定)。
|
/>
|
||||||
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话。
|
|
||||||
provider 无 model_configs(空池)时:下拉空 + 强制自动(toggle 禁用)。
|
|
||||||
仅展示 active provider 的 enabled model_configs。 -->
|
|
||||||
<div class="ai-model-picker" :title="modelPickerHint">
|
|
||||||
<div class="ai-model-mode">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-model-mode-btn"
|
|
||||||
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
|
|
||||||
:disabled="!enabledModels.length"
|
|
||||||
@click="setAutoMode"
|
|
||||||
>{{ $t('aiChat.autoMode') }}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-model-mode-btn"
|
|
||||||
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
|
|
||||||
:disabled="!enabledModels.length"
|
|
||||||
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
|
|
||||||
@click="setSpecifyMode"
|
|
||||||
>{{ $t('aiChat.specifyMode') }}</button>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
v-if="enabledModels.length"
|
|
||||||
class="ai-model-select"
|
|
||||||
:value="selectedModelId"
|
|
||||||
:disabled="!isSpecifyMode"
|
|
||||||
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
|
|
||||||
@change="onModelSelect($event)"
|
|
||||||
>
|
|
||||||
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
|
|
||||||
{{ modelOptionLabel(m) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-header-actions">
|
|
||||||
<button class="ai-btn-icon" @click="confirmNewConversation" :title="$t('aiChat.newConversation')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)。
|
|
||||||
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC)。 -->
|
|
||||||
<button
|
|
||||||
class="ai-btn-icon"
|
|
||||||
:disabled="!hasActiveMessages || store.state.streaming"
|
|
||||||
:title="$t('aiChat.clearContext')"
|
|
||||||
@click="handleClearContext"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)。
|
|
||||||
loading(isCompressing)时禁用 + 显示 spinner;空/全归档/生成中禁用。 -->
|
|
||||||
<button
|
|
||||||
class="ai-btn-icon"
|
|
||||||
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
|
|
||||||
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
|
||||||
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
|
|
||||||
@click="handleCompressContext"
|
|
||||||
>
|
|
||||||
<svg v-if="!store.isCompressing.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
||||||
<svg v-else class="ai-btn-spinner" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
|
||||||
<button class="ai-btn-icon" @click="confirmClearChat" :title="$t('aiChat.clearChat')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
|
||||||
<template v-if="!props.detached">
|
|
||||||
<button class="ai-btn-icon" @click="store.toggleMaximize()" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
|
||||||
<svg v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
|
||||||
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-btn-icon" @click="store.detachPanel()" :title="$t('aiChat.detachWindow')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-btn-icon" @click="store.togglePanel()" :title="$t('aiChat.closePanel')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<!-- 分离模式:吸附 + 置顶 + 关闭 -->
|
|
||||||
<template v-else>
|
|
||||||
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="store.dockDetached()" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="toggleAlwaysOnTop" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-btn-icon" @click="store.closeDetachedWindow()" :title="$t('aiChat.closeWindow')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 提供商状态 -->
|
|
||||||
<div
|
|
||||||
class="ai-provider-bar"
|
|
||||||
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
|
|
||||||
v-if="store.state.providers.length > 0"
|
|
||||||
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
|
|
||||||
@click="cycleProvider"
|
|
||||||
>
|
|
||||||
<span class="provider-dot"></span>
|
|
||||||
<span class="provider-name">{{ activeProviderName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-provider-bar ai-provider-empty" v-else>
|
|
||||||
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出) -->
|
|
||||||
<Transition name="ai-provider-switch">
|
|
||||||
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
|
|
||||||
<span class="ai-provider-switch-dot"></span>
|
|
||||||
<span class="ai-provider-switch-text">{{ $t('aiChat.providerSwitchHint', { model: activeProviderName }) }}</span>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
|
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
|
||||||
@@ -501,6 +387,7 @@ import { aiApi } from '../api'
|
|||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||||
import ChatInput from './ai/ChatInput.vue'
|
import ChatInput from './ai/ChatInput.vue'
|
||||||
|
import TopBar from './ai/TopBar.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
import { useMarkdown } from '../composables/useMarkdown'
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import ToolCardList from './ToolCardList.vue'
|
import ToolCardList from './ToolCardList.vue'
|
||||||
@@ -799,9 +686,10 @@ const projectsStore = useProjectStore()
|
|||||||
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
// 2026-06-18 改:打开/切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
||||||
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。
|
// 不随机/不摇摆)。enabledModels 已按 weight 降序,[0]=最高。空池(未拉取)落 null=auto 兜底。
|
||||||
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
||||||
|
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
|
||||||
|
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消 + 滚动边沿重置。
|
||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
if (editingMsgId.value) cancelEdit()
|
if (editingMsgId.value) cancelEdit()
|
||||||
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
|
||||||
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
|
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
|
||||||
wasNearBottom = true
|
wasNearBottom = true
|
||||||
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
|
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
|
||||||
@@ -813,93 +701,16 @@ watch(() => store.state.activeConversationId, () => {
|
|||||||
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
|
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
|
||||||
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
||||||
|
|
||||||
const activeProviderName = computed(() => {
|
// 第三批抽离: activeProviderName/activeProviderRecord/enabledModels/isSpecifyMode/
|
||||||
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
// selectedModelId/modelPickerHint/modelOptionLabel/setAutoMode/setSpecifyMode/onModelSelect/
|
||||||
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
// cycleProvider/providerBarVisible/showProviderSwitched 已迁移至 ai/TopBar.vue
|
||||||
})
|
// (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。
|
||||||
|
|
||||||
// ── 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当前视图是否正在生成(切走后台生成时光标不显示)
|
// 当前视图是否正在生成(切走后台生成时光标不显示)
|
||||||
const isViewingGenerating = computed(() =>
|
const isViewingGenerating = computed(() =>
|
||||||
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
|
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<typeof setTimeout> | 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 {
|
function isLastAi(msg: AiMessage): boolean {
|
||||||
const msgs = store.state.messages
|
const msgs = store.state.messages
|
||||||
@@ -1194,8 +1005,6 @@ onBeforeUnmount(() => {
|
|||||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
||||||
// UX-2025-14:释放 provider 切换 bar 计时器
|
|
||||||
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
|
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
|
||||||
|
|||||||
287
src/components/ai/TopBar.vue
Normal file
287
src/components/ai/TopBar.vue
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ 第三批抽离: 顶部工具栏(provider/model 选择器 + 标题 + 审批角标 + 顶部操作),
|
||||||
|
从 AiChat.vue 行 12-145 迁移,零行为变更。store 单例共享(useAiStore 同实例),
|
||||||
|
动作按钮经 emit 回父(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。 ═══ -->
|
||||||
|
<div class="ai-header">
|
||||||
|
<div class="ai-header-left">
|
||||||
|
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="ai-header-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
|
||||||
|
<div
|
||||||
|
v-if="pendingApprovalCount > 0"
|
||||||
|
class="ai-approval-badge"
|
||||||
|
:title="$t('aiChat.pendingApprovalHint')"
|
||||||
|
@click="emit('scroll-to-pending')"
|
||||||
|
>
|
||||||
|
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- F-01 阶段6: 模型选择器(自动/指定)。
|
||||||
|
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话。
|
||||||
|
provider 无 model_configs(空池)时:下拉空 + 强制自动(toggle 禁用)。
|
||||||
|
仅展示 active provider 的 enabled model_configs。 -->
|
||||||
|
<div class="ai-model-picker" :title="modelPickerHint">
|
||||||
|
<div class="ai-model-mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ai-model-mode-btn"
|
||||||
|
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
|
||||||
|
:disabled="!enabledModels.length"
|
||||||
|
@click="setAutoMode"
|
||||||
|
>{{ $t('aiChat.autoMode') }}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ai-model-mode-btn"
|
||||||
|
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
|
||||||
|
:disabled="!enabledModels.length"
|
||||||
|
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
|
||||||
|
@click="setSpecifyMode"
|
||||||
|
>{{ $t('aiChat.specifyMode') }}</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-if="enabledModels.length"
|
||||||
|
class="ai-model-select"
|
||||||
|
:value="selectedModelId"
|
||||||
|
:disabled="!isSpecifyMode"
|
||||||
|
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
|
||||||
|
@change="onModelSelect($event)"
|
||||||
|
>
|
||||||
|
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
|
||||||
|
{{ modelOptionLabel(m) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-header-actions">
|
||||||
|
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)。
|
||||||
|
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC)。 -->
|
||||||
|
<button
|
||||||
|
class="ai-btn-icon"
|
||||||
|
:disabled="!hasActiveMessages || store.state.streaming"
|
||||||
|
:title="$t('aiChat.clearContext')"
|
||||||
|
@click="emit('clear-context')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)。
|
||||||
|
loading(isCompressing)时禁用 + 显示 spinner;空/全归档/生成中禁用。 -->
|
||||||
|
<button
|
||||||
|
class="ai-btn-icon"
|
||||||
|
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
|
||||||
|
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
||||||
|
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
|
||||||
|
@click="emit('compress-context')"
|
||||||
|
>
|
||||||
|
<svg v-if="!store.isCompressing.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
|
<svg v-else class="ai-btn-spinner" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
||||||
|
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
||||||
|
<template v-if="!detached">
|
||||||
|
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
||||||
|
<svg v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||||
|
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<!-- 分离模式:吸附 + 置顶 + 关闭 -->
|
||||||
|
<template v-else>
|
||||||
|
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提供商状态 -->
|
||||||
|
<div
|
||||||
|
class="ai-provider-bar"
|
||||||
|
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
|
||||||
|
v-if="store.state.providers.length > 0"
|
||||||
|
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
|
||||||
|
@click="cycleProvider"
|
||||||
|
>
|
||||||
|
<span class="provider-dot"></span>
|
||||||
|
<span class="provider-name">{{ activeProviderName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-provider-bar ai-provider-empty" v-else>
|
||||||
|
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出) -->
|
||||||
|
<Transition name="ai-provider-switch">
|
||||||
|
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
|
||||||
|
<span class="ai-provider-switch-dot"></span>
|
||||||
|
<span class="ai-provider-switch-text">{{ $t('aiChat.providerSwitchHint', { model: activeProviderName }) }}</span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移)
|
||||||
|
// 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父,
|
||||||
|
// 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。
|
||||||
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import type { ModelConfig } from '../../api/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
|
||||||
|
detached?: boolean
|
||||||
|
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
|
||||||
|
hasActiveMessages: boolean
|
||||||
|
/** 待审批数(审批角标显隐 + 计数;父 computed) */
|
||||||
|
pendingApprovalCount: number
|
||||||
|
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
|
||||||
|
alwaysOnTop: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
|
||||||
|
(e: 'toggle-sidebar'): void
|
||||||
|
/** 新建会话(父 confirmNewConversation 带二次确认) */
|
||||||
|
(e: 'new-conversation'): void
|
||||||
|
/** 清空上下文(归档保留;父 handleClearContext) */
|
||||||
|
(e: 'clear-context'): void
|
||||||
|
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
|
||||||
|
(e: 'compress-context'): void
|
||||||
|
/** 清空对话(真删 DB;父 confirmClearChat 带二次确认) */
|
||||||
|
(e: 'clear-chat'): void
|
||||||
|
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
|
||||||
|
(e: 'scroll-to-pending'): void
|
||||||
|
/** 最大化/还原(store.toggleMaximize) */
|
||||||
|
(e: 'toggle-maximize'): void
|
||||||
|
/** 分离窗口(store.detachPanel) */
|
||||||
|
(e: 'detach'): void
|
||||||
|
/** 关闭面板(store.togglePanel) */
|
||||||
|
(e: 'close-panel'): void
|
||||||
|
/** 吸附/取消吸附(store.dockDetached) */
|
||||||
|
(e: 'dock'): void
|
||||||
|
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
|
||||||
|
(e: 'toggle-always-on-top'): void
|
||||||
|
/** 关闭分离窗口(store.closeDetachedWindow) */
|
||||||
|
(e: 'close-detached'): void
|
||||||
|
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
|
||||||
|
(e: 'provider-switched', modelName: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
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: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
||||||
|
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
|
||||||
|
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
|
||||||
|
watch(() => store.state.activeConversationId, () => {
|
||||||
|
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 循环切换 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),经 emit 回父弹。
|
||||||
|
showProviderSwitched(next.name || next.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UX-2025-14: Provider 切换临时 bar ──
|
||||||
|
// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。本子组件自管 bar 显隐(ref + Transition),
|
||||||
|
// toast 经 emit 回父(父持有 toast reactive 容器)。
|
||||||
|
const providerBarVisible = ref(false)
|
||||||
|
let _providerBarTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function showProviderSwitched(modelName: string) {
|
||||||
|
// toast:经 emit 回父(单一 toast 源)
|
||||||
|
emit('provider-switched', modelName)
|
||||||
|
// bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出
|
||||||
|
providerBarVisible.value = true
|
||||||
|
if (_providerBarTimer) clearTimeout(_providerBarTimer)
|
||||||
|
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UX-2025-14:释放 provider 切换 bar 计时器(从 AiChat onBeforeUnmount 迁移,与 _providerBarTimer 同处)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user