Compare commits

..
2 Commits
29 changed files with 1159 additions and 1003 deletions
Generated
+1
View File
@@ -953,6 +953,7 @@ dependencies = [
"anyhow",
"axum",
"df-nodes",
"df-project",
"df-storage",
"df-types",
"futures",
+3
View File
@@ -11,6 +11,9 @@ df-types = { path = "../df-types" }
# 含 is_valid_state+can_transition+同态拒绝三层校验),避免 MCP 直调底层
# advance_status_atomic 绕过状态机(防止外部客户端非法跳态 todo→done)。
df-nodes = { path = "../df-nodes" }
# df-project: bind_directory 复用技术栈探测(detect_stack)与路径规范化(normalize_path),
# 对齐 GUI tool_registry.rs::bind_dir_to_project,避免在本 crate 重复实现镜像。
df-project = { path = "../df-project" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+85 -1
View File
@@ -215,7 +215,19 @@ pub(crate) async fn dispatch(ctx: &Ctx, read_only: bool, id: Option<Value>, meth
let r = CallToolResult::error(msg);
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
// Low / Medium:执行
// Low / Medium:执行前先校验 schema 必填参数(缺必填直接拒,
// 防 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入)
let missing: Vec<String> = required_names(&spec.tool.input_schema)
.into_iter()
.filter(|name| arguments.get(name).map_or(true, |v| v.is_null()))
.collect();
if !missing.is_empty() {
let r = CallToolResult::error(format!(
"缺少必填参数: {}",
missing.join(", ")
));
return Response::ok(id, serde_json::to_value(r).unwrap_or(Value::Null));
}
let result = (spec.handler)(ctx, arguments).await;
Response::ok(id, serde_json::to_value(result).unwrap_or(Value::Null))
}
@@ -235,6 +247,30 @@ fn should_execute(read_only: bool, risk: RiskLevel) -> bool {
!(read_only && risk != RiskLevel::Low) && risk != RiskLevel::High
}
/// 从工具 inputSchema 提取必填参数名列表。
///
/// 兼容 MCP schema required 的两种形态:array 显式列出必填属性;bool true 表示全部
/// properties 必填;false/缺失表示无必填。供 tools/call 执行前校验参数完整性,
/// 缺必填直接拒绝,杜绝 handler 内 arg_str_or 静默兜底把缺参当空串/默认值写入。
fn required_names(schema: &Value) -> Vec<String> {
match schema.get("required") {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
Some(Value::Bool(true)) => {
let mut names: Vec<String> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
}
_ => Vec::new(),
}
}
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
fn fire_write_hook(config: &ServerConfig, name: &str) {
@@ -415,6 +451,54 @@ mod tests {
assert_eq!(list_v["projects"][0]["name"], "McpProj");
}
// ── schema required 校验(dispatch 层缺必填直接拒)────────────────
#[tokio::test]
async fn tools_call_missing_required_arg_returns_error() {
// create_project schema required 含 name:缺 name 应被 dispatch 层拒绝
// (而非 handler 内 arg_str_or 静默兜底把 description 当空串)
let input =
r#"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"create_project","arguments":{"description":"缺 name"}}}"#;
let out = run_io_lines(&[input], false).await;
let v: Value = serde_json::from_str(&out[0]).unwrap();
assert_eq!(v["result"]["isError"], true);
let text = v["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("缺少必填参数: name"), "实际: {text}");
}
#[test]
fn required_names_from_array() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {} },
"required": ["id", "name"]
});
assert_eq!(required_names(&schema), vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn required_names_from_bool_true_means_all_properties() {
let schema = json!({
"type": "object",
"properties": { "id": {}, "name": {}, "desc": {} },
"required": true
});
// bool true = 全部 properties 必填,返回全部属性名(排序保证确定性)
assert_eq!(
required_names(&schema),
vec!["desc".to_string(), "id".to_string(), "name".to_string()]
);
}
#[test]
fn required_names_false_or_missing_is_empty() {
assert_eq!(required_names(&json!({ "type": "object" })), Vec::<String>::new());
assert_eq!(
required_names(&json!({ "type": "object", "required": false })),
Vec::<String>::new()
);
}
#[tokio::test]
async fn visible_predicate() {
assert!(visible(false, RiskLevel::Low));
+82 -39
View File
@@ -104,19 +104,19 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
// ─── 项目 ───
spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_projects),
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name", "description"]), Medium, create_project),
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name"]), Medium, create_project),
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_project),
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
// ─── 任务 ───
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task),
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title"]), Medium, create_task),
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task),
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
// ─── 灵感 ───
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title"]), Medium, create_idea),
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea),
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
@@ -455,26 +455,55 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
medium_audit("bind_directory", &format!("{id} <- {path}"));
Box::pin(async move {
let repo = ProjectRepo::new(&db);
// 目录必须真实存在(对齐 GUI bind_dir_to_project:先 is_dir 校验再往下走)
if !std::path::Path::new(&path).is_dir() {
return CallToolResult::error(format!("目录不存在: {path}"));
}
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
if has_traversal {
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
}
let norm = normalize_path(&path);
// 路径冲突检测
// canonicalize 解析真实绝对路径:目录已存在,符号链接在此被解析,
// 防经 symlink 逃逸到预期外目录(对齐 GUI normalize_path 的 canonicalize 优先)。
let canon = match std::path::Path::new(&path).canonicalize() {
Ok(c) => c,
Err(e) => return CallToolResult::error(format!("路径解析失败: {path}: {e}")),
};
// 路径冲突检测(规范化比较,防路径写法差异绕过,复用 Repo 统一实现)
let norm = df_project::scan::normalize_path(&path);
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
return CallToolResult::error(format!(
"路径已被项目「{}」({})绑定,请先解绑",
conflict.name, conflict.id
));
}
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
// 技术栈探测:detect_stack 内含多次同步 fs IO,spawn_blocking 防阻塞 tokio runtime
// (与 GUI bind_dir_to_project 同法;用 canonicalize 后的真实目录探测)
let stack_dir = canon;
let stack = match tokio::task::spawn_blocking(move || {
df_project::scan::detect_stack(&stack_dir)
})
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => return CallToolResult::error(format!("技术栈探测失败: {e}")),
Err(e) => return CallToolResult::error(format!("技术栈探测任务失败: {e}")),
};
let stack_json = match serde_json::to_string(&stack) {
Ok(s) => s,
Err(e) => return CallToolResult::error(format!("技术栈序列化失败: {e}")),
};
// 写回 path(规范化后的真实路径)与 stack 字段,其余字段保留
match repo.update_field(&id, "path", &norm).await {
Ok(true) => {}
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
}
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
return err_str(e);
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
})
@@ -951,39 +980,6 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
})
}
// ============================================================
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
// ============================================================
fn normalize_path(p: &str) -> String {
// 先尝试 canonicalize(解析符号链接 + 绝对化 + .. 折叠,仅已存在的路径有效)
if let Ok(abs) = std::path::Path::new(p).canonicalize() {
return abs.to_string_lossy().replace('\\', "/").to_lowercase();
}
// fallback:路径尚未创建,手动做以下处理:
// ① 统一分隔符
// ② 逐段折叠 ..(防 foo/../bar → foo/bar)
// ③ 去尾斜杠
// ④ 小写化
let normalized = p.replace('\\', "/");
let mut segments: Vec<&str> = Vec::new();
for seg in normalized.split('/') {
match seg {
"." | "" => continue, // 当前目录 / 空段(连续斜杠)
".." if segments.is_empty() => segments.push(".."), // 根级 .. 保留(相对路径语义)
".." => { segments.pop(); } // 上级 → 弹出上一段
_ => segments.push(seg),
}
}
let result = if segments.is_empty() {
String::new()
} else {
segments.join("/")
};
// 去尾斜杠
result.trim_end_matches('/').to_lowercase()
}
// ============================================================
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
// ============================================================
@@ -1514,4 +1510,51 @@ mod tests {
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["has_more"], false);
}
// ── bind_directory(对齐 GUI:is_dir 校验 + canonicalize + stack 探测)──
#[tokio::test]
async fn bind_directory_rejects_non_existent_dir() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let fake = std::env::temp_dir().join(format!("df_mcp_no_such_{}", uuid::Uuid::new_v4()));
let r = bind_directory(&ctx, json!({ "id": pid, "path": fake.to_string_lossy() })).await;
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("目录不存在"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_rejects_traversal_path() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let root = std::env::temp_dir().join(format!("df_mcp_trav_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(root.join("sub")).unwrap();
// 真实存在但含 `..` 段的路径:is_dir 通过后应由遍历检测拒绝
let path = root.join("sub").join("..").join("sub");
let r = bind_directory(&ctx, json!({ "id": pid, "path": path.to_string_lossy() })).await;
std::fs::remove_dir_all(&root).ok();
assert_eq!(r.is_error, Some(true));
assert!(text_of(&r).contains("'..'"), "实际: {}", text_of(&r));
}
#[tokio::test]
async fn bind_directory_binds_real_dir_and_writes_stack() {
let ctx = test_ctx().await;
let pid = seed_project(&ctx, "项目").await;
let dir = std::env::temp_dir().join(format!("df_mcp_bind_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
// 放一个 Cargo.toml 让 detect_stack 识别为 rust,验证 stack 探测生效
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let r = bind_directory(&ctx, json!({ "id": pid, "path": dir.to_string_lossy() })).await;
std::fs::remove_dir_all(&dir).ok();
assert!(r.is_error.is_none(), "绑定失败: {:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["id"], pid);
let stack = v["project"]["stack"].as_str().unwrap_or_default();
assert!(stack.contains("rust"), "应探测到 rust 技术栈,实际 stack: {stack}");
}
}
+12 -1
View File
@@ -176,7 +176,18 @@ async fn resolve_and_inject(
return String::new();
}
let loc = sanitize::locality_of(provider);
let (augs, _errors) = state.resolvers.resolve_all(&refs, loc).await;
let (augs, errors) = state.resolvers.resolve_all(&refs, loc).await;
// 技能/提及注入失败透出:单条 resolve 失败不阻断整批注入(既有语义保留),
// 但失败不能静默——warn 日志让技能注入失败可见(不改前端行为)。
if !errors.is_empty() {
tracing::warn!(
skill = ?skill,
fail_count = errors.len(),
"[ai] 技能/提及注入失败 {} 条(已跳过,不阻断注入): {:?}",
errors.len(),
errors
);
}
build_augmentation_segment(&augs, lang)
}
+69 -12
View File
@@ -148,6 +148,27 @@ fn accumulate_tool_calls(
}
}
/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。
fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
/// AR-8: flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() {
return;
}
let delta = std::mem::take(pending_delta);
emit_ai_chat_event(
app_handle,
AiChatEvent::AiTextDelta {
delta,
conversation_id: Some(conv_id.to_string()),
},
);
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
@@ -184,6 +205,10 @@ pub(crate) async fn stream_llm(
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// AR-8: 后端 delta 合批窗口。50ms 内的多次 AiTextDelta 合并为一条再 emit,
/// 降低 IPC 事件风暴(前端 rAF 只节流渲染不节流 IPC);合并后前端 currentText
/// 逐条累加,最终一致。
const DELTA_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
// ================================================================
// BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的
@@ -264,6 +289,10 @@ pub(crate) async fn stream_llm(
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None;
// AR-8: per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
// next_flush_at 记录下次 flush 的绝对时刻(首个入 buffer 时置 now+50ms)。
let mut pending_delta = String::new();
let mut next_flush_at: Option<tokio::time::Instant> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
@@ -290,6 +319,7 @@ pub(crate) async fn stream_llm(
// BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。
if tokio::time::Instant::now() >= idle_deadline {
if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: true,
error: format!(
@@ -304,6 +334,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -312,10 +343,24 @@ pub(crate) async fn stream_llm(
};
}
// 三分支 select!:
// AR-8: 到点 flush 合批的 delta(单条 AiTextDelta 双写 emit + event_bus)。
if let Some(deadline) = next_flush_at {
if tokio::time::Instant::now() >= deadline {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
// 多分支 select!:
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) notify.notified():用户停止即时打断
// 4) flush 定时器(sleep_until next_flush_at):尾部 delta 不因流静默而延迟(AR-8)。
let flush_deadline = next_flush_at
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
let flush_fut = tokio::time::sleep_until(flush_deadline);
tokio::pin!(flush_fut);
tokio::select! {
chunk_result = tokio::time::timeout(
Duration::from_secs(15),
@@ -325,6 +370,7 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => {
// 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。
if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: true,
error: format!(
@@ -339,6 +385,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -355,13 +402,11 @@ pub(crate) async fn stream_llm(
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
// L3 emit 双写
let ev = AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-8: 合批入 buffer,首个入 buffer 时置 flush 时刻(到点统一 emit 单条)。
pending_delta.push_str(&chunk.delta);
if next_flush_at.is_none() {
next_flush_at = Some(tokio::time::Instant::now() + DELTA_FLUSH_INTERVAL);
}
}
if let Some(tc_deltas) = &chunk.tool_calls {
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
@@ -395,6 +440,7 @@ pub(crate) async fn stream_llm(
"[ai] provider 流式错误事件",
);
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable,
error: fmt_diag(
@@ -405,6 +451,7 @@ pub(crate) async fn stream_llm(
),
};
}
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -429,6 +476,7 @@ pub(crate) async fn stream_llm(
"[ai] 流式接收中途错误(from 专用线程)",
);
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag(
@@ -439,6 +487,7 @@ pub(crate) async fn stream_llm(
),
};
}
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -450,11 +499,9 @@ pub(crate) async fn stream_llm(
}
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
_ = heartbeat.tick() => {
let ev = AiChatEvent::AiHeartbeat {
emit_ai_chat_event(app_handle, AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
}
// 3) 即时停止唤醒(B-260615-14)
_ = notify.notified() => {
@@ -463,11 +510,19 @@ pub(crate) async fn stream_llm(
break;
}
}
// 4) AR-8: delta 合批到点 flush(流静默/工具执行等待期也能按时发出尾部文本)
_ = &mut flush_fut => {
if next_flush_at.is_some() {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
}
}
// 用户停止
if stopped {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
@@ -490,6 +545,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -499,6 +555,7 @@ pub(crate) async fn stream_llm(
}
// 正常完成
flush_delta(app_handle, conv_id, &mut pending_delta);
StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
+9
View File
@@ -618,6 +618,15 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
});
// 补注册 5 个实现完整的孤儿节点(git/docker/http/notify/subflow):
// 均为无参单元结构体(impl Node),与 human 节点同风格注册(工厂闭包直接 Box 结构体,
// 无需捕获 db)。此前未接线,前端 DAG 用对应节点类型会报「未注册」。
// "script" 节点保持禁用(ScriptNode 走 cmd/sh 无审批,见上方 R-PD-2 说明)。
registry.register("git", |_config| Box::new(df_nodes::git_node::GitNode));
registry.register("docker", |_config| Box::new(df_nodes::docker_node::DockerNode));
registry.register("http", |_config| Box::new(df_nodes::http_node::HttpNode));
registry.register("notify", |_config| Box::new(df_nodes::notify_node::NotifyNode));
registry.register("subflow", |_config| Box::new(df_nodes::subflow_node::SubflowNode));
registry
}
+2 -1
View File
@@ -114,7 +114,8 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
import { getConvState } from '../../composables/ai/useAiEvents'
import { textIdle } from '../../composables/ai/useAiPendingState'
import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog'
+1 -1
View File
@@ -43,7 +43,7 @@
import { reactive, computed, watch, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
import { pendingDirAuths } from '../../composables/ai/useAiPendingState'
import { aiApi } from '../../api'
const emit = defineEmits<{
+1 -1
View File
@@ -25,7 +25,7 @@
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingHelp } from '../../composables/ai/useAiEvents'
import { pendingHelp } from '../../composables/ai/useAiPendingState'
// toast emit ( toast , MaxRoundsCard )
const emit = defineEmits<{
+2 -1
View File
@@ -27,7 +27,8 @@
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
import { getConvState } from '../../composables/ai/useAiEvents'
import { pendingMaxRounds } from '../../composables/ai/useAiPendingState'
import { aiApi } from '../../api'
const emit = defineEmits<{
+13 -10
View File
@@ -171,6 +171,7 @@ const {
scheduleStreamParse,
clearStreamingState,
cancelPendingRaf,
resetStreamCache,
} = useStreamRenderer({
saveSelection,
restoreSelection,
@@ -715,6 +716,8 @@ watch(() => store.state.activeConversationId, () => {
} else {
cancelPendingRaf()
}
// memo (_blockCache),
resetStreamCache()
})
// rAF : pending rAF(streaming /),
@@ -898,7 +901,7 @@ defineExpose({
:aria-expanded="tokenPopoverMsgId === item.msg.id"
@click="toggleTokenPopover(item.msg, $event)"
>
<span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></span>
<span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
<span class="ai-token-sep">·</span>
@@ -914,34 +917,34 @@ defineExpose({
class="ai-token-popover"
@click.stop
>
<div class="ai-token-popover-title">Token 用量详情<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></div>
<div class="ai-token-popover-title">{{ $t('aiChat.tokenDetailTitle') }}<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">输入(未命中,全价)</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">缓存命中(低价)</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<span class="ai-token-popover-label">输出</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenOutLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
</div>
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
<span class="ai-token-popover-label">思考(reasoning)</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">缓存命中率</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
</div>
<div v-if="item.msg.model" class="ai-token-popover-row">
<span class="ai-token-popover-label">模型</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenModelLabel') }}</span>
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
</div>
<div v-if="tokenEstimated(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">用量说明</span>
<span class="ai-token-popover-val">prompt_tokens 缺失,按估算展示</span>
<span class="ai-token-popover-label">{{ $t('aiChat.tokenUsageNoteLabel') }}</span>
<span class="ai-token-popover-val">{{ $t('aiChat.tokenUsageNote') }}</span>
</div>
</div>
</div>
+12 -8
View File
@@ -172,8 +172,8 @@
<div class="ai-context-section-title">{{ $t('aiChat.contextSystemPrompt') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">目标({{ contextInfo.goals.length }})</span>
<span class="ai-context-value">{{ contextInfo.goals.join('; ') || '无' }}</span>
<span class="ai-context-label">{{ $t('aiChat.goalsCount', { n: contextInfo.goals.length }) }}</span>
<span class="ai-context-value">{{ contextInfo.goals.join('; ') || $t('aiChat.none') }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.contextEnvironment') }}</span>
@@ -190,8 +190,8 @@
<div class="ai-context-section-title">{{ $t('aiChat.contextEnrichment') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">@项目</span>
<span class="ai-context-value">{{ contextInfo.projectNames || '无' }}</span>
<span class="ai-context-label">{{ $t('aiChat.projectLabel') }}</span>
<span class="ai-context-value">{{ contextInfo.projectNames || $t('aiChat.none') }}</span>
</div>
</div>
</div>
@@ -325,9 +325,13 @@ function onModelSelect(e: Event) {
store.modelOverride.value = v || null
}
// model
// :/(,)
watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
const current = store.modelOverride.value
const valid = current && enabledModels.value.some(m => m.model_id === current)
if (!valid) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
})
// provider override (BUG-2026-07-07:provider header ):
@@ -372,7 +376,7 @@ function fmtTime(ts: number): string {
const yest = new Date(now)
yest.setDate(yest.getDate() - 1)
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `昨天 ${hm}`
return `${t('aiChat.yesterday')} ${hm}`
}
//
return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}`
@@ -481,7 +485,7 @@ const contextInfo = computed<ContextInfo>(() => {
goals: conv?.pinned_goals ?? [],
summaryCount: summaryMsgs.value.length,
osInfo: 'Windows 11 / PowerShell',
projectNames: Array.from(projectLabels).join(', ') || '无',
projectNames: Array.from(projectLabels).join(', ') || t('aiChat.none'),
}
})
+21 -21
View File
@@ -3,10 +3,10 @@
<div class="dep-graph__toolbar">
<span class="dep-graph__title">{{ $t('dependencyGraph.title') }}</span>
<div class="dep-graph__actions">
<button v-if="modules.length >= 2" class="btn btn-ghost btn-sm" @click="showAddDep = true">+ 依赖</button>
<button v-if="dependencies.length > 0" class="btn btn-ghost btn-sm" @click="checkCycles">🔍 环检测</button>
<button class="btn btn-ghost btn-sm" @click="exportPNG">📷 导出</button>
<button class="btn btn-ghost btn-sm" @click="fitContent">适应内容</button>
<button v-if="modules.length >= 2" class="btn btn-ghost btn-sm" @click="showAddDep = true">{{ $t('dependencyGraph.addDep') }}</button>
<button v-if="dependencies.length > 0" class="btn btn-ghost btn-sm" @click="checkCycles">{{ $t('dependencyGraph.checkCycles') }}</button>
<button class="btn btn-ghost btn-sm" @click="exportPNG">{{ $t('dependencyGraph.exportImage') }}</button>
<button class="btn btn-ghost btn-sm" @click="fitContent">{{ $t('dependencyGraph.fitContent') }}</button>
<button class="btn btn-ghost btn-sm" @click="zoomIn">+</button>
<button class="btn btn-ghost btn-sm" @click="zoomOut">-</button>
</div>
@@ -36,32 +36,32 @@
<!-- 添加依赖弹窗 -->
<div v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false">
<div class="dep-graph__modal">
<h3>添加工程依赖</h3>
<h3>{{ $t('dependencyGraph.addDepTitle') }}</h3>
<div class="dep-graph__modal-field">
<label>源工程(依赖方)</label>
<label>{{ $t('dependencyGraph.sourceLabel') }}</label>
<select v-model="depFrom" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="dep-graph__modal-field">
<label>目标工程(被依赖)</label>
<label>{{ $t('dependencyGraph.targetLabel') }}</label>
<select v-model="depTo" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="dep-graph__modal-field">
<label>依赖类型</label>
<label>{{ $t('dependencyGraph.depTypeLabel') }}</label>
<select v-model="depType" class="dep-graph__modal-input">
<option value="library">类库</option>
<option value="api">API 调用</option>
<option value="mq">消息队列</option>
<option value="shared">共享资源</option>
<option value="custom">自定义</option>
<option value="library">{{ $t('dependencyGraph.typeLibrary') }}</option>
<option value="api">{{ $t('dependencyGraph.typeApi') }}</option>
<option value="mq">{{ $t('dependencyGraph.typeMq') }}</option>
<option value="shared">{{ $t('dependencyGraph.typeShared') }}</option>
<option value="custom">{{ $t('dependencyGraph.typeCustom') }}</option>
</select>
</div>
<div class="dep-graph__modal-actions">
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">取消</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo" @click="onAddDep">确认</button>
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</button>
</div>
</div>
</div>
@@ -257,13 +257,13 @@ async function checkCycles() {
cycleNodes.value = new Set(cycles)
renderGraph()
if (cycles.length > 0) {
Message.warning(`检测到 ${cycles.length} 个环节点(已红框高亮)`)
Message.warning(t('dependencyGraph.cycleWarning', { n: cycles.length }))
} else {
Message.success('未检测到环形依赖')
Message.success(t('dependencyGraph.noCycle'))
}
} catch (e) {
console.error('[DependencyGraph] 环检测失败:', e)
Message.error('环检测失败')
Message.error(t('dependencyGraph.cycleError'))
}
}
@@ -286,17 +286,17 @@ async function exportPNG() {
async function confirmRemoveDep(dep: ModuleDependencyRecord) {
const fromName = modules.value.find(m => m.id === dep.from_module_id)?.name ?? dep.from_module_id
const toName = modules.value.find(m => m.id === dep.to_module_id)?.name ?? dep.to_module_id
const ok = await confirmDialog(`确定删除「${fromName}${toName}」的依赖吗?此操作不可撤销。`, t('common.delete'))
const ok = await confirmDialog(t('dependencyGraph.deleteConfirm', { from: fromName, to: toName }), t('common.delete'))
if (!ok) return
try {
await moduleApi.removeModuleDependency(dep.id)
Message.info('依赖已删除')
Message.info(t('dependencyGraph.deleteSuccess'))
await loadModules()
renderGraph()
bindEdgeDeleteButtons()
} catch (e) {
console.error('[DependencyGraph] 删除依赖失败:', e)
Message.error('删除依赖失败')
Message.error(t('dependencyGraph.deleteError'))
}
}
+126
View File
@@ -11,11 +11,26 @@
//! events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
import { reactive } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { t } from '@/i18n/i18n-helpers'
import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types'
/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */
export function notifyConversationChanged(): void {
emit('ai-conversation-changed', {})
}
/** 后端原始错误转用户友好提示 */
export function friendlyError(raw: string): string {
if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound')
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth')
if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout')
if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork')
return raw
}
/**
* AI (useAiSend.sendMessage / useAiContext.compressContext )
@@ -329,3 +344,114 @@ export function clearConvStreamState(convId: string | null | undefined): void {
//
// 普通 Set(仅事件处理器读,无渲染追踪需求);生命周期由 switchConversation 的 finally 管理。
export const switchingConvs = new Set<string>()
// ── 流式文本回填 + delta 去重(useAiEvents 拆分后跨域共享) ──
//
// flushCurrentText 被 streaming(新轮)/lifecycle(收尾)/send(发送前)三域共用,且需写
// _lastDeltas 去重 Map,故下沉到本共享层。为规避 aiShared ↔ stores/ai 顶层循环引用(TDZ),
// 沿用 __bindMessages 的 getter 注入模式:stores/ai 在 state 就绪后注入 getter,
// 本模块函数运行时惰性取最新 state。
export interface AiStateShape {
currentText: string
messages: AiMessage[]
activeConversationId: string | null
}
let _stateGetter: (() => AiStateShape) | null = null
/** @internal 由 stores/ai.ts 在 state 创建后注入 getter(flushCurrentText 惰性读 state) */
export function __bindState(getter: () => AiStateShape): void {
_stateGetter = getter
}
function getState(): AiStateShape | null {
return _stateGetter?.() ?? null
}
/** per-conv delta 去重 Map(convId → 上一次 delta 内容,根治跨会话撞值误丢) */
const _lastDeltas = new Map<string, string>()
function lastDeltaKey(convId: string | null | undefined): string {
return convId || getState()?.activeConversationId || ''
}
/** 读某会话上一次 delta(去重检查);无记录返回 undefined */
export function getLastDelta(convId: string | null | undefined): string | undefined {
return _lastDeltas.get(lastDeltaKey(convId))
}
/** 记录某会话当前 delta */
export function setLastDelta(convId: string | null | undefined, delta: string): void {
_lastDeltas.set(lastDeltaKey(convId), delta)
}
/** 删除某会话的 delta 记录(会话删除/收尾时调用,防 Map 无限增长) */
export function clearLastDelta(convId: string | null | undefined): void {
_lastDeltas.delete(lastDeltaKey(convId))
}
/**
* currentText assistant (AiAgentRound/AiCompleted/AiError )
*
* guard whitespace LLM `\n`/, whitespace-only
* content , trim currentText,
* ()
*/
export function flushCurrentText(): void {
const s = getState()
if (!s) return
const resetDelta = () => clearLastDelta(s.activeConversationId)
if (!s.currentText || !s.currentText.trim()) {
s.currentText = ''
resetDelta()
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入(跳过重试错误气泡,保留部分回复)
for (let i = s.messages.length - 1; i >= 0; i--) {
const m = s.messages[i]
if (m.role !== 'assistant') break
if (!m.isError) {
m.content = s.currentText
break
}
}
s.currentText = ''
resetDelta()
}
// ── 工具慢执行计时器(useAiEvents 拆分后跨域共享:tool 域 + lifecycle 收尾共用) ──
//
// 工具执行超时提示(纯前端降级,后端无工具级取消 IPC):每个 running 工具一个独立 setTimeout,
// 到时若仍未收到 Completed/Approval,经 Tauri 事件 ai-tool-slow-toast 弹 warning toast(仅提示一次,
// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。
const TOOL_SLOW_MS = 30000
const _toolTimers = new Map<string, ReturnType<typeof setTimeout>>()
const _toolSlowNotified = new Set<string>()
/** 启动工具慢执行计时器(幂等:同 id 重复 Started 不重建;超时仅弹 toast,不改 status) */
export function startToolSlowTimer(callId: string, toolName: string): void {
if (_toolTimers.has(callId)) return
const timer = setTimeout(() => {
_toolTimers.delete(callId)
if (_toolSlowNotified.has(callId)) return
_toolSlowNotified.add(callId)
// 经 Tauri 事件总线广播(composable 无组件上下文),AiChat.vue listen 后弹本地 toast
void emit('ai-tool-slow-toast', { name: toolName })
}, TOOL_SLOW_MS)
_toolTimers.set(callId, timer)
}
/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */
export function clearToolSlowTimer(callId: string): void {
const timer = _toolTimers.get(callId)
if (timer) {
clearTimeout(timer)
_toolTimers.delete(callId)
}
}
/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */
export function clearAllToolSlowTimers(): void {
for (const timer of _toolTimers.values()) clearTimeout(timer)
_toolTimers.clear()
_toolSlowNotified.clear()
}
+10 -7
View File
@@ -7,10 +7,9 @@
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { notifyConversationChanged } from './useAiEvents'
import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText } from './aiShared'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
@@ -425,14 +424,16 @@ export async function switchConversation(id: string, force = false) {
/** ;+ id
* G3.4:收敛进 withConvOp( + + ), await unhandled rejection */
async function deleteConversation(id: string) {
// AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 +
// 错误气泡推错视图)。
// 删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + 错误气泡推错视图)
clearAllApprovalTimers()
const conv = state.conversations.find(c => c.id === id)
const prevIndex = conv ? state.conversations.indexOf(conv) : -1
const wasActive = state.activeConversationId === id
const prevActive = state.activeConversationId
const prevMessages = state.messages
// 删活跃会话前捕获相邻会话(删除后回落,避免空白无引导)
const neighbor = wasActive ? (state.conversations[prevIndex + 1] ?? state.conversations[prevIndex - 1]) : null
const neighborId = neighbor?.id ?? null
const ok = await withConvOp(
() => {
// 乐观:从列表移除 + 若删的是活跃会话则清空视图
@@ -459,18 +460,20 @@ async function deleteConversation(id: string) {
)
// 注意:op 返回 void,成功=undefined、失败=null(不能 `!ok`——undefined 也 falsy 会误判失败)
if (ok === null) return // 失败已回滚 + 推气泡,保持原视图
// F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长
// (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。
// 清该会话的 stream state 与 delta 去重记录,防 per-conv Map 无限增长
clearConvStreamState(id)
clearLastDelta(id)
if (wasActive) {
void appSettings.remove('df-ai-active-conv')
// G3.5:删除活跃会话 → load_more 游标复位(无 active 视图)
// 删除活跃会话 → load_more 游标复位(无 active 视图)
loadMoreCursor.hasMore = false
loadMoreCursor.earliestSeq = null
loadMoreCursor.convId = null
loadMoreCursor.loading = false
}
await loadConversations()
// 删的是活跃会话:回落相邻会话作为新活跃视图
if (neighborId) await switchConversation(neighborId)
notifyConversationChanged()
}
+50 -848
View File
@@ -1,633 +1,68 @@
//! AI 事件监听与分发 — startListener/stopListener/handleEvent 及其辅助函数
//! AI 事件监听与分发(分派器)— startListener / stopListener / handleEvent
//!
//! 模块级私有状态(不进 reactive):
//! - _unlistenAiEvent / _unlistenConvChanged: 已注册的 unlistener
//! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
//! - useAiStreamingEvents.handleStreamingEvent(流式)
//! - useAiToolEvents.handleToolEvent(工具)
//! - useAiLifecycleEvents.handleLifecycleEvent(生命周期)
//! 跨域共享的 helper/状态(textIdle/pendingMaxRounds/pendingDirAuths/pendingHelp、工具慢执行
//! 计时器、delta 去重 Map、flushCurrentText/friendlyError/notifyConversationChanged)已下沉到
//! aiShared 或 useAiPendingState(叶子)。依赖方向:分派器 → 各叶子 handler → aiShared / 叶子(无环)。
//!
//! handleEvent 结构(fe-arch P0-2 拆分后):
//! - 外围逻辑(convId 同步/路由/看门狗)留在 handleEvent
//! - 14 个 case 按域分派到 3 个 handler:
//! - handleStreamingEvent:AiTextDelta/AiAgentRound/AiHeartbeat/AiStreamRetry/AiMaxRoundsReached
//! - handleToolEvent:AiToolCallStarted/AiToolCallCompleted/AiToolAutoApproved/AiApprovalRequired/AiApprovalResult
//! - handleLifecycleEvent:AiCompleted/AiError
//! 各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。
//!
//! 耦合:
//! - handleEvent 调 useAiConversations.loadConversations、
//! useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
//! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除)
//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上)
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
//! handleEvent 保留外围逻辑(convId 同步/路由/看门狗)与 L2 状态机、跨端用户消息两个域内 handler。
//! useAiEvents() 返回 shape 保持不变(stores/ai.ts spread 形状不变 → 组件零改动)。
import { listen, emit } from '@tauri-apps/api/event'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, convStates, setConvState, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming } from './aiShared'
// 批4 双轨收口:getConvState 下沉到 aiShared,本模块 re-export 保持消费方
// (ChatInput.vue/MaxRoundsCard.vue 等)import 路径不变,组件零改动透明继承。
export { getConvState } from './aiShared'
import { resetStreamWatchdog, clearStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, AiToolCallInfo, MessageId } from '@/api/types'
import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents'
import type { AiChatEvent, MessageId } from '@/api/types'
// 跨域共享 helper re-export(原为 useAiEvents 导出,拆分后从 aiShared/叶子透传,消费方 import 路径不变)
export { getConvState } from './aiShared'
export { flushCurrentText, friendlyError, notifyConversationChanged }
export { setContextCallbacks } from './useAiLifecycleEvents'
export type { ContextCallbacks } from './useAiLifecycleEvents'
let _unlistenAiEvent: (() => void) | null = null
let _unlistenConvChanged: (() => void) | null = null
// AE-2025-06: onStreamTimeout 广播的审批计时器清理事件 unlistener
// onStreamTimeout 广播的审批计时器清理事件 unlistener
let _unlistenApprovalClear: (() => void) | null = null
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
// (两回调写同一 state.currentText → 流式文字双倍)
let _startPromise: Promise<void> | null = null
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
let _lastDelta = ''
// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。
// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态,
// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。
export const textIdle = ref(true)
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
function resetTextIdleTimer(): void {
if (_textIdleTimer) clearTimeout(_textIdleTimer)
textIdle.value = false // 有新 delta → 文本活跃
_textIdleTimer = setTimeout(() => {
textIdle.value = true // 300ms 无新 delta → 文本空闲
_textIdleTimer = null
}, 300)
}
function clearTextIdleTimer(): void {
if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
textIdle.value = true // 整轮结束/新轮开始 → 默认空闲
}
const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。
// 看门狗不重置的事件集合(审批等待/完成/错误/达 max/路径授权/求助由各自 case 内 clear)。
// 模块级 Set 复用,避免每事件(delta/token 高频)新建数组字面量做 includes。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
// 经 Tauri 事件 ai-tool-slow-toast 通知 AiChat.vue 弹 warning toast(仅提示一次,
// 不动 running 态——慢工具如 read_file 大文件/run_workflow 长任务不可误杀)。
// 真正取消需后端开 ai_cancel_tool IPC + 工具执行 select 改造,单独立项。
const TOOL_SLOW_MS = 30000
// callId → timer;记录所有已挂载的工具级计时器
const _toolTimers = new Map<string, ReturnType<typeof setTimeout>>()
// 已提示过的 callId(同一工具只弹一次 toast,即便仍 running 到本轮结束)
const _toolSlowNotified = new Set<string>()
/** 启动工具级慢执行计时器(超时仅弹 toast,不修改 status) */
function startToolSlowTimer(callId: string, toolName: string): void {
if (_toolTimers.has(callId)) return // 幂等:同 id 重复 Started 不重建
const timer = setTimeout(() => {
_toolTimers.delete(callId)
if (_toolSlowNotified.has(callId)) return
_toolSlowNotified.add(callId)
// 经 Tauri 事件总线广播(composable 无组件上下文,无法直接调 toast)。
// AiChat.vue(主窗口与分离窗口各挂一份)listen 后弹本地 toast。
// toolName 来自后端事件,经 i18n key 查不到翻译时回退原值——这里原样透传,
// 由消费方 i18n 插值展示。
void emit('ai-tool-slow-toast', { name: toolName })
}, TOOL_SLOW_MS)
_toolTimers.set(callId, timer)
}
/** 清除单个工具的慢执行计时器(收到 Completed/Approval 时调用) */
function clearToolSlowTimer(callId: string): void {
const timer = _toolTimers.get(callId)
if (timer) {
clearTimeout(timer)
_toolTimers.delete(callId)
}
}
/** 清除全部工具慢执行计时器(stopListener/整流超时收尾时调用) */
function clearAllToolSlowTimers(): void {
for (const timer of _toolTimers.values()) clearTimeout(timer)
_toolTimers.clear()
_toolSlowNotified.clear()
}
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true)。
//
// 不进 store.state(批次41 领地,且与 pendingApprovals 同性质是"待用户操作"信号,
// 独立事件源——AiMaxRoundsReached 与 AiApprovalRequired 不混)。用模块级 ref 导出,
// AiChat.vue 直接 import 读;case 内 push,true 表"有挂起询问"。
// 一次只追踪一个挂起询问(后端 AiSession 单例,达 max 后续轮次前用户必先决定),
// 故用单值 ref 而非数组,语义更精确。
//
// TD-260621-02 per-conv:原 ref(false)(boolean)无 convId 维度,F-09 多会话并发下 A 达 max
// 挂起时切到 B 会话,MaxRoundsCard 守卫(仅判 isViewingGenerating)会把 A 的操作卡错显于 B。
// 改 ref<string|null>(存挂起 convId,null=无挂起)。消费方(MaxRoundsCard)守卫改精确比对
// pendingMaxRounds === activeConversationId。set 按事件 conversation_id 赋值;
// clear(AiCompleted/AiError)仅当 pendingMaxRounds===该 convId 才清 null(避免清错会话的挂起)。
export const pendingMaxRounds = ref<string | null>(null)
// F-260619-03 Phase B: 路径授权弹窗挂起态(后端 AiDirAuthRequired 事件置,用户决策后清)。
//
// path_auth 审批链阶段1(解卡死):单 ref → 数组。根因——同轮多个文件工具落不同未授权目录时,
// 后端连发多条 AiDirAuthRequired,单 ref 后到覆盖先到,先到的弹窗丢失→授权无法到达→loop 卡死。
// 改数组 push 多条并存,各 case(AiApprovalResult/AiCompleted/AiError)按 id filter 移除。
// 开关 df-ai-multi-pending-auth 控制多挂起并存(true,默认)vs 单 ref 覆盖(false,兜底回退)。
// 模块级 ref(不进 store.state),AiChat.vue 的 DirAuthDialog 组件直接 import 读。
export interface PendingDirAuth {
id: string
tool: string
path: string
dir: string
conversationId?: string
}
export const pendingDirAuths = ref<PendingDirAuth[]>([])
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
//
// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。
// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。
// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号,
// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId,
// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。
// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。
export interface PendingHelp {
reason: string
context: string
options: string[]
conversationId: string | null
}
export const pendingHelp = ref<PendingHelp | null>(null)
// F-15 上下文管理生命周期事件回调(原 useAiContext 独立监听器,现合入统一分发器)。
// useAiContext.initContextListener 注入回调,useAiEvents.handleLifecycleEvent 统一分发。
// 设计:一个事件源(ai-chat-event)→ 一个监听器(useAiEvents)→ 一个分发器(handleEvent),
// 架构保证无双重处理风险(原 useAiContext 第二监听器已删除)。
export interface ContextCallbacks {
/** 压缩开始(AiCompressing):置 loading=true */
onCompressing?: () => void
/** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */
onManualCompressed?: (conversationId: string, summary: string) => void
/** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */
onAutoCompressed?: () => void
/** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */
onCleared?: (conversationId: string) => void
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
onError?: (conversationId: string, message: string) => void
}
let _contextCallbacks: ContextCallbacks = {}
/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */
export function setContextCallbacks(callbacks: ContextCallbacks): void {
_contextCallbacks = callbacks
}
// L2 统一状态机 convStates/getConvState/setConvState 已下沉到 aiShared.ts(批4 双轨收口破环:
// stores/ai.ts、useAiStream.ts、useAiConversations.ts 等读点 import getConvState 会与
// useAiEvents 现有依赖构成环,故下沉到本模块既有的破环共享层)。本模块从 aiShared re-import。
// 批4 收口后:convStates 是「会话是否生成中」的唯一真相源,旧 generatingConvs bool 轨已退役,
// 不再有双轨同步逻辑(原 handleConvStateEvent 内 idle/error delete、generating/stopping/compressed
// add 的兜底分支已删,enum 单写 setConvState 即收敛)。
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
}
/** 后端原始错误转用户友好提示 */
export function friendlyError(raw: string): string {
if (/404|not\s*found/i.test(raw)) return t('ai.errorNotFound')
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return t('ai.errorAuth')
if (/timeout|超时/i.test(raw)) return t('ai.errorTimeout')
if (/network|connection|ECONN|网络|连接/i.test(raw)) return t('ai.errorNetwork')
return raw
}
/** 把流式累积的 currentText 回填到最后一条 assistant 消息(AiAgentRound/AiCompleted/AiError 收尾共用) */
export function flushCurrentText() {
// 根因修复(空气泡):只 guard 空串会漏 whitespace —— LLM 工具调用前常推 `\n`/空格,
// 累积成 whitespace-only currentText 后写进占位 content,前端 !content 真值判断漏过
// → 渲染带边框空气泡。trim 兜底空白,空白流式文本不回填(无实质内容)。
if (!state.currentText || !state.currentText.trim()) {
state.currentText = ''
_lastDelta = '' // 同步复位 delta 跟踪(防下一轮首个 delta 误判重复)
return
}
// 从末尾向前找最后一个非 isError assistant 气泡写入。跳过 AiStreamRetry 错误气泡
// (isError),写入其前的占位 assistant,保留部分回复(UX-260619-06 MED-1)。
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.role !== 'assistant') break // 遇 user/tool 停,占位不在其后
if (!m.isError) {
m.content = state.currentText
break
}
}
// BUG-260624-01(消息重叠根治·诊断 workflow 确认):回填后立即自清 currentText。
// 原:清空依赖调用方(AiAgentRound/AiCompleted/AiError/AiHelpRequired 各跟一行 currentText='')。
// 竞态:任一新增 flush 调用点漏清,或事件乱序致渲染先于调用方清空,全局单例 currentText
// 残留 → MessageList 渲染侧 isLastAi(msg)&&currentText 把残留文本渲到新气泡 → 重叠堆叠。
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
// currentText='' 对已清空值幂等,无副作用。
state.currentText = ''
_lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
function isShowTokenUsage(): boolean {
return appSettings.get<boolean>('df-show-token-usage', false)
}
/** path_auth (appSettings key `df-ai-multi-pending-auth`)
* path_auth 审批链阶段1:解卡死核心,
* AiDirAuthRequired ref ,loop
* (true,):pendingDirAuths ,push , id
* (false,退):退 ref (push ),
* 回退路径:appSettings.set('df-ai-multi-pending-auth', false) ref , */
function isMultiPendingAuth(): boolean {
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
}
/**
* path_auth 3b()(appSettings key `df-ai-unified-approval`)
*
* (plan 3b):( pendingApprovals + kind )+ (path authorizeDir /
* risk approve) path pendingApprovals kind='path' ,
* ToolCard (once/always/deny);退 DirAuthDialog ()
*
* (true,):AiDirAuthRequired case path pendingApprovals (kind='path'),
* push pendingDirAuths;DirAuthDialog AiChat ToolCard kind
* once/always/deny , aiApi.authorizeDir
* (false,退):退1AiDirAuthRequired push pendingDirAuths,DirAuthDialog
* ,3a ( HashMap + kind )(, UI )
* 回退路径:appSettings.set('df-ai-unified-approval', false) DirAuthDialog ,
*/
function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true)
}
/** 向 pendingDirAuths 追加一条(开关控制:多挂起并存 vs 单 ref 覆盖) */
function pushPendingDirAuth(p: PendingDirAuth): void {
if (isMultiPendingAuth()) {
pendingDirAuths.value.push(p)
} else {
// 兜底单 ref 语义:后到覆盖先到(行为对齐旧版,数组只留最新一条)
pendingDirAuths.value = [p]
}
}
/** 按 id 从 pendingDirAuths 移除一条 */
function removePendingDirAuth(id: string): void {
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
}
// ─── 事件域 handler(从原 handleEvent switch 抽出,按域分组)────────────────────────────
//
// 拆分策略:策略 C(抽 case 体为函数)+ 按域分组(streaming/tool/lifecycle)。
// handleEvent 保留外围逻辑(convId 同步/路由/看门狗),switch 改为按 event.type 分派到
// 对应域 handler。各 handler 共享模块级 helper(state/aiShared/审批计时器/工具慢执行计时器)。
// 零行为变更:逻辑等价搬运,case 体一字未改。
//
// 域分组:
// - streaming 流式:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached
// - tool 工具:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult
// - lifecycle 生命周期:AiCompleted / AiError
//
// 返回值:true=已处理(命中域内 case);false=未命中(供 handleEvent 兜底/未来扩展)
// 例外:AiMaxRoundsReached 在 streaming 域(语义属流式中断信号),但 lifecycle 域收尾时
// 需置 pendingMaxRounds=false —— 该 ref 为模块级,跨域共享无碍。
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiTextDelta': {
// BUG-260624-01 / UX-260617-12 消息重叠防御:
// 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。
// 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
// startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
// 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
// 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
// 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
// ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
// 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
// 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
// df-ai-trace-delta 开关控制(默认开,生产可关)
if (appSettings.get('df-ai-trace-delta', true)) {
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
}
return true
}
_lastDelta = event.delta
state.currentText += event.delta
// 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。
resetTextIdleTimer()
return true
}
case 'AiAgentRound': {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
flushCurrentText()
state.currentText = ''
// 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红)
// 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。
// 按钮白由 textIdle 独立控制,无需翻转 streaming。
clearTextIdleTimer()
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
// AE-2025-07: 记录当前轮次供进度条展示。
// event.round>0 = run_agentic_loop 内 iteration+1(第几轮工具→LLM 循环);
// event.round==0 = try_continue_agent_loop 审批通过后"隔开新一轮"的占位事件,
// 此时实际轮次尚未推进(run_agentic_loop 入口 iteration=0),不覆盖,避免审批通过瞬间
// 进度条 2→0 闪烁;下一轮真正的 round>0 事件到达时再更新。
if (event.round > 0) state.agentRound = event.round
return true
}
case 'AiHeartbeat':
// 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
return true
case 'AiStreamRetry': {
// UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。
// 方案 A 根治 N+1 气泡:后端重试过程不再先 emit AiError(emit 权交 agentic 重试耗尽/Fatal 时
// 统一发最终错误),故首次 AiStreamRetry 到达时末条不是 isError,需创建新错误气泡;
// 后续 AiStreamRetry 到达时末条已是 isError,仅更新 content(单气泡聚合)。
// 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。
const lastMsg = state.messages[state.messages.length - 1]
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
if (lastMsg && lastMsg.isError) {
lastMsg.content = retryText
} else {
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: retryText,
isError: true,
timestamp: Date.now(),
} as AiMessage)
}
return true
}
case 'AiMaxRoundsReached': {
// F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
// 后端已 save 落库,这里仅翻 pendingMaxRounds 驱动 UI 卡片;看门狗已由
// NO_RESET_WATCHDOG 跳过 reset(达 max 不计整流超时)。
// BUG-260623-06: NO_RESET 只跳 reset 不 clear,达 max 前活跃事件(delta/tool)设的 timer 仍走,
// 130s 后触发 onStreamTimeout → generating=true(后端保持)+ tool completed → 误报
// "工具已执行完成,后续回复中断"(实测用户报"对话完成后等一会弹出")。
// 修:对齐 AiApprovalRequired/AiDirAuthRequired(等用户场景均 clear),达 max 也 clear watchdog。
// 用户点继续(ai_continue_loop→后端续跑→delta reset)/停止(ai_stop_loop→AiCompleted clear)时重计。
clearStreamWatchdog(event.conversation_id || undefined)
// TD-260621-02 per-conv:存挂起 convId(非 boolean),消费方按 activeConversationId 精确比对。
// 注:无 convId 时**不**设 null(=无挂起,达 max 操作卡丢失)——保留原值兜底。
// 用 activeConversationId 兜底:达 max 事件理论上必带 convId,无时默认属当前活跃会话,
// 优于清空挂起导致 MaxRoundsCard 守卫误判无挂起(用户达 max 操作卡凭空消失)。
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true
}
case 'AiDirAuthRequired': {
// F-260619-03 Phase B: 路径授权挂起(后端 generating 保持 true,等用户决策)。
// push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗;看门狗已由 NO_RESET_WATCHDOG 跳过 reset。
// F-260620 根治(错调 ai_approve 致卡死):path_auth 挂起**不**置工具卡 pending_approval——
// 工具卡 pending_approval 态有审批按钮调 ai_approve(为 RiskLevel 审批设计),path_auth 错调
// ai_approve 会被后端拦(path_auth 回滚+Err)→ 前端转圈卡死(authz-debug.log 铁证:同 tool_call_id
// ai_approve+ai_authorize_dir 双调)。path_auth 只走 DirAuthDialog(ai_authorize_dir),
// 工具卡保持 running(等授权结果,授权后 AiToolCallCompleted 转 completed)。
//
// path_auth 审批链阶段3b(统一审批模型):开关 df-ai-unified-approval 开时,path 挂起归一进
// pendingApprovals 带 kind='path',由 ToolCard 内联显 once/always/deny 三按钮(调 authorizeDir),
// 消除独立 DirAuthDialog 弹窗(单真相源:状态层合)。开关关时回退阶段1老链路(push pendingDirAuths,
// DirAuthDialog 渲染)。两路共存,兜底可随时回退。
clearStreamWatchdog(event.conversation_id || undefined)
clearToolSlowTimer(event.id)
// 开关开:归一进 pendingApprovals(kind='path'),驱动 ToolCard 内联审批。
// 同 id 幂等:GLM anthropic_compat id 不稳或重发时,已有同 id 不重复 push(防残留重复卡)。
if (isUnifiedApproval()) {
if (!state.pendingApprovals.some(p => p.id === event.id)) {
state.pendingApprovals.push({
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
kind: 'path',
dir: event.dir,
path: event.path,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
// path 类审批提示复用 aiChat.dirAuthHint(已存在 i18n,tool+path 文案);
// 不新增 key 避免 i18n 缺失 prod runtime 报错(memory: i18n-message-compile-blindspot)。
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
})
// 同步改对应工具卡的 tc(让 ToolCard 显 path 类审批按钮 once/always/deny + status-dot pending 色)。
// F-260620 注释"工具卡保持 running"是为防 path_auth 错调 ai_approve 卡死——统一模式后 path 类
// 调 authorizeDir 不再错调,故可安全进 pending_approval(与 risk 类 AiApprovalRequired 同款流转)。
// 开关关(兜底)时仍保持老语义(tc 不动,走 DirAuthDialog)。
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.kind = 'path'
tc.dir = event.dir
tc.path = event.path
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
}
// path 类挂起同样启动审批超时计时器(5min 不处理自动拒),与 risk 类一致。
// 传 kind='path' → 到点回调调 authorizeDir(id,'deny')(非 ai_approve,避免后端 kind==Risk 校验拒卡死)。
startApprovalTimer(event.id, event.tool, 'path')
}
// BUG-260624-02(授权弹窗卡死根治·诊断 workflow high 置信):path 类审批卡归一进 ToolCard
// 内联后,可能落入同名工具≥2 的折叠分组(ToolCardList group-hidden display:none)对用户
// 不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默 authorizeDir('deny')→ 误显
// "用户拒绝" → 用户全程未见审批入口即卡死。通知 ToolCardList 自动展开折叠组 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
// 开关关(兜底回退):push 到 pendingDirAuths 驱动 DirAuthDialog 弹窗。
// pushPendingDirAuth 据开关(df-ai-multi-pending-auth)决定多挂起并存(默认)还是单 ref 覆盖兜底。
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
pushPendingDirAuth({
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
})
}
return true
}
default:
return false
}
}
/** tool 域:工具卡片状态流转/会话级信任/审批 */
function handleToolEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'running',
}
// B-260616-21: 同 tool_call_id 重复 emit Started 时(GLM anthropic_compat id 不稳),
// 仅挂计时器不重复 push(对齐 startToolSlowTimer 守卫风格),防残留 running 空卡(0 行·N KB)。
// 详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md 方案①治标(后端治本待取证)。
if (!findToolCall(event.id)) {
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
}
}
// B-260616-12: 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
startToolSlowTimer(event.id, event.name)
return true
}
case 'AiToolCallCompleted': {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'completed'
tc.result = event.result
}
state.completedTools++ // 进度条计数器
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
clearApprovalTimer(event.id)
return true
}
case 'AiToolAutoApproved': {
// AE-2025-04 会话级信任:不写消息/不动 pending(Started/Completed 仍独立发),
// 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与
// ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
return true
}
case 'AiApprovalRequired': {
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计超时
// path_auth 审批链阶段3b:event.kind 缺省默认 'risk'(后端阶段3a wire 未加 kind 字段,
// 老后端不传即 risk 类)。event.kind 为 'path' 时理论上 path 挂起也走此事件(后端未来可统一),
// 当前阶段3a 后端 path 挂起仍走独立 AiDirAuthRequired 事件,故此处 kind 恒 'risk'。
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'pending_approval',
kind: event.kind ?? 'risk',
// BUG-260624-03(根因3):reason 必须写入 info,否则浮窗 ApprovalPopup.vue
// 的 tc.reason 永远空(后端 build_approval_reason 生成的风险说明丢失)。
// tc.reason 与 pendingApprovals[].reason 是两条独立赋值路径:tc 走 findToolCall,
// 浮窗走主窗口推送的 pendingApprovals 快照,后者此前漏写 reason。
reason: event.reason,
// A2-B10 conv-scoped:挂起项归属会话 id,cleanup 按此仅清本会话审批(不连累并发会话)。
// 事件必带 conversation_id(缺省兜底当前活跃会话——AiApprovalRequired 仅当 isCurrent 才到达此处)。
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.reason = event.reason
// AE-2025-03: write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览
if (event.diff) tc.diff = event.diff
}
// B-260616-12: 进入审批等待→取消该工具慢执行计时器(审批耗时由用户主导,非执行慢)
clearToolSlowTimer(event.id)
// AE-2025-06: 审批等待开始→启动审批超时计时器(5min 不处理自动拒绝)
// 传 kind='risk' → 到点回调调 aiApi.approve(id,false)(后端 ai_approve kind==Risk 链路)。
startApprovalTimer(event.id, event.name, 'risk')
// BUG-260624-02:同 AiDirAuthRequired,risk 类 pending 卡落折叠分组不可见时自动展开 + scroll。
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
case 'AiApprovalResult': {
if (!event.approved) {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'rejected'
// CR-260615-08(P1-1):走 i18n —— 原硬编码中文使 en locale 拒绝提示恒中文,
// 且废掉已存在的 aiTool.rejectedHint(en: 'User rejected this action')翻译
tc.result = t('ai.aiTool.rejectedHint')
}
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
// AE-2025-06: 用户已拒绝(状态离开 pending_approval)→清审批超时计时器
clearApprovalTimer(event.id)
} else {
// B-260616-12: 审批通过→工具重新进入执行态,重启慢执行计时器
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
// path_auth 审批链:path 类(once/always 通过)与 risk 类共用此分支。
// 补 clearApprovalTimer 防 timer 到期错调:授权通过但超时计时器未清,5min 后仍会触发拒绝回调
// (path 类调 authorizeDir('deny')/risk 类调 ai_approve(false))与已通过的执行态冲突。risk 类拒绝分支(:422)已清,
// 此处通过分支原漏清(回归:统一审批开关开后 path 类挂起也挂 timer,通过分支须对称清)。
clearApprovalTimer(event.id)
}
// F-260619-03 Phase B + path_auth 审批链阶段1:路径授权挂起的工具收到 ApprovalResult
// (once/always 通过 / deny 拒绝)→ 按 id 从 pendingDirAuths 移除该条(后端 ai_authorize_dir
// 已 try_continue 续 loop)。数组化后只清这一条,不影响同轮其他未决策的挂起项。
removePendingDirAuth(event.id)
return true
}
default:
return false
}
}
/** L2 : AiConvStateChanged, convStates(per-conv conv_state )
*
* 4 双轨收口后:enum(convStates),setConvState
* idle/error delete generatingConvs add
* (bool generatingConvs 退) delta/,
* 返回值:true=( case);false= */
* true=; convId () */
function handleConvStateEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiConvStateChanged') return false
const convId = event.conversation_id || state.activeConversationId
if (!convId) return true // 无 convId 无法路由,忽略(防误写全局态)
if (!convId) return true
setConvState(convId, event.conv_state)
return true
}
/** :F-260622-02 miniapp user
*
* AiUserMessage 语义:远程(miniapp) publish_event
* 广 ai_event_bus, user ( sendMessage ,
* store user ,, assistant )
*
* (): content user( push ,
* miniapp push ), pushMVP + content
* ; user content ( push)
*
* 返回值:true= AiUserMessage;false= */
/** :miniapp user ( AiUserMessage)
* 去重:末条已是同 content user ( push + ) */
function handleUserMessageEvent(event: AiChatEvent): boolean {
if (event.type !== 'AiUserMessage') return false
// 多会话并行隔离(2026-08-05 BUG-260805-02):AiUserMessage 必带 conversation_id(后端已 resolve)。
// - 当前会话(或 null)→ push user 气泡(微信端当前会话消息,桌面若同会话直接展示)
// - 非当前会话 → 不污染当前视图(桌面在并行看别的会话),仅刷新会话列表
// (侧栏显示该会话有新消息;用户切过去 switchConversation 从 DB 加载完整历史)。
// 旧实现无条件 push 到 state.messages → 非当前会话的 user 消息混入当前视图(历史内容被"污染")。
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations()
return true
}
// 去重:末条已是 user 且 content 相同则跳过(防桌面本地乐观 push + 事件回灌双气泡)。
const last = state.messages[state.messages.length - 1]
if (last && last.role === 'user' && last.content === event.message) {
return true
@@ -641,251 +76,27 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
return true
}
/**
* ( #6 DRY ):AiCompleted / AiError / AiHelpRequired
* ///currentText/agentRound/per-conv
* (localStorage + pendingMaxRounds + pendingDirAuths + convStates +
* A2-B10 conv-scoped 审批收尾:pendingApprovals + convId )
*
* ():
* - AiCompleted: 调用后追加 incomplete / token / drain
* - AiError: 调用后追加错误气泡 + queue( cleanup conv-scoped )
* - AiHelpRequired: 调用后翻 pendingHelp
*/
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
clearStreamWatchdog(convId || undefined)
clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText()
state.currentText = ''
// 清「重试中」过渡气泡(成功后残留根治,与 miniapp clearRetryBubbles 对齐)。
// AiStreamRetry 推的 retry 气泡(isError,content 含「正在重试(」)是过渡态,终态不清会
// 永久残留列表(flushCurrentText 只写非 error 气泡)。这里按 i18n 文案前缀清除。
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) {
state.messages.splice(i, 1)
}
}
setStreaming(false, { convId: convId || null, reason })
state.agentRound = 0 // AE-2025-07: agentic 结束/中断/求助,复位轮次(隐藏进度条)
// 批4 双轨收口:收尾收敛 convStates(AiCompleted/AiHelpRequired 删项→idle,AiError 置 error)。
// 后端 CONV_STATE_ENABLED=on 时 AiConvStateChanged 已先到此处幂等 no-op;
// off 或老后端无 AiConvStateChanged 时此处保证 convStates 不陈旧。
if (reason === 'AiError') {
if (convId) convStates.set(convId, 'error') // 保留 error 项供停止按钮显「重试」态
} else {
convStates.delete(convId)
}
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(避免清其他会话的挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === convId) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(终止后旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(终止只清本会话弹窗,不连累并发会话)。
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
// A2-B10 修正:终止语义=清本 conv(该会话已结束),保其他 conv;无归属旧项保守保留。
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// A2-B10 conv-scoped 审批收尾:仅清目标 conv 的待审批项 + 对称清其审批超时计时器。
// 对齐上方 pendingDirAuths per-conv filter 范例。conversationId 缺失的旧项(无法归属)
// 保守保留——误清其他 conv 正在审批的卡 = 回归;仅清 conversationId===convId 的项。
// 与旧全局清(AiError/AiHelpRequired 分支 state.pendingApprovals = [] + clearAllApprovalTimers)
// 的差异:此处按 convId 收敛,其他会话的审批卡/计时器不受影响。
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
// 对称清本 conv 被移除项的审批超时计时器(id 集合),防到点回调改已终止会话 state。
for (const p of removedApprovals) clearApprovalTimer(p.id)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
if (convId) {
localStorage.removeItem(`df-ai-gen-${convId}`)
localStorage.removeItem(`df-ai-text-${convId}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError) */
function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiCompleted': {
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
// 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
// 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
if (event.incomplete) {
state.messages.push({
id: `incomplete-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.responseIncomplete'),
timestamp: Date.now(),
})
}
// 对话透明化 L1:直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
if (event.pinned_goals && state.activeConversationId) {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
if (conv) conv.pinned_goals = event.pinned_goals
}
void loadConversations()
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传,前端 in=cache_miss 分计费展示
if (isShowTokenUsage()) {
state.lastTokenUsage = {
prompt: event.prompt_tokens,
completion: event.completion_tokens,
total: event.total_tokens,
cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
}
if (state.convTokenTotal) {
state.convTokenTotal.prompt += event.prompt_tokens
state.convTokenTotal.completion += event.completion_tokens
state.convTokenTotal.total += event.total_tokens
} else {
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
}
// 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
// 同时写消息级 cache/reasoning 字段(详情面板 + 分项显示用)。
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) {
m.tokenUsage = {
prompt: event.prompt_tokens,
completion: event.completion_tokens,
cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
}
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
m.reasoning_tokens = event.reasoning_tokens
break
}
}
}
notifyConversationChanged()
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
emit('ai-drain-queue', { conversationId: event.conversation_id })
return true
}
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
// A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
// cleanupTerminatedConversation 按 convId 收敛(不再全局 clearAllApprovalTimers /
// state.pendingApprovals = [],避免连累并发会话正在审批的卡)。
// 只清除出错会话的队列项,不误伤其他会话的排队消息
if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else {
state.queue = []
}
// UX-03: 错误消息携带 error_type(供错误气泡差异化显隐「去设置」按钮)。
// AiMessage 类型未含 errorType 字段(不在本批白名单),用对象字面量 + cast 扩展;
// 消费方(AiChat.vue canOpenSettings)经同 cast 读取,类型闭环在两端,不污染 types.ts。
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: friendlyError(event.error),
isError: true,
errorType: event.error_type,
timestamp: Date.now(),
} as AiMessage)
// F-15 上下文管理:AiError 也用于压缩 IPC 失败场景。通知上下文回调复位 loading +
// 弹错误 toast(若处于压缩中)。与上方流式错误处理不冲突——上下文回调内部判 isCompressing 守卫。
_contextCallbacks.onError?.(event.conversation_id || '', event.error)
return true
}
case 'AiHelpRequired': {
// L1 求助协议(§2.3,2026-06-21):后端断路器瘝断已 guard.reset(generating=false,loop 终止)。
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
// A2-B10 conv-scoped 审批收尾:本 conv 的待审批项 + 审批超时计时器已由
// cleanupTerminatedConversation 按 convId 收敛(不再全局 state.pendingApprovals = [],
// 避免连累并发会话正在审批的卡)。
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
pendingHelp.value = {
reason: event.reason,
context: event.context,
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
notifyConversationChanged()
return true
}
// F-15 上下文管理生命周期事件(原 useAiContext 独立监听器,现合入统一分发器)
case 'AiCompressing':
_contextCallbacks.onCompressing?.()
return true
case 'AiManualCompressed': {
// 手动 IPC 压缩(用户点压缩按钮):复位 loading + 弹 toast + 刷新视图回填摘要。
_contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '')
return true
}
case 'AiAutoCompressed': {
// loop 自动压缩对桌面静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就为 false),
// 不调 onManualCompressed(防每次发送误弹 toast + 误 switchConversation 打断 LLM 流)。
_contextCallbacks.onAutoCompressed?.()
return true
}
case 'AiContextCleared':
_contextCallbacks.onCleared?.(event.conversation_id || '')
return true
default:
return false
}
}
/** 后端事件分发:按 conversation_id 路由,流式累积文本,工具状态流转,看门狗联动 */
export function handleEvent(event: AiChatEvent) {
const convId = event.conversation_id
// 注:F-260620 path_auth 审批链根治卡死后,原 [AI-DIRAUTH-DIAG] 高频诊断 console.log 已删除
// (使命完成;热路径污染 devtools)。后续如需事件流诊断用 df-ai-event-trace 开关控制。
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
// 同步写 appSettings(SQLite):刷新页面后 loadConversations 据此恢复上次会话
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)并持久化
if (convId && !state.activeConversationId) {
state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId)
}
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号(F-09 多会话并发下须追踪所有会话),
// 在 isCurrent 守卫**之前**处理,避免切走会话时 conv_state 不更新致停止按钮/MaxRoundsCard 错态
// 该事件非流式活跃信号,不重置看门狗、不进活跃事件兜底写(由 handleConvStateEvent 写 enum)。
// L2 状态机:AiConvStateChanged 是 per-conv 状态信号,在 isCurrent 守卫之前处理
// (切走会话时也须更新 conv_state,防停止按钮/MaxRoundsCard 错态)
if (handleConvStateEvent(event)) return
// F-260622-02 跨端用户消息同步:在 isCurrent 守卫**之前**处理——AiUserMessage 是补 user 气泡
// 信号(非流式活跃事件),不应触发 watchdog reset / 活跃事件兜底写(后续 AiAgentRound/
// AiTextDelta 等活跃事件会正常触发)。也不受"非当前会话"过滤——理论上此事件必带 conversation_id
// 且属当前会话(后端 route_send_message 在放行时广播),即使因切走会话到达也仅补 user 气泡,
// 不影响其他态(handleUserMessageEvent 去重幂等)。
// 跨端用户消息同步:在 isCurrent 守卫之前处理(补 user 气泡信号,非流式活跃事件)
if (handleUserMessageEvent(event)) return
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止事件:收敛会话状态(删 Map 项回不在生成)。非当前会话的 error 态无
// 消费方(操作卡/输入区均读 activeConversationId),回退 null 与停止按钮回退行为等价。
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '')
void loadConversations()
// 后台会话终止也触发队列续发/清队(原 isCurrent 守卫拦截导致非当前会话排队消息永不
// drain,切回后队列卡死)。完成触发续发;错误终止对齐当前会话分支清该会话队列。
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id })
} else if (event.type === 'AiError') {
@@ -896,29 +107,25 @@ export function handleEvent(event: AiChatEvent) {
}
}
} else if (event.type === 'AiTextDelta' && convId && switchingConvs.has(convId)) {
// 切换中缓冲:目标会话正被 switchConversation 拉取,该会话的 delta 累积到 per-conv
// 流式态(而非丢弃),切换完成后由渲染层续显,避免切换窗口丢可见内容。不做气泡/工具
// 副作用(消息视图尚未切到目标会话,避免污染当前视图)。
// 切换中缓冲:目标会话正被 switchConversation 拉取, delta 累积到 per-conv 流式态,
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
setConvStreaming(convId, true)
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
}
return
}
// 批4 双轨收口:活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中。
// 完成后端 CONV_STATE_ENABLED=off 或老后端不发 AiConvStateChanged{generating} 时,此处作为
// enum 兜底写入口(替代原 generatingConvs.add + 清残留 error 双操作,合并为 setConvState('generating') 一步)。
// CONV_STATE_ENABLED=on 时后端 AiConvStateChanged{generating} 已先到(handleConvStateEvent 在
// isCurrent 守卫前处理),此处 setConvState 同值 set 幂等覆盖,无副作用。完成/错误/求助事件除外
// (三者后端均已终止 loop,在各自 case 内由 convStates.delete/set('error') 收敛)。
// 无目标会话(事件缺 convId 且当前无活跃会话):流式 delta 无处累积,跳过写防 null 键无意义写入
if (!convId && !state.activeConversationId && event.type === 'AiTextDelta') return
// 活跃事件(delta/工具/新轮/审批结果)到达即标记该会话生成中(enum 兜底写入口;
// 后端 AiConvStateChanged{generating} 已先到时同值 set 幂等覆盖,无副作用)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
setConvState(convId, 'generating')
}
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear
// TD-260621-01 per-conv:传 convId 走 per-conv Map(各会话独立计时,互不顶替/连累)。
// 流式看门狗:活跃事件重置;审批等待/完成/错误在 case 内 clear(per-conv,各会话独立计时)
if (!NO_RESET_WATCHDOG.has(event.type)) {
resetStreamWatchdog(convId || undefined)
}
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作(保留原 switch 无 default 的语义)
// 按域分派(streaming → tool → lifecycle);未命中任一域 → 无操作
if (handleStreamingEvent(event)) return
if (handleToolEvent(event)) return
void handleLifecycleEvent(event)
@@ -926,11 +133,9 @@ export function handleEvent(event: AiChatEvent) {
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
export async function startListener() {
// 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册)
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
return
}
// 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍
if (_startPromise) return _startPromise
_startPromise = (async () => {
try {
@@ -950,9 +155,7 @@ export async function startListener() {
}
}
/** (, listener + )
* 清看门狗:卸载时若仍在生成,_streamWatchdog ,130s onStreamTimeout
* state(messages.push/ streaming) */
/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗/计时器,防回调改已卸载组件 state) */
export function stopListener() {
_unlistenAiEvent?.()
_unlistenConvChanged?.()
@@ -960,13 +163,12 @@ export function stopListener() {
_unlistenAiEvent = null
_unlistenConvChanged = null
_unlistenApprovalClear = null
// UX-260617-26: stop 清零去重 promise——极速 mount/unmount/mount(HMR)时,首 mount 的
// _startPromise 可能仍在 pending(未 await 完即 unmount),再 mount 若命中并发去重分支会
// 返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册。
// stop 清零去重 promise:极速 mount/unmount/mount(HMR)时,首 mount 的 _startPromise 可能仍 pending,
// 再 mount 若命中并发去重分支会返回这个已代表「旧注册」的过期 promise,新 listener 实际未注册
_startPromise = null
clearAllStreamWatchdogs() // TD-260621-01: 卸载清全部 per-conv + legacy fallback timer,防回调改已卸载组件 state
clearAllToolSlowTimers() // B-260616-12: 卸载时释放所有工具慢执行计时器,防回调改 state 触发已卸载组件
clearAllApprovalTimers() // AE-2025-06: 卸载时释放所有审批超时计时器,防回调 push 消息触发已卸载组件
clearAllStreamWatchdogs()
clearAllToolSlowTimers()
clearAllApprovalTimers()
}
export function useAiEvents() {
+222
View File
@@ -0,0 +1,222 @@
//! 生命周期事件域 — handleLifecycleEvent + cleanupTerminatedConversation
//!
//! 处理:AiCompleted / AiError / AiHelpRequired + 上下文生命周期事件(AiCompressing/
//! AiManualCompressed/AiAutoCompressed/AiContextCleared)。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream / streamingGuard /
//! useAiConversations,不导入任何同级子 composable。
import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
// 上下文管理生命周期事件回调(useAiContext.initContextListener 注入,本域统一分发)
export interface ContextCallbacks {
/** 压缩开始(AiCompressing):置 loading=true */
onCompressing?: () => void
/** 手动压缩成功(AiManualCompressed):置 loading=false + toast + 回填摘要 */
onManualCompressed?: (conversationId: string, summary: string) => void
/** 自动压缩成功(AiAutoCompressed):静默置 loading=false(不弹 toast) */
onAutoCompressed?: () => void
/** 上下文已清空(AiContextCleared):toast + 刷新消息列表 */
onCleared?: (conversationId: string) => void
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
onError?: (conversationId: string, message: string) => void
}
let _contextCallbacks: ContextCallbacks = {}
/** 注入上下文生命周期回调(useAiContext.initContextListener 调用) */
export function setContextCallbacks(callbacks: ContextCallbacks): void {
_contextCallbacks = callbacks
}
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
function isShowTokenUsage(): boolean {
return appSettings.get<boolean>('df-show-token-usage', false)
}
/**
* (AiCompleted/AiError/AiHelpRequired ):///
*
* ():
* - AiCompleted: 调用后追加 incomplete / token / drain
* - AiError: 调用后追加错误气泡 + queue
* - AiHelpRequired: 调用后翻 pendingHelp
*/
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
clearStreamWatchdog(convId || undefined)
clearAllToolSlowTimers() // 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText()
state.currentText = ''
// 清「重试中」过渡气泡(成功后残留根治;AiStreamRetry 推的 retry 气泡是过渡态,终态不清会永久残留)
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.isError && typeof m.content === 'string' && m.content.includes('正在重试(')) {
state.messages.splice(i, 1)
}
}
setStreaming(false, { convId: convId || null, reason })
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
if (reason === 'AiError') {
if (convId) convStates.set(convId, 'error')
} else {
convStates.delete(convId)
}
// per-conv 挂起仅清本 conv(避免清错其他会话的挂起)
if (pendingMaxRounds.value && pendingMaxRounds.value === convId) {
pendingMaxRounds.value = null
}
if (pendingHelp.value && pendingHelp.value.conversationId === convId) {
pendingHelp.value = null
}
// 路径授权挂起仅清本 conv(终止只清本会话弹窗,不连累并发会话;无归属旧项保守保留)
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId !== convId)
// 审批收尾仅清目标 conv 的待审批项 + 对称清其超时计时器(其他会话的审批卡/计时器不受影响;
// conversationId 缺失的旧项无法归属,保守保留防误清正在审批的卡)
const removedApprovals = state.pendingApprovals.filter(p => p.conversationId === convId)
state.pendingApprovals = state.pendingApprovals.filter(p => !p.conversationId || p.conversationId !== convId)
for (const p of removedApprovals) clearApprovalTimer(p.id)
// 清分离窗口生成态快照(per-conv key,清本会话;兼容旧单 key)
if (convId) {
localStorage.removeItem(`df-ai-gen-${convId}`)
localStorage.removeItem(`df-ai-text-${convId}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
}
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。 */
export function handleLifecycleEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiCompleted': {
console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming)
cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted')
console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming)
// 流中途失败保文:后端 emit AiCompleted(incomplete=true),前端追加系统提示气泡
if (event.incomplete) {
state.messages.push({
id: `incomplete-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.responseIncomplete'),
timestamp: Date.now(),
})
}
// 直接从事件更新 pinned_goals,不等 loadConversations 异步刷新
if (event.pinned_goals && state.activeConversationId) {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
if (conv) conv.pinned_goals = event.pinned_goals
}
void loadConversations()
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) {
state.lastTokenUsage = {
prompt: event.prompt_tokens,
completion: event.completion_tokens,
total: event.total_tokens,
cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
}
if (state.convTokenTotal) {
state.convTokenTotal.prompt += event.prompt_tokens
state.convTokenTotal.completion += event.completion_tokens
state.convTokenTotal.total += event.total_tokens
} else {
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
}
// 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示
for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) {
m.tokenUsage = {
prompt: event.prompt_tokens,
completion: event.completion_tokens,
cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
}
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
m.reasoning_tokens = event.reasoning_tokens
break
}
}
}
notifyConversationChanged()
// 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 循环依赖)
emit('ai-drain-queue', { conversationId: event.conversation_id })
return true
}
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
// 只清除出错会话的队列项,不误伤其他会话的排队消息
if (event.conversation_id) {
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
} else {
state.queue = []
}
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: friendlyError(event.error),
isError: true,
errorType: event.error_type,
timestamp: Date.now(),
} as AiMessage)
// AiError 也用于压缩 IPC 失败:通知上下文回调复位 loading + 弹错误 toast(内部判 isCompressing 守卫)
_contextCallbacks.onError?.(event.conversation_id || '', event.error)
return true
}
case 'AiHelpRequired': {
// 求助协议:后端断路器熔断已 guard.reset(loop 终止)。按终止态收尾但不创建错误气泡,
// 改为翻 pendingHelp 驱动求助卡(reason + options 按钮供用户选)。
cleanupTerminatedConversation(event.conversation_id || '', 'AiHelpRequired')
// 用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)
pendingHelp.value = {
reason: event.reason,
context: event.context,
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
notifyConversationChanged()
return true
}
case 'AiCompressing':
_contextCallbacks.onCompressing?.()
return true
case 'AiManualCompressed': {
// 手动 IPC 压缩:复位 loading + 弹 toast + 刷新视图回填摘要
_contextCallbacks.onManualCompressed?.(event.conversation_id || '', event.summary || '')
return true
}
case 'AiAutoCompressed': {
// loop 自动压缩对桌面静默——仅复位 loading(不弹 toast、不打断 LLM 流)
_contextCallbacks.onAutoCompressed?.()
return true
}
case 'AiContextCleared':
_contextCallbacks.onCleared?.(event.conversation_id || '')
return true
default:
return false
}
}
+77
View File
@@ -0,0 +1,77 @@
//! AI 待决/流式信号共享态(叶子)—— textIdle / pendingMaxRounds / pendingDirAuths / pendingHelp
//! 及各自的定时器与 push/remove。
//!
//! 叶子定位:仅被导入,不导入任何子 composable(依赖仅 vue + appSettings),故无环风险。
//! useAiEvents 拆分后,streaming/tool/lifecycle 三域与组件(ChatInput/MaxRoundsCard/
//! DirAuthDialog/HelpRequiredCard)统一从这里取这些跨域信号。
import { ref } from 'vue'
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettings = useAppSettingsStore()
// 文本空闲信号:streaming=true 且 300ms 无新 delta 时=true,按钮据此显「发送」而非「停止」。
// 不动 streaming(streaming 是渲染态,控制 MessageList 流式 block 显隐,不能中途翻转)。
export const textIdle = ref(true)
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
/** 每次新 delta 重置 300ms 空闲计时(textIdle=false 表示文本活跃) */
export function resetTextIdleTimer(): void {
if (_textIdleTimer) clearTimeout(_textIdleTimer)
textIdle.value = false
_textIdleTimer = setTimeout(() => {
textIdle.value = true
_textIdleTimer = null
}, 300)
}
/** 整轮结束/新轮开始清空闲计时并回落默认空闲 */
export function clearTextIdleTimer(): void {
if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
textIdle.value = true
}
// 达 max_iterations 暂停态(后端仍 generating=true):存挂起 convId,null=无挂起。
// 消费方(MaxRoundsCard)按 pendingMaxRounds === activeConversationId 精确比对,多会话不串台。
export const pendingMaxRounds = ref<string | null>(null)
// 路径授权挂起(后端 AiDirAuthRequired 置,用户决策后清)。数组 push 多条并存:
// 同轮多个文件工具落不同未授权目录时,后端连发多条,单 ref 会被后到覆盖致弹窗丢失。
export interface PendingDirAuth {
id: string
tool: string
path: string
dir: string
conversationId?: string
}
export const pendingDirAuths = ref<PendingDirAuth[]>([])
// 多挂起并存开关(appSettings key `df-ai-multi-pending-auth`):开=数组 push 多条;
// 关=单 ref 覆盖(后到覆盖先到,行为对齐旧版)。
function isMultiPendingAuth(): boolean {
return appSettings.get<boolean>('df-ai-multi-pending-auth', true)
}
/** 向 pendingDirAuths 追加一条(开关控制多挂起并存 vs 单 ref 覆盖) */
export function pushPendingDirAuth(p: PendingDirAuth): void {
if (isMultiPendingAuth()) {
pendingDirAuths.value.push(p)
} else {
pendingDirAuths.value = [p]
}
}
/** 按 id 从 pendingDirAuths 移除一条 */
export function removePendingDirAuth(id: string): void {
pendingDirAuths.value = pendingDirAuths.value.filter(p => p.id !== id)
}
// 求助协议挂起(agent 断路器熔断时后端 AiHelpRequired 置):显 reason + options 驱动求助卡。
// 存挂起 convId,消费方按 activeConversationId 精确比对(多会话并发不串台)。
export interface PendingHelp {
reason: string
context: string
options: string[]
conversationId: string | null
}
export const pendingHelp = ref<PendingHelp | null>(null)
+13 -12
View File
@@ -23,8 +23,8 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener, flushCurrentText } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming } from './aiShared'
import { startListener } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared'
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -33,18 +33,18 @@ const QUEUE_LIMIT = 10
const QUEUE_TIMEOUT_MS = 30_000
/**
* F-01 阶段6: 用户指定模型 override(,)
* override(,)
*
* null=();= model_id
* (agentic)生效:doSend/sendMessage/regenerate/editMessage ,
* //( IPC override)
* //
*
* (agentic.rs):override provider model_configs ,
* override
*
* ():AiChat.vue ref, ref ,
* AiChat props/inject session.model_override,
* (modelOverride.value = null) UI
* ref():AiChat , ref , props/inject
* session.model_override;(
* TopBar 兜底:仅当 override /),
*/
const modelOverride = ref<string | null>(null)
@@ -278,9 +278,8 @@ export function drainQueue(convId?: string | null) {
const next = state.queue.splice(idx, 1)[0]!
void (async () => {
try {
// 用目标会话 id 发送(而非当前活跃会话)——若已切到别的会话,原实现会把 A 的队首消息
// 误发到当前活跃会话 B。队列项无 mentionSpans 字段,传 undefined。
await sendMessage(next.text, next.skill, false, next.parts, undefined, targetId)
// 用目标会话 id 发送(而非当前活跃会话),避免切走后把 A 的队首消息误发到 B;spans 一并透传
await sendMessage(next.text, next.skill, false, next.parts, next.spans, targetId)
} catch (e) {
// 失败时彻底收尾:带目标会话 id 精准清理 per-conv 看门狗 + 会话状态
setConvStreaming(targetId, false)
@@ -352,6 +351,7 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
skill: skill || undefined,
enqueuedAt: Date.now(),
parts: parts && parts.length > 0 ? parts : undefined,
spans: spans && spans.length > 0 ? spans : undefined,
conversationId: targetConvId,
})
return
@@ -388,7 +388,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>,
// 此处无需前置 false(前置 false 会触发消息列表 watch 清流式块再重建,产生瞬态抖动)
clearStreamWatchdog()
try {
await sendMessage(first.text, first.skill, true, first.parts, undefined, first.conversationId)
await sendMessage(first.text, first.skill, true, first.parts, first.spans, first.conversationId)
return true
} catch (e) {
// force_send 也失败:回填该会话队首保消息不丢,复位 streaming 让 UI 可继续操作
@@ -398,6 +398,7 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>,
skill: first.skill,
enqueuedAt: first.enqueuedAt,
parts: first.parts,
spans: first.spans,
conversationId: first.conversationId,
})
setStreaming(false, { convId: targetConv ?? state.activeConversationId, reason: 'tryForceSend-fail' })
@@ -481,7 +482,7 @@ async function sendQueuedNow(index: number) {
// backendGenerating(后端真值仍 true)→ spliced 被入队而非立即发,与"立即发送"语义不符。
// force_send 原子复位 generating=false 再发,无竞态窗口;stop_flag 让旧 loop 在下个检测点退出。
// 显式传 spliced 所属会话 id(当前活跃视图内操作,即当前会话)。
await sendMessage(spliced.text, spliced.skill, true, spliced.parts, undefined, spliced.conversationId)
await sendMessage(spliced.text, spliced.skill, true, spliced.parts, spliced.spans, spliced.conversationId)
}
/** streaming
+143
View File
@@ -0,0 +1,143 @@
//! 流式事件域 — handleStreamingEvent
//!
//! 处理:AiTextDelta / AiAgentRound / AiHeartbeat / AiStreamRetry / AiMaxRoundsReached / AiDirAuthRequired。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
//! _lastDelta 去重 Map 与 flushCurrentText 因跨域共用已下沉 aiShared,这里仅经 accessor 读写。
import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText } from './aiShared'
import { resetTextIdleTimer, clearTextIdleTimer, pendingMaxRounds, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
// path_auth 统一审批模型开关(appSettings key `df-ai-unified-approval`):开=path 挂起归一进
// pendingApprovals(kind='path',ToolCard 内联审批);关=走 pendingDirAuths 独立弹窗(DirAuthDialog)。
function isUnifiedApproval(): boolean {
return appSettings.get<boolean>('df-ai-unified-approval', true)
}
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停。返回 true=已处理。 */
export function handleStreamingEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiTextDelta': {
// 连续完全相同 delta(且非空)丢弃第二次(双窗口 listener 或 provider 异常重复,留诊断证据)
const convRef = event.conversation_id || state.activeConversationId
const lastDelta = getLastDelta(convRef)
if (event.delta && lastDelta === event.delta && event.delta.length > 1) {
if (appSettings.get('df-ai-trace-delta', true)) {
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
}
return true
}
setLastDelta(convRef, event.delta)
state.currentText += event.delta
// 重置文本空闲定时器:每次 delta 重置 300ms,无新 delta 到 300ms → textIdle=true → 按钮白
resetTextIdleTimer()
return true
}
case 'AiAgentRound': {
// 新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息,清空闲定时器。
// 不设 streaming=false(streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas)。
flushCurrentText()
state.currentText = ''
clearTextIdleTimer()
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
role: 'assistant',
content: '',
timestamp: Date.now(),
})
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
if (event.round > 0) state.agentRound = event.round
return true
}
case 'AiHeartbeat':
// 心跳仅维持看门狗(已在上方 reset),显式 case 防 switch 穿透
return true
case 'AiStreamRetry': {
// 重试中——首条重试推新错误气泡,后续仅更新其 content(单气泡聚合,根治 N+1 气泡)
const lastMsg = state.messages[state.messages.length - 1]
const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
if (lastMsg && lastMsg.isError) {
lastMsg.content = retryText
} else {
state.messages.push({
id: `err-${nextMsgId()}` as MessageId,
role: 'assistant',
content: retryText,
isError: true,
timestamp: Date.now(),
} as AiMessage)
}
return true
}
case 'AiMaxRoundsReached': {
// 达 max 暂停态(后端仍 generating=true):看门狗不计整流超时(clear),挂起 convId 驱动操作卡。
// 无 convId 时保留原值兜底(达 max 事件理论上必带 convId,缺失默认当前活跃会话)。
clearStreamWatchdog(event.conversation_id || undefined)
pendingMaxRounds.value = event.conversation_id || state.activeConversationId || pendingMaxRounds.value
return true
}
case 'AiDirAuthRequired': {
// 路径授权挂起(后端 generating 保持 true,等用户决策):看门狗跳过 reset,由本 case clear。
clearStreamWatchdog(event.conversation_id || undefined)
clearToolSlowTimer(event.id)
// 统一审批模型开:path 挂起归一进 pendingApprovals(kind='path'),ToolCard 内联审批。
// 同 id 幂等(provider id 不稳或重发时不重复 push 防残留卡)。
if (isUnifiedApproval()) {
if (!state.pendingApprovals.some(p => p.id === event.id)) {
state.pendingApprovals.push({
id: event.id,
name: event.tool,
args: { path: event.path },
status: 'pending_approval',
kind: 'path',
dir: event.dir,
path: event.path,
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
reason: t('aiChat.dirAuthHint', { tool: event.tool, path: event.path }),
})
// 同步改对应工具卡的 tc,让 ToolCard 显 path 类审批按钮(once/always/deny)
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.kind = 'path'
tc.dir = event.dir
tc.path = event.path
tc.reason = t('aiChat.dirAuthHint', { tool: event.tool, path: event.path })
}
// path 类挂起同样启动审批超时计时器(到点回调调 authorizeDir(id,'deny'))
startApprovalTimer(event.id, event.tool, 'path')
}
// 通知 ToolCardList 自动展开折叠组 + scroll(挂起卡落折叠分组 display:none 用户不可见)
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
// 统一审批模型关(兜底):走 DirAuthDialog 独立弹窗(push pendingDirAuths,开关控多挂起并存)
if (!pendingDirAuths.value.some(p => p.id === event.id)) {
pushPendingDirAuth({
id: event.id,
tool: event.tool,
path: event.path,
dir: event.dir,
conversationId: event.conversation_id,
})
}
return true
}
default:
return false
}
}
+108
View File
@@ -0,0 +1,108 @@
//! 工具事件域 — handleToolEvent
//!
//! 处理:AiToolCallStarted / AiToolCallCompleted / AiToolAutoApproved / AiApprovalRequired / AiApprovalResult。
//! 依赖方向:本模块 → aiShared / useAiPendingState(叶子)/ useAiStream,不导入任何同级子 composable。
import { emit } from '@tauri-apps/api/event'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { findToolCall, clearApprovalTimer, startApprovalTimer, startToolSlowTimer, clearToolSlowTimer } from './aiShared'
import { removePendingDirAuth } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream'
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
/** tool 域:工具卡片状态流转/会话级信任/审批。返回 true=已处理。 */
export function handleToolEvent(event: AiChatEvent): boolean {
switch (event.type) {
case 'AiToolCallStarted': {
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'running',
}
// 同 id 重复 Started 不重复 push(provider id 不稳,防残留 running 空卡)
if (!findToolCall(event.id)) {
const lastMsg = state.messages[state.messages.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.toolCalls = lastMsg.toolCalls || []
lastMsg.toolCalls.push(info)
}
}
// 工具开始执行即挂慢执行计时器(超时仅 toast,不动 running 态)
startToolSlowTimer(event.id, event.name)
return true
}
case 'AiToolCallCompleted': {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'completed'
tc.result = event.result
}
state.completedTools++ // 进度条计数器
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
// 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
clearApprovalTimer(event.id)
return true
}
case 'AiToolAutoApproved': {
// 会话级信任:经事件总线桥接弹本地 toast(composable 无组件上下文)
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
return true
}
case 'AiApprovalRequired': {
clearStreamWatchdog(event.conversation_id || undefined) // 等用户审批,不计整流超时
const info: AiToolCallInfo = {
id: event.id,
name: event.name,
args: event.args,
status: 'pending_approval',
kind: event.kind ?? 'risk',
// reason 必须写入 info,否则浮窗 ApprovalPopup 的 tc.reason 永远空(审批说明丢失)
reason: event.reason,
conversationId: event.conversation_id ?? state.activeConversationId ?? undefined,
}
state.pendingApprovals.push(info)
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'pending_approval'
tc.reason = event.reason
// write_file 审批注入行级 diff(旧文件 vs 新内容),前端审批卡预览
if (event.diff) tc.diff = event.diff
}
// 进入审批等待 → 取消该工具慢执行计时器(审批耗时由用户主导,非执行慢),再启动审批超时计时器
clearToolSlowTimer(event.id)
startApprovalTimer(event.id, event.name, 'risk')
// 通知 ToolCardList 展开折叠组 + scroll(pending 卡落折叠分组不可见时)
void emit('ai-pending-arrived', { toolCallId: event.id })
return true
}
case 'AiApprovalResult': {
if (!event.approved) {
const tc = findToolCall(event.id)
if (tc) {
tc.status = 'rejected'
tc.result = t('ai.aiTool.rejectedHint')
}
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
clearToolSlowTimer(event.id)
clearApprovalTimer(event.id)
} else {
// 审批通过 → 工具重回执行态,重启慢执行计时器;对称清审批超时计时器防到点误拒
startToolSlowTimer(event.id, findToolCall(event.id)?.name || '')
clearApprovalTimer(event.id)
}
// 路径授权挂起的工具收到 ApprovalResult → 按 id 移除该条(数组化后只清这一条)
removePendingDirAuth(event.id)
return true
}
default:
return false
}
}
+7
View File
@@ -207,6 +207,12 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
}
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
function resetStreamCache(): void {
_blockCache.clear()
}
return {
mdReady,
renderMd,
@@ -216,5 +222,6 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
renderStreamingBlocks,
clearStreamingState,
cancelPendingRaf,
resetStreamCache,
}
}
+17
View File
@@ -246,5 +246,22 @@ export default {
ciNFailed: '{n} failed',
ciNPending: '{n} pending',
ciNPassed: '{n} passed',
// ── MessageList token detail popover (per-fee breakdown: input/cache hit/output/reasoning) ──
tokenDetailTitle: 'Token usage details',
estimated: 'estimated',
tokenInLabel: 'Input (cache miss, full price)',
tokenCacheLabel: 'Cache hit (low price)',
tokenOutLabel: 'Output',
tokenReasonLabel: 'Reasoning',
tokenCacheRateLabel: 'Cache hit rate',
tokenModelLabel: 'Model',
tokenUsageNoteLabel: 'Usage note',
tokenUsageNote: 'prompt_tokens missing, shown as estimated',
// ── TopBar goal/project context labels ──
goalsCount: 'Goals ({n})',
none: 'None',
projectLabel: "{'@'}Projects",
},
}
+20
View File
@@ -3,5 +3,25 @@ export default {
title: 'Dependency Graph',
empty: 'No modules yet, please add a module first',
tabTitle: '🔗 Dependencies',
addDep: '+ Add dependency',
checkCycles: '🔍 Check cycles',
exportImage: '📷 Export',
fitContent: 'Fit content',
addDepTitle: 'Add project dependency',
sourceLabel: 'Source module (depends on)',
targetLabel: 'Target module (depended on)',
depTypeLabel: 'Dependency type',
typeLibrary: 'Library',
typeApi: 'API call',
typeMq: 'Message queue',
typeShared: 'Shared resource',
typeCustom: 'Custom',
confirm: 'Confirm',
cycleWarning: 'Detected {n} cycle nodes (highlighted in red)',
noCycle: 'No cyclic dependencies detected',
cycleError: 'Cycle detection failed',
deleteConfirm: 'Delete the dependency "{from} → {to}"? This cannot be undone.',
deleteSuccess: 'Dependency deleted',
deleteError: 'Failed to delete dependency',
},
}
+17
View File
@@ -247,5 +247,22 @@ export default {
ciNFailed: '{n} 项失败',
ciNPending: '{n} 项进行中',
ciNPassed: '{n} 项通过',
// ── MessageList token 详情面板(分计费:输入/缓存命中/输出/思考) ──
tokenDetailTitle: 'Token 用量详情',
estimated: '估算',
tokenInLabel: '输入(未命中,全价)',
tokenCacheLabel: '缓存命中(低价)',
tokenOutLabel: '输出',
tokenReasonLabel: '思考(reasoning)',
tokenCacheRateLabel: '缓存命中率',
tokenModelLabel: '模型',
tokenUsageNoteLabel: '用量说明',
tokenUsageNote: 'prompt_tokens 缺失,按估算展示',
// ── TopBar 目标/项目上下文标签 ──
goalsCount: '目标({n})',
none: '无',
projectLabel: "{'@'}项目",
},
}
+20
View File
@@ -3,5 +3,25 @@ export default {
title: '依赖关系图',
empty: '暂无工程,请先添加工程',
tabTitle: '🔗 依赖图',
addDep: '+ 依赖',
checkCycles: '🔍 环检测',
exportImage: '📷 导出',
fitContent: '适应内容',
addDepTitle: '添加工程依赖',
sourceLabel: '源工程(依赖方)',
targetLabel: '目标工程(被依赖)',
depTypeLabel: '依赖类型',
typeLibrary: '类库',
typeApi: 'API 调用',
typeMq: '消息队列',
typeShared: '共享资源',
typeCustom: '自定义',
confirm: '确认',
cycleWarning: '检测到 {n} 个环节点(已红框高亮)',
noCycle: '未检测到环形依赖',
cycleError: '环检测失败',
deleteConfirm: '确定删除「{from} → {to}」的依赖吗?此操作不可撤销。',
deleteSuccess: '依赖已删除',
deleteError: '删除依赖失败',
},
}
+9 -3
View File
@@ -20,7 +20,7 @@
//! - state 为模块级单例,全应用共享同一引用
import { computed, reactive, watch } from 'vue'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, SkillInfo } from '@/api/types'
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, ContentPart, MentionSpan, SkillInfo } from '@/api/types'
/**
* messages ()
@@ -52,7 +52,7 @@ import { useAiEvents } from '@/composables/ai/useAiEvents'
import { useAiStream } from '@/composables/ai/useAiStream'
// 批4 双轨收口:getConvState 从 aiShared 取(下沉破环,避免 stores/ai↔useAiEvents 反向环)。
// F-09 per-conv streaming/currentText(DEC-07a 父④):convStreamStates Map + 辅助函数亦从 aiShared 取。
import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText, __bindMessages } from '@/composables/ai/aiShared'
import { getConvState, getConvStreamState, setConvStreaming, setConvCurrentText, __bindMessages, __bindState } from '@/composables/ai/aiShared'
import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
import { useAiApproval } from '@/composables/ai/useAiApproval'
import { useAiConversations } from '@/composables/ai/useAiConversations'
@@ -101,7 +101,7 @@ const _stateBase: {
detached: boolean
docked: boolean
skills: SkillInfo[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; conversationId?: string | null }[]
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; spans?: MentionSpan[]; conversationId?: string | null }[]
agentRound: number
/// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
completedTools: number
@@ -203,6 +203,12 @@ try {
// aiShared 模块初始化中(ES module 循环),延迟到微任务里重试
Promise.resolve().then(() => __bindMessages(() => state.messages))
}
// 注入 state getter 到 aiShared 共享层(flushCurrentText 惰性读 state,破 aiShared ↔ 本模块循环)
try {
__bindState(() => state)
} catch (e) {
Promise.resolve().then(() => __bindState(() => state))
}
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
+7 -37
View File
@@ -161,6 +161,7 @@ import {
} from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import { useToast } from '@/composables/useToast'
import { usePersistedRef } from '@/composables/usePersistedRef'
import { stripMd } from '@/utils/markdown'
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
import type { KnowledgeDetailPayload, KnowledgeRecord } from '@/api/types'
@@ -170,38 +171,10 @@ const store = useKnowledgeStore()
// /:/( Settings.vue/Projects.vue useToast )
const { toast, showToast } = useToast()
// topTab/activeKind/searchQuery localStorage()
// :(id ):/退
const LS_KEY = 'kn.view'
function loadView(): { topTab: 'library' | 'inbox'; activeKind: string; searchQuery: string } {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return { topTab: 'library', activeKind: 'all', searchQuery: '' }
const v = JSON.parse(raw)
return {
topTab: v.topTab === 'inbox' ? 'inbox' : 'library',
activeKind: typeof v.activeKind === 'string' ? v.activeKind : 'all',
searchQuery: typeof v.searchQuery === 'string' ? v.searchQuery : '',
}
} catch {
return { topTab: 'library', activeKind: 'all', searchQuery: '' }
}
}
function saveView() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
topTab: topTab.value,
activeKind: activeKind.value,
searchQuery: searchQuery.value,
}))
} catch {
/* localStorage 不可用(隐私模式/配额)静默降级,视图态非关键 */
}
}
const persisted = loadView()
// Tab: library() | inbox()
const topTab = ref<'library' | 'inbox'>(persisted.topTab)
// topTab/activeKind/searchQuery localStorage()
// :(id )usePersistedRef ,
// /退 default, Tasks/Ideas
const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library')
const categories = [
{ key: 'all', icon: '📦' },
@@ -211,8 +184,8 @@ const categories = [
function catLabel(key: string): string {
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
}
const activeKind = ref(persisted.activeKind)
const searchQuery = ref(persisted.searchQuery)
const activeKind = usePersistedRef('knowledge.activeKind', 'all')
const searchQuery = usePersistedRef('knowledge.searchQuery', '')
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearchInput() {
@@ -246,9 +219,6 @@ function switchTopTab(tab: 'library' | 'inbox') {
}
}
// (topTab/activeKind/searchQuery): localStorage
watch([topTab, activeKind, searchQuery], saveView)
//
function clearError() {
store.clearError()