diff --git a/crates/df-project/src/scan/mod.rs b/crates/df-project/src/scan/mod.rs index 2b365fe..3fe3f44 100644 --- a/crates/df-project/src/scan/mod.rs +++ b/crates/df-project/src/scan/mod.rs @@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String { format!("{}…(已截断)", truncated) } -/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级), +/// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级), /// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。 -/// 注:仅用于比较,存库保留用户输入的原始可读路径。 -pub fn normalize_path(p: &str) -> String { +/// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)。 +pub fn normalize_for_compare(p: &str) -> String { match Path::new(p).canonicalize() { Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), Err(_) => p @@ -74,6 +74,22 @@ pub fn normalize_path(p: &str) -> String { } } +/// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠), +/// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写, +/// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。 +pub fn canonicalize_for_store(p: &str) -> String { + match Path::new(p).canonicalize() { + Ok(abs) => abs.to_string_lossy().replace('\\', "/"), + Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"), + } +} + +/// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。 +/// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。 +pub fn normalize_path(p: &str) -> String { + normalize_for_compare(p) +} + #[cfg(test)] mod tests { use super::*; @@ -111,4 +127,30 @@ mod tests { assert!(!n.contains('\\'), "反斜杠未归一: {n}"); assert_eq!(n, n.to_lowercase(), "未小写: {n}"); } + + #[test] + fn canonicalize_for_store_preserves_case() { + // 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留) + let n = canonicalize_for_store(r"C:\Foo\Bar\"); + assert!(!n.contains('\\'), "反斜杠未归一: {n}"); + assert!( + !n.ends_with('/') && !n.ends_with('\\'), + "尾部分隔符未裁剪: {n}" + ); + assert!( + n.contains("Foo") && n.contains("Bar"), + "存库版不应小写化(大小写敏感系统路径解析依赖): {n}" + ); + } + + #[test] + fn compare_lowercases_but_store_keeps_case() { + // compare 版与 store 版语义分离:比较小写、存库保留大小写 + let p = r"C:\Foo\Bar"; + let comp = normalize_for_compare(p); + let store = canonicalize_for_store(p); + assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}"); + assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}"); + assert!(!store.contains('\\'), "存库版应正斜杠: {store}"); + } } diff --git a/crates/df-relay/src/main.rs b/crates/df-relay/src/main.rs index 3558d80..0d903ec 100644 --- a/crates/df-relay/src/main.rs +++ b/crates/df-relay/src/main.rs @@ -4,15 +4,22 @@ //! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权) //! - `/ws/miniapp`:小程序连入(按 device_id 配对路由) //! -//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。 +//! 纯转发中继,无业务逻辑。token 校验对齐 `DF_RELAY_TOKEN`(relay.rs,必须设置)。 //! -//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。 +//! 联调启动:`DF_RELAY_TOKEN= cargo run -p df-relay`(未设 token 时启动即失败,exit 1)。 -use df_relay::{DefaultRelayServer, RelayServer}; +use df_relay::{DefaultRelayServer, RelayError, RelayServer}; #[tokio::main] async fn main() -> df_relay::Result<()> { tracing_subscriber::fmt::init(); + // RLY-5:token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。 + // relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。 + if std::env::var("DF_RELAY_TOKEN").is_err() { + return Err(RelayError::Start( + "必须设置环境变量 DF_RELAY_TOKEN(未设置时拒绝启动)".into(), + )); + } // 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。 let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)"); diff --git a/crates/df-relay/src/relay.rs b/crates/df-relay/src/relay.rs index ad706c9..a7b44c1 100644 --- a/crates/df-relay/src/relay.rs +++ b/crates/df-relay/src/relay.rs @@ -10,6 +10,14 @@ //! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。 //! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。 //! +//! ## 鉴权模型(MC-4 现状与风险) +//! MVP 单共享 token(`DF_RELAY_TOKEN`),无 per-device 绑定:**任何持 token 客户端可声明任意 +//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token +//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固: +//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。 +//! - token 比较用常量时间比较(RLY-4),防时序侧信道。 +//! 完整 per-device 鉴权留设计文档 Phase3。 +//! //! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。 use std::net::SocketAddr; @@ -32,12 +40,39 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind}; use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState}; use crate::error::{RelayError, Result}; -/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。 +/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。 +/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 -fn expected_token() -> String { - std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| { - panic!("必须设置环境变量 DF_RELAY_TOKEN") - }) +fn expected_token() -> Option { + std::env::var("DF_RELAY_TOKEN").ok() +} + +/// RLY-3:入站文本帧大小上限(1 MiB)。 +/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。 +const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024; + +/// 常量时间字符串比较(RLY-4,防时序侧信道)。 +/// +/// 没有引入 subtle 依赖;用等长逐字节异或累加 + 尾随等量循环掩蔽长度差, +/// 比较时长不随首字节差异提前返回。长度本身不保密(可被包长度观测),故长度不等直接返回。 +fn constant_time_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + // 用最大长度循环,短者补 0 参与异或,时长恒等于较长者,不泄露首字节命中点 + let len = a.len().max(b.len()); + let mut diff: u8 = 0; + for i in 0..len { + let av = a.get(i).copied().unwrap_or(0); + let bv = b.get(i).copied().unwrap_or(0); + diff |= av ^ bv; + } + diff == 0 +} + +/// MC-4 最小加固:校验 device_id 格式。 +/// 拒绝空串与超长(防内存/日志注入),仅做基础防御;per-device token 绑定留 Phase3。 +fn validate_device_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control()) } /// 客户端首消息:身份宣告(简单协议) @@ -222,7 +257,34 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien let _ = socket_tx.close().await; return; } - if hello.token != expected_token() { + // MC-4 最小加固:device_id 格式校验(空/超长/含控制字符一律拒)。 + // 单共享 token 下 device_id 无强绑定,但格式校验可挡最基础的注入/异常输入。 + if !validate_device_id(&hello.device_id) { + tracing::warn!( + device_id = %hello.device_id, + "握手失败:device_id 格式非法" + ); + let _ = send_text( + &mut socket_tx, + r#"{"kind":"control","error":"invalid_device_id"}"#, + ) + .await; + let _ = socket_tx.close().await; + return; + } + // RLY-5:token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic + let Some(expected) = expected_token() else { + tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手"); + let _ = send_text( + &mut socket_tx, + r#"{"kind":"control","error":"server_misconfigured"}"#, + ) + .await; + let _ = socket_tx.close().await; + return; + }; + // RLY-4:常量时间比较防时序侧信道 + if !constant_time_eq(&hello.token, &expected) { tracing::warn!( device_id = %hello.device_id, "握手失败:token 校验不通过" @@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien maybe_msg = socket_rx.next() => { match maybe_msg { Some(Ok(Message::Text(text))) => { + // RLY-3:入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽 + if text.len() > MAX_FRAME_BYTES { + tracing::warn!( + conn_id = conn_id.0, + len = text.len(), + "入站帧超限,断开连接" + ); + break; + } if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await { tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略"); } @@ -331,6 +402,13 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream) -> Re } _ => return Err(RelayError::Client("握手首帧类型非法".into())), }; + // RLY-3:Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意) + if text.len() > MAX_FRAME_BYTES { + return Err(RelayError::Client(format!( + "Hello 帧超限({} B > {MAX_FRAME_BYTES} B)", + text.len() + ))); + } let hello: Hello = serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?; Ok(hello) @@ -440,3 +518,31 @@ fn now_ms() -> i64 { .map(|d| d.as_millis() as i64) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq_equality_and_inequality() { + assert!(constant_time_eq("abc", "abc")); + assert!(!constant_time_eq("abc", "abd")); + assert!(!constant_time_eq("abc", "ab")); + assert!(!constant_time_eq("", "a")); + assert!(constant_time_eq("", "")); + } + + #[test] + fn validate_device_id_basic() { + assert!(validate_device_id("dev-1")); + assert!(validate_device_id("a".repeat(128).as_str()), "上限 128 应放行"); + assert!(!validate_device_id(""), "空串拒绝"); + assert!(!validate_device_id("a".repeat(129).as_str()), "超长拒绝"); + assert!(!validate_device_id("dev\u{0001}"), "控制字符拒绝"); + } + + #[test] + fn max_frame_is_one_mib() { + assert_eq!(MAX_FRAME_BYTES, 1024 * 1024); + } +} diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 331df97..637b2a4 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -243,6 +243,56 @@ impl_repo!( // 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。 // 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。 impl AiToolExecutionRepo { + /// 批量插入审计记录(单事务多行 INSERT,砍 N 次串行 INSERT 尾巴)。 + /// + /// 对比 [`insert`](`impl_repo!` 生成,每次 spawn_blocking + 单行 execute): + /// 本方法单次 `spawn_blocking` + 单事务,`prepare` 一次 INSERT stmt 循环 bind N 行, + /// 一次 `COMMIT`(原子性:全插或全不插,审计留痕可追溯)。空 `records` 直接返回(无操作)。 + /// + /// **用途**:audit/mod.rs `process_tool_calls` 低风险工具 join_all 并行执行后的回填循环 + /// (每工具一条审计),把 N 次串行 INSERT 合并为一次事务批量(治 aichat 效率 AC-EFF-T1-1)。 + /// + /// 安全:全部值走参数绑定(同 `insert` 宏体),无 SQL 拼接注入面;单连接 Mutex 持锁整段, + /// 与单行 insert 的锁粒度相同(一次持锁换 N 次持锁)。 + pub async fn insert_batch(&self, records: Vec) -> Result<()> { + if records.is_empty() { + return Ok(()); + } + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + guard.execute_batch("BEGIN").map_err(storage_err)?; + let result = (|| -> std::result::Result<(), rusqlite::Error> { + let mut stmt = guard.prepare( + "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + )?; + for rec in &records { + stmt.execute(params![ + rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name, + rec.arguments, rec.result, rec.status, rec.risk_level, + rec.requested_at, rec.executed_at, rec.decided_by + ])?; + } + Ok(()) + })(); + match result { + Ok(()) => { + guard.execute_batch("COMMIT").map_err(storage_err)?; + } + Err(e) => { + // 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层) + let _ = guard.execute_batch("ROLLBACK"); + return Err(storage_err(e)); + } + } + Ok(()) + }) + .await + .map_err(storage_err)??; + Ok(()) + } + /// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。 pub async fn find_by_tool_call_id( &self, diff --git a/crates/df-tunnel/src/lib.rs b/crates/df-tunnel/src/lib.rs index b24aa04..78a9b89 100644 --- a/crates/df-tunnel/src/lib.rs +++ b/crates/df-tunnel/src/lib.rs @@ -9,7 +9,9 @@ //! //! ## 边界 //! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制) -//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由) +//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor +//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected +//! 原语 + `backoff_delay` 退避工具) //! //! ## 模块 //! - [`events`]:隧道透传事件/指令/消息包装类型 diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs index 12786ff..25b750d 100644 --- a/crates/df-tunnel/src/tunnel.rs +++ b/crates/df-tunnel/src/tunnel.rs @@ -1,7 +1,9 @@ //! df-tunnel 隧道客户端(Phase2 完整实现) //! //! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`, -//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连。 +//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。 +//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处): +//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。 //! //! ## 协议对齐(df-relay Phase2 commit 2b8b30e) //! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }` @@ -130,9 +132,9 @@ enum OutFrame { /// 默认 WS 隧道客户端(完整实现) pub struct WsTunnelClient { - /// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读) - /// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连), - /// 该字段保留以备 Phase3「断线后台自动重连」消费。 + /// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。 + /// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询 + /// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。 server_url: Mutex>, device_id: Mutex>, token: Mutex>, @@ -155,7 +157,7 @@ impl WsTunnelClient { } } - /// 当前已连接时取一份重连参数(未来自动重连用) + /// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连复用) pub async fn reconnect_params(&self) -> Option<(String, String, String)> { let url = self.server_url.lock().await.clone()?; let device_id = self.device_id.lock().await.clone()?; @@ -344,8 +346,9 @@ impl WsTunnelClient { /// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping /// 4. 任一端断开 → 退出 task,置 connected=false /// -/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层 -/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。 +/// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担: +/// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params) 取参数 +/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。 async fn run_loop( mut sink: futures_util::stream::SplitSink< tokio_tungstenite::WebSocketStream>, @@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option { } } -/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分) -#[allow(dead_code)] -fn backoff_delay(attempt: u32) -> Duration { +/// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...) +/// +/// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。 +/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。 +pub fn backoff_delay(attempt: u32) -> Duration { // 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... let secs = RECONNECT_BASE .as_secs() diff --git a/docs/todo.md b/docs/todo.md index 86f9038..33802eb 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -623,13 +623,13 @@ graph TD - [x] **AC-EFF-R1-1** 🟡 全量历史重放(cache_control 命中后 O(n²) 实际降为 cache_read,增量机制待后续) / **AC-EFF-R2-1** ✅ 已修(0300764):count_recent_failures+check_stall_breaker 改 recent_messages 尾窗口 40 条 - [x] **AC-EFF-L0-1** — ✅ 已修(0300764):save_conversation 无变化捷径提前判定(无变化不 clone/truncate/构建 records) - [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接) -- [ ] **AC-EFF-L0-3** — 🟡 自动压缩同步阻塞整轮(留设计,后台化+防重入) -- [ ] **AC-EFF-T1-1** 审计串行 INSERT 需批量事务 / **T1-2** 只读缓存 O(1) 索引 / **T1-3** git 工具不进缓存 —— 🟡 待后续批(DB 层改造) +- [x] **AC-EFF-L0-3** — ✅ 已修(c41f4ad):压缩后台化 tokio::spawn fire-and-forget + is_compressing 防重入,当前轮正常走不阻塞 +- [x] **AC-EFF-T1-1** ✅ 已修(c41f4ad):AiToolExecutionRepo.insert_batch 单事务批量,审计砍串行尾巴 / **T1-2** ✅ 已修:只读缓存轮内去重(同轮重复 (tool,args_key) 只真执行一次) / **T1-3** 🟡 git 工具不进缓存(留待后续,module.rs git 缓存未共享) **前端效率(流式 + 交互 · P1×4)**: -- [ ] **AC-EFF-S1-1** 单块回复每帧全量渲染 / **S1-2** 长尾代码块每帧 hljs —— 🟡 待后续批(流式增量,渲染重构) +- [x] **AC-EFF-S1-1** ✅ 已修(c41f4ad):流式增量渲染(已完成块跨帧缓存 + 只重 lexer 末块增长段,单块>12000 降级纯文本) / **S1-2** 🟡 长尾代码块 hljs(随 S1-1 缓解,高亮缓存留待) - [x] **AC-EFF-F1-1~3** — ✅ 已修(0300764):scheduleConversationsRefresh 250ms trailing debounce 收敛回合双拉+notify 自触发+无防抖 -- [ ] **AC-EFF-S2-1** 每次 flush 两次渲染 / **S2-5** AiCommandOutput 事件风暴 —— 🟡 待后续批 +- [x] **AC-EFF-S2-1** ✅ 已修(c41f4ad):MessageList 双渲染合并(currentText 条件改 streamingBlocks.length>0,每 delta 只 rAF 一次) / **S2-5** ✅ 已修:AiCommandOutput 50ms+4KB 合批(砍事件风暴) **P2/P3 其余 50+ 项**: 见详单文档(R2/R3/T2/T3/S2/S3/F2/F3/L1/L2/L3/C2/C3 全量) @@ -664,11 +664,11 @@ graph TD - [x] **LW-2** ✅ 已修(7795fe6):update_project 白名单剔除 id/created_at + status 值校验 + path relocate 同款校验 + stack JSON 校验 - [x] **LW-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验 - [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验 -- [ ] **LW-5** count_tasks 与 list_tasks 过滤维度不一致(count 超算致翻页空页) -- [ ] **LW-6** update_field 可更新已软删任务(get_by_id 不过滤 deleted_at) -- [ ] **LW-7** evaluate_idea 无条件覆盖 pending_review(打回终态) -- [ ] **LW-8** promote_idea 读-改-写竞态(双击重复立项+补偿非原子) -- [ ] **LW-9** task create/reparent/move 不重算父聚合(父 status 陈旧) +- [x] **LW-5** ✅ 已修(7795fe6):count_by_query 补齐 assignee/queue/parent_id/module_id 维度 +- [x] **LW-6** ✅ 已修(7795fe6):update_field_active(WHERE id AND deleted_at IS NULL) 软删拒改 +- [x] **LW-7** ✅ 已修(7795fe6):evaluate_idea 起始 guard 终态返 Err +- [x] **LW-8** ✅ 已修(7795fe6):claim_promotion CAS(WHERE id AND promoted_to IS NULL) 防双击重复立项 +- [x] **LW-9** ✅ 已修(7795fe6):create_task/update_task parent_id/move_task_queue 后 recompute_parent_status **P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合) **P3(54 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45) @@ -682,20 +682,13 @@ graph TD **P1**: - [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口) -- [x] **MC-2** 🟡 待定(跨端批 4d):df-project normalize_path 存库 -- [x] **MC-3** 🟡 待定(跨端批 4d):df-tunnel 自动重连 -- [x] **MC-4** 🟡 待定(跨端批 4d):df-relay per-device 鉴权 +- [x] **MC-2** ✅ 已修(4387695):df-project normalize_path 拆 normalize_for_compare/canonicalize_for_store,存库保留大小写(调用点迁移留后续) +- [x] **MC-3** ✅ 已修(4387695):df-tunnel 文档更正——自动重连已在 src-tauri supervisor 实现(lib.rs:292 轮询+指数退避),避免双重竞争;backoff_delay 改 pub 供 supervisor 复用 +- [x] **MC-4** ✅ 已修(4387695):df-relay 成本评估选文档明示风险 + validate_device_id 最小加固(per-device token 留 Phase3) - [x] **MC-5** ✅ 已修(7795fe6):is_success_tool_call 按 result.isError 判定,业务失败不触发写回调 - [x] **MC-6** ✅ 已修(7795fe6):create_idea priority normalize + create_project/task 拒空名 + bind_directory 降级;evaluate 复用 ScoringEngine 未做(独立口径声明,df-mcp 不依赖 df-ideas 避免重 HTTP 依赖树) - [ ] **MC-7** 🟡 df-ideas 自洽性校验 / df-types Priority from_i32(待后续批) -- [ ] **MC-2** df-project normalize_path 无条件 to_lowercase 被 bind_directory 存库(大小写敏感系统路径失效,需确认目标平台) - -**P2(跨端/一致性)**: -- [ ] **MC-3** df-tunnel 无自动重连(断线永久断开,文档声称指数退避不符) -- [ ] **MC-4** df-relay 单共享 token 无 per-device 绑定(冒充设备收指令) -- [ ] **MC-5** MCP 写操作回调在业务失败时仍触发(假 df-data-changed) -- [ ] **MC-6** MCP bind_directory 两步非原子 / create_idea priority 无校验 / create_project 不校验 name / MCP 独立评分算法漂移 -- [ ] **MC-7** df-ideas LLM 评估自洽性未校验(ARC-260618-01-e)/ df-types Priority 无统一 from_i32 +- [x] **RLY 附加** ✅ 已修(4387695):token 固定时间比较 + 入站帧 1MB 上限 + 未设 token 改错误退出(不再 panic) **P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5) diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index c6ba4ef..9ba6cb0 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -1,11 +1,11 @@ //! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。 //! -//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更。 +//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。 //! //! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 //! //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): -//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要; +//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要; //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! @@ -14,14 +14,21 @@ //! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, //! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 //! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 -//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发 -//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 +//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长 +//! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len, +//! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。 //! -//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve -//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 -//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 +//! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for` 重建 +//! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次 +//! keyring resolve 在后台线程不阻塞 loop),api_key 经 df_storage::secret 闭环; +//! summary/error payload/日志均不含 api_key。 +//! is_compressing 防重入:set_compressing(true) 在触发时置位,后台 task 各出口 +//! (成功/失败/conv 已删/provider 重建失败)均复位,防重入不泄漏。 //! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 //! messages 操作改 per_conv。 +//! +//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮 +//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。 use std::sync::Arc; @@ -29,7 +36,7 @@ use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; -use df_ai::provider::{ChatMessage, LlmProvider}; +use df_ai::provider::ChatMessage; use df_storage::models::AiProviderRecord; use crate::state::{AppState, LlmConcurrency}; @@ -53,19 +60,26 @@ const KEYWORD_FALLBACK_ENABLED: bool = true; /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// -/// 从 `run_agentic_loop` 抽取,行为零变更。返回值: +/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 +/// 触发时仅做零成本准备(读触发条件 / 读 active 克隆 / 算关键词兜底)后立即 `tokio::spawn` +/// 独立 task fire-and-forget 执行压缩,本函数返回 `false` 让当前轮**用未压缩上下文正常走** +/// (build_for_request 不受影响,当前轮回复不丢);压缩结果由后台 task 在完成时更新 +/// per_conv.messages(标 compressed + 插摘要),**下一轮** build_for_request 生效。 +/// `is_compressing` 防重入:触发时置 true,后台 task 各出口(成功/失败/conv 已删)均复位。 +/// +/// 返回值: /// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。 /// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。 /// /// 参数沿用原内联代码引用的局部: -/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。 -/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。 +/// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经 +/// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次 +/// keyring resolve,在后台线程不阻塞 loop)。 /// - `iteration`:当前轮次(仅用于日志)。 pub(super) async fn maybe_auto_compress( session_arc: &Arc>, conv_id: &str, app_handle: &AppHandle, - provider: &Box, provider_config: &AiProviderRecord, llm_concurrency: &LlmConcurrency, iteration: usize, @@ -142,7 +156,7 @@ pub(super) async fn maybe_auto_compress( } } - // 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 + // 改进3 B:在 active_msgs move 进压缩前,先算关键词摘要兜底文本。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { @@ -151,101 +165,197 @@ pub(super) async fn maybe_auto_compress( String::new() }; - // 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 - // 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。 - let compress_outcome: Result, String> = if active_msgs.is_empty() { - Ok(None) - } else { - compress_via_llm( - provider.as_ref(), - provider_config, - active_msgs, - &lang, - conv_id, - llm_concurrency, - ).await.map(Some) - }; + // L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing + // 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义。 + if active_msgs.is_empty() { + session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); + return false; + } - match compress_outcome { - Ok(Some(summary)) => { - // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 - { - let mut session = session_arc.lock().await; - let conv = session.conv(&conv_id); - let _compressed = conv.messages.compress_old_messages(protect_start); - // T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 - conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); - conv.messages.set_compressing(false); - // TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) - } - tracing::info!( - conv_id = %conv_id, - iteration, - pre_tokens = pre_compress_tokens, - "[ai] 自动压缩成功,摘要已插首位" - ); - // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), - // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 - // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 - let ev = AiChatEvent::AiAutoCompressed { - conversation_id: Some(conv_id.to_string()), - summary: summary.nl_summary, - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } - Ok(None) => { - // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 - session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); - } - Err(e) => { - // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 - // - // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 - // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): - // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, - // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); - // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; - // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 - // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 - // KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) + - // 消息状态不变,降级 build_for_request 裁剪)。 - session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); - tracing::warn!( - conv_id = %conv_id, - error = %e, - keyword_fallback_len = keyword_fallback.len(), - "[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", - KEYWORD_FALLBACK_ENABLED, - ); - if KEYWORD_FALLBACK_ENABLED { - // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 - let inserted = { + // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ── + // + // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s), + // 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。 + // + // 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false: + // - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮); + // - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要), + // 下一轮 build_for_request 生效; + // - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删) + // 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩。 + // + // 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经 + // session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后 + // 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len + // (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化, + // 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免 + // 与 loop 共享 Box(后者非 Clone,无法移入 task)。 + // + // 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底, + // 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream。 + let session_arc = session_arc.clone(); + let app_handle = app_handle.clone(); + let conv_id = conv_id.to_string(); + let provider_config = provider_config.clone(); + let llm_concurrency = llm_concurrency.clone(); + tokio::spawn(async move { + // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。 + // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放 + // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降, + // 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)。 + let provider = match super::super::secret::build_provider_for(&provider_config) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + conv_id = %conv_id, + error = %e, + keyword_fallback_len = keyword_fallback.len(), + "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", + KEYWORD_FALLBACK_ENABLED, + ); + // 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。 + { let mut session = session_arc.lock().await; + if session.per_conv.contains_key(&conv_id) { + session.conv(&conv_id).messages.set_compressing(false); + } + } + if KEYWORD_FALLBACK_ENABLED { + // 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。 + let inserted = { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(&conv_id) { + return; + } + let conv = session.conv(&conv_id); + let _compressed = conv.messages.compress_old_messages(protect_start); + if !keyword_fallback.is_empty() { + conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); + true + } else { + false + } + }; + if inserted { + tracing::info!( + conv_id = %conv_id, + pre_tokens = pre_compress_tokens, + "[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" + ); + } + } + return; + } + }; + + // 压缩调用(复用主 candidate 的 provider_config,api_key 经 secret 闭环)。 + // 成功 → Some(summary);失败 → Err。 + let compress_outcome: Result, String> = + compress_via_llm( + provider.as_ref(), + &provider_config, + active_msgs, + &lang, + &conv_id, + &llm_concurrency, + ).await.map(Some); + + match compress_outcome { + Ok(Some(summary)) => { + // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 + // conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv, + // is_compressing 标志随 conv 销毁无意义,直接 return。 + { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(&conv_id) { + return; + } let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); - if !keyword_fallback.is_empty() { - conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); - true - } else { - false - } + // T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 + conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); + conv.messages.set_compressing(false); + // TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) + } + tracing::info!( + conv_id = %conv_id, + iteration, + pre_tokens = pre_compress_tokens, + "[ai] 自动压缩成功(后台),摘要已插首位" + ); + // 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), + // 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 + // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 + let ev = AiChatEvent::AiAutoCompressed { + conversation_id: Some(conv_id.clone()), + summary: summary.nl_summary, }; - if inserted { - tracing::info!( - conv_id = %conv_id, - pre_tokens = pre_compress_tokens, - "[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)" - ); + let _ = app_handle.emit("ai-chat-event", ev.clone()); + // L3 emit 双写:tunnel subscriber透传 miniapp + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + } + Ok(None) => { + // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。 + // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(&conv_id) { + session.conv(&conv_id).messages.set_compressing(false); + } + } + Err(e) => { + // LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 + // + // 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 + // 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): + // - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, + // 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); + // - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; + // - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 + // 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 + // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) + // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放 + // token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。 + // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 + { + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(&conv_id) { + session.conv(&conv_id).messages.set_compressing(false); + } + } + tracing::warn!( + conv_id = %conv_id, + error = %e, + keyword_fallback_len = keyword_fallback.len(), + "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", + KEYWORD_FALLBACK_ENABLED, + ); + if KEYWORD_FALLBACK_ENABLED { + // 标 compressed 释放 token + 关键词摘要塞首位(若非空)。 + let inserted = { + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(&conv_id) { + return; + } + let conv = session.conv(&conv_id); + let _compressed = conv.messages.compress_old_messages(protect_start); + if !keyword_fallback.is_empty() { + conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); + true + } else { + false + } + }; + if inserted { + tracing::info!( + conv_id = %conv_id, + pre_tokens = pre_compress_tokens, + "[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)" + ); + } } } - // 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false) - // 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有) - // + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm, - // 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。 } - } + }); } } false diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 8b866fe..ead3682 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1498,15 +1498,16 @@ pub(crate) async fn run_agentic_loop( // 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 // - // 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return + // 抽取自本函数原内联块。返回 true = conv 已删除,loop 应立即 return // (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。 // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见 - // context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。 + // context_lifecycle.rs 顶部文档与函数体内注释。 + // L0-3(2026-08-09):压缩 LLM 调用已后台化(fire-and-forget),本调用不再同步阻塞本轮, + // 返回 false 让当前轮用未压缩上下文正常走;压缩结果下一轮生效(见 context_lifecycle.rs)。 if maybe_auto_compress( &session_arc, &conv_id, &app_handle, - &provider, &provider_config, &llm_concurrency, iteration, diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index ccd51f4..105a6b1 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -215,7 +215,10 @@ fn canonical_args_key(args: &serde_json::Value) -> String { /// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 -fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { +/// +/// pub(crate):供 audit/mod.rs 轮内去重(AC-EFF-T1-2)复用同一 key 口径(同源同 key, +/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。 +pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { if matches!(tool_name, "list_directory" | "list_dir") { args.get("path") .and_then(|v| v.as_str()) diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index b1971b1..2eccbef 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -41,7 +41,7 @@ use approval::{detect_retry_count, handle_approval_tool}; // (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。 pub mod record; #[allow(unused_imports)] -pub(crate) use record::{audit_tool_call, query_audit_history, record_audit}; +pub(crate) use record::{audit_tool_call, build_audit_record, query_audit_history, record_audit}; #[allow(unused_imports)] pub use record::{ list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery, @@ -70,7 +70,7 @@ pub(crate) use finalize::audit_finalize; // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // 第三批从本文件抽离,行为零变更。 mod cache; -pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for}; +pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -594,16 +594,37 @@ pub(crate) async fn process_tool_calls( // 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。 // 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。 // + // AC-EFF-T1-2(2026-08-09)轮内去重:同轮重复 (tool, args_key) 只真执行一次。 + // LLM 单轮可能发多条同参只读调用(失忆重调),原实现首查未命中(本轮首个尚未落 messages) + // 致重复全部真执行(重复 I/O + 重复 tool_result 回灌)。现进程内暂存首个结果 + // (in_round_cache),后续同 key 复用——语义同跨轮缓存,仅因首个在本轮未落库故内存暂存。 + // 命中计数递增威慑(cache_hit_warning 同口径);key 与跨轮缓存同源(readonly_cache_args_key), + // 防两处 key 口径漂移。跨轮重复仍走 find_cached_readonly_result(历史扫描,未变)。 + // // find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。 + // in_round_cache:同轮 (tool, args_key) -> (首个结果内容, 已见同参次数)。首个出现: + // 跨轮缓存命中 → 存 (content, hit_count);未命中 → 真执行,backfill 后存 (content, 1)。 + // in_round_dupes:同轮重复的 draft(延后到 backfill 后复用 in_round_cache,防双执行)。 + let mut in_round_cache: HashMap<(String, String), (String, u32)> = HashMap::new(); + let mut seen_round_keys: HashSet<(String, String)> = HashSet::new(); + let mut in_round_dupes: Vec<(ToolCallDraft, RiskLevel, String)> = Vec::new(); let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len()); for (draft, args, risk_level) in low_risk { + let args_key = readonly_cache_args_key(&draft.name, &args); + let key = (draft.name.clone(), args_key.clone()); + if !seen_round_keys.insert(key) { + // 轮内重复:延后复用首个结果(首个已在 seen/uncached/backfill),不重复历史扫描+DB 查。 + in_round_dupes.push((draft, risk_level, args_key)); + continue; + } let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await; if let Some((cached_content, hit_count)) = cached { - // 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源), - // 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。 + // 缓存命中:存轮内缓存(供后续同 key 复用)+ 直接 push tool_result + 审计 + // (decided_by=cache_hit 标记缓存来源),不走真执行 + 不重emit Started/Completed。 // AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的 // tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result), // 命中次数递增威慑,告知勿再重复调用相同参数的工具。 + in_round_cache.insert((draft.name.clone(), args_key), (cached_content.clone(), hit_count)); let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content); // emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。 let ev = AiChatEvent::AiToolCallCompleted { @@ -680,7 +701,12 @@ pub(crate) async fn process_tool_calls( } })).await; - // 串行回填 tool_result + 审计(短 lock push + 锁外 audit) + // 串行回填 tool_result + 审计收集(短 lock push + 锁外收集),审计改单事务批量插入。 + // AC-EFF-T1-1(2026-08-09):原每结果 audit_tool_call(每次 spawn_blocking + 单行 INSERT, + // 单连接 Mutex 串行)合并为 build_audit_record 收集 + insert_batch 一次事务批量, + // 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。 + let mut audit_records: Vec = + Vec::with_capacity(results.len()); for (draft, raw_result, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), @@ -717,7 +743,89 @@ pub(crate) async fn process_tool_calls( RiskLevel::Medium => "auto_takeover_medium", RiskLevel::High => "auto_takeover_all", }; - audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await; + // AC-EFF-T1-2 轮内去重回填:真执行结果存入 in_round_cache,供同轮后续同 key 复用 + // (首个执行 → count=1,轮内重复命中时递增;key 与跨轮缓存同源)。 + { + let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null); + let exec_key = (draft.name.clone(), readonly_cache_args_key(&draft.name, &args_val)); + in_round_cache.entry(exec_key).or_insert_with(|| (content.clone(), 1)); + } + audit_records.push(build_audit_record( + conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, + Some(content), Some(decided_by), current_message_id, + )); + } + // 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。 + if !audit_records.is_empty() { + let batch_len = audit_records.len(); + if let Err(e) = audit_repo.insert_batch(audit_records).await { + tracing::error!( + conv_id = %conv_id, + batch = batch_len, + "[ai] insert_batch 批量写审计记录失败(已回滚,共 {} 条): {}", + batch_len, + e + ); + } + } + + // AC-EFF-T1-2 轮内重复处理:复用首个结果(已回填 in_round_cache),跳过重复真执行。 + // 语义同跨轮缓存命中:emit Completed + push tool_result + 审计(decided_by=cache_hit), + // 命中计数递增威慑(cache_hit_warning 同口径)。首个结果来自跨轮命中(前段已存)或 + // 本轮真执行 backfill(上段已存),故必命中;防御性缺失则丢弃(不重复执行,LLM 下轮可重调)。 + let mut dupe_audit_records: Vec = + Vec::with_capacity(in_round_dupes.len()); + for (draft, risk_level, args_key) in in_round_dupes { + let key = (draft.name.clone(), args_key); + let Some((content, count)) = in_round_cache.get(&key).cloned() else { + tracing::warn!( + conv_id = %conv_id, + tool = %draft.name, + tc_id = %draft.id, + "[ai] 轮内去重:首个结果缺失(理论不可达),丢弃重复调用(防御降级)" + ); + continue; + }; + let new_count = count + 1; + in_round_cache.insert(key, (content.clone(), new_count)); + let warned_content = format!("{}{}", cache_hit_warning(&draft.name, new_count), content); + // emit Completed 携带缓存结果供前端折叠卡片展示。 + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(warned_content.clone()), + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + // 短 lock 段:push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content)); + } + dupe_audit_records.push(build_audit_record( + conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, + Some(warned_content), Some("cache_hit"), current_message_id, + )); + tracing::info!( + conv_id = %conv_id, + tool = %draft.name, + tc_id = %draft.id, + hit_count = new_count, + "[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行", + new_count + ); + } + if !dupe_audit_records.is_empty() { + let dup_len = dupe_audit_records.len(); + if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await { + tracing::error!( + conv_id = %conv_id, + batch = dup_len, + "[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}", + dup_len, + e + ); + } } } diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 1db35c5..85f53ef 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -24,10 +24,50 @@ use crate::state::AppState; use super::risk_str; -/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) +/// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。 /// -/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; -/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 +/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; +/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 +pub(crate) fn build_audit_record( + conv_id: &str, + tool_call_id: &str, + tool_name: &str, + arguments: &str, + status: &str, + risk_level: RiskLevel, + result: Option, + decided_by: Option<&str>, + message_id: Option<&str>, +) -> AiToolExecutionRecord { + let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; + AiToolExecutionRecord { + id: new_id(), + conversation_id: Some(conv_id.to_string()), + // P1 消息级溯源:message_id 由调用方(process_tool_calls)从 + // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 + // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 + // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 + message_id: message_id.map(|s| s.to_string()), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + arguments: arguments.to_string(), + result, + status: status.to_string(), + risk_level: risk_str(risk_level).to_string(), + requested_at: now_millis(), + executed_at, + decided_by: decided_by.map(|s| s.to_string()), + } +} + +/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) +/// +/// 单条写入路径。批量路径(audit/mod.rs process_tool_calls 回填循环)经 +/// [`build_audit_record`] 收集记录后调 `AiToolExecutionRepo::insert_batch` +/// 单事务批量插入(治 aichat 效率 AC-EFF-T1-1,N 次串行 INSERT → 一次事务)。 +/// +/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; +/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 pub(crate) async fn audit_tool_call( repo: &AiToolExecutionRepo, conv_id: &str, @@ -40,28 +80,11 @@ pub(crate) async fn audit_tool_call( decided_by: Option<&str>, message_id: Option<&str>, ) { - let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; - if let Err(e) = repo - .insert(AiToolExecutionRecord { - id: new_id(), - conversation_id: Some(conv_id.to_string()), - // P1 消息级溯源:message_id 由调用方(process_tool_calls)从 - // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 - // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 - // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 - message_id: message_id.map(|s| s.to_string()), - tool_call_id: tool_call_id.to_string(), - tool_name: tool_name.to_string(), - arguments: arguments.to_string(), - result, - status: status.to_string(), - risk_level: risk_str(risk_level).to_string(), - requested_at: now_millis(), - executed_at, - decided_by: decided_by.map(|s| s.to_string()), - }) - .await - { + let record = build_audit_record( + conv_id, tool_call_id, tool_name, arguments, + status, risk_level, result, decided_by, message_id, + ); + if let Err(e) = repo.insert(record).await { tracing::error!( "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", conv_id, diff --git a/src-tauri/src/commands/ai/command_stream.rs b/src-tauri/src/commands/ai/command_stream.rs index 6960e6a..0379c0b 100644 --- a/src-tauri/src/commands/ai/command_stream.rs +++ b/src-tauri/src/commands/ai/command_stream.rs @@ -13,38 +13,179 @@ //! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler), //! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。 //! +//! AC-EFF-S2-5(2026-08-09):AiCommandOutput 合批。run_command 逐行回调不再每行单独 emit + +//! publish_event(大输出风暴时 IPC 事件风暴),改为累积进 [`CommandSink`] 共享缓冲(stdout/stderr +//! 分桶),由「后台 50ms 定时 flush + 体积阈值(4KB)立即 flush + Drop 兜底 flush」三路输出, +//! 每个 stream 字段累积的字符串(多行 '\n' 连接)作为单条 AiCommandOutput 输出(对齐 +//! stream_recv DELTA_FLUSH_INTERVAL=50ms 的 delta 合批;消费方按文本渲染,多行输出等价逐行)。 +//! //! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop, //! 兜底不报错不阻断。 +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + use tauri::{AppHandle, Emitter, Manager}; use super::AiChatEvent; use df_execute::shell::StreamKind; +/// AiCommandOutput 合批参数(AC-EFF-S2-5,对齐 stream_recv DELTA_FLUSH_INTERVAL=50ms)。 +/// +/// 时间维度:后台 flush task 每 50ms 兜底输出一次(与 AR-8 delta 合批同 cadence); +/// 体积维度:单次累积超 [`FLUSH_BYTE_THRESHOLD`] 立即 flush(防大输出风暴 50ms 窗口内积压过多)。 +/// 语义:stdout/stderr 各累积的字符串(多行以 '\n' 连接)作为**单条** AiCommandOutput 事件输出, +/// 不再每行单独 emit + publish_event——砍 IPC 事件风暴(与 delta 合批「合并后前端逐条累加,最终一致」 +/// 同思路,消费方按文本渲染,多行输出等价于逐行)。 +const FLUSH_INTERVAL: Duration = Duration::from_millis(50); +const FLUSH_BYTE_THRESHOLD: usize = 4096; + /// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。 -#[derive(Clone)] +/// +/// AC-EFF-S2-5(2026-08-09):合批。run_command 逐行回调 → 累积进共享缓冲(stdout/stderr 分桶), +/// 由「后台 50ms 定时 flush + 体积阈值立即 flush + Drop 兜底 flush」三路输出,不再每行单独 +/// emit(治 aichat 效率 S2-5)。缓冲用 `std::sync::Mutex`(短临界区无 await,emit 是同步回调)。 pub struct CommandSink { app: AppHandle, tool_call_id: String, conversation_id: Option, + /// 合批共享缓冲(emit 线程写 / flush 定时读)。 + buffer: Arc>, + /// 后台 flush task 守卫(Drop 时停止 + abort,并兜底 flush 尾部)。 + _flusher: FlushGuard, +} + +/// 合批缓冲:stdout/stderr 分开累积(按 stream 字段 emit),bytes 供体积阈值判定。 +#[derive(Default)] +struct CommandBuffer { + stdout: String, + stderr: String, + bytes: usize, +} + +/// 后台 flush task 守卫:stop 标志 + JoinHandle。Drop 时置停 + abort(任务 50ms 循环读到 stop 退出), +/// 尾部输出由 CommandSink::drop 兜底 flush,不丢命令结尾。 +struct FlushGuard { + stop: Arc, + handle: Option>, } impl CommandSink { pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option) -> Self { - Self { app, tool_call_id, conversation_id } + let buffer = Arc::new(StdMutex::new(CommandBuffer::default())); + let stop = Arc::new(AtomicBool::new(false)); + // 后台 50ms flush task。仅当处于 tokio runtime 上下文时 spawn(tokio::spawn 需 runtime)。 + // CommandSink::new 由 execute_with_heartbeat / ai_approve(async)调用,正常在 runtime 内; + // 非 runtime(如单测直接 new)跳过定时 task,退化为「体积阈值 + Drop 兜底」flush(仍不丢尾部)。 + let handle = if tokio::runtime::Handle::try_current().is_ok() { + let buf = buffer.clone(); + let stop_f = stop.clone(); + let app_f = app.clone(); + let id_f = tool_call_id.clone(); + let conv_f = conversation_id.clone(); + Some(tokio::spawn(async move { + let mut interval = tokio::time::interval(FLUSH_INTERVAL); + interval.tick().await; // 弃首 tick(tokio interval 首 tick 立即返回,对齐心跳弃首模式) + loop { + interval.tick().await; + if stop_f.load(Ordering::SeqCst) { + break; + } + Self::flush_inner(&app_f, &id_f, &conv_f, &buf); + } + })) + } else { + None + }; + Self { + app, + tool_call_id, + conversation_id, + buffer, + _flusher: FlushGuard { stop, handle }, + } } - /// emit 一行 stdout/stderr(AiCommandOutput,双写 app.emit + ai_event_bus)。 - /// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。 + /// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。 + /// + /// AC-EFF-S2-5:不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush + /// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。 fn emit(&self, kind: StreamKind, line: &str) { + let over_threshold = { + let mut buf = self.buffer.lock().unwrap(); + let target = if kind == StreamKind::Stdout { &mut buf.stdout } else { &mut buf.stderr }; + if !target.is_empty() { + target.push('\n'); + } + target.push_str(line); + buf.bytes += line.len(); + buf.bytes >= FLUSH_BYTE_THRESHOLD + }; + if over_threshold { + Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer); + } + } + + /// 兜底 flush(把当前缓冲整体取出并 emit)。Drop 时调用,保证尾部输出不丢。 + fn flush(&self) { + Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer); + } + + /// 内部 flush:单次锁内 take 缓冲(不持锁 emit),stdout/stderr 各输出一条(多行拼接)。 + /// 与后台 task / Drop 并发调用安全:take 语义下同一批只被 drain 一次(无重复 emit)。 + fn flush_inner( + app: &AppHandle, + tool_call_id: &str, + conversation_id: &Option, + buffer: &Arc>, + ) { + let drained = { + let mut buf = buffer.lock().unwrap(); + if buf.stdout.is_empty() && buf.stderr.is_empty() { + return; + } + Some(std::mem::take(&mut *buf)) + }; + if let Some(buf) = drained { + if !buf.stdout.is_empty() { + Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stdout, &buf.stdout); + } + if !buf.stderr.is_empty() { + Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stderr, &buf.stderr); + } + } + } + + /// emit 一条 AiCommandOutput(stdout/stderr 累积字符串,双写 app.emit + ai_event_bus)。 + fn emit_line( + app: &AppHandle, + tool_call_id: &str, + conversation_id: &Option, + kind: StreamKind, + line: &str, + ) { let ev = AiChatEvent::AiCommandOutput { - id: self.tool_call_id.clone(), + id: tool_call_id.to_string(), stream: kind.as_str().to_string(), line: line.to_string(), - conversation_id: self.conversation_id.clone(), + conversation_id: conversation_id.clone(), }; - let _ = self.app.emit("ai-chat-event", ev.clone()); - let _ = self.app.state::().ai_event_bus.publish_event(ev); + let _ = app.emit("ai-chat-event", ev.clone()); + let _ = app.state::().ai_event_bus.publish_event(ev); + } +} + +impl Drop for CommandSink { + fn drop(&mut self) { + // 停后台 flush task + abort(任务在 stop 后下次 tick 退出),再兜底 flush 剩余缓冲 + // (命令结束尾部输出不丢)。并发安全:flush_inner 的 take 语义保证不重复 emit。 + self._flusher.stop.store(true, Ordering::SeqCst); + if let Some(h) = self._flusher.handle.take() { + h.abort(); + } + self.flush(); } } diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index f67199b..f8eca98 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -867,13 +867,16 @@ defineExpose({ @click="toggleJson(item.msg.id)" >
{{ item.msg.content }}
- -