优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:59 +08:00
parent fbd8fae44b
commit 11f4978ec1
15 changed files with 841 additions and 193 deletions
+45 -3
View File
@@ -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}");
}
}
+10 -3
View File
@@ -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=<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)");
+112 -6
View File
@@ -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<String> {
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<WebSocket>) -> 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);
}
}
@@ -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<AiToolExecutionRecord>) -> 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,
+3 -1
View File
@@ -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`]:隧道透传事件/指令/消息包装类型
+15 -10
View File
@@ -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<Option<String>>,
device_id: Mutex<Option<String>>,
token: Mutex<Option<String>>,
@@ -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<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
}
}
/// 计算指数退避延迟(供未来 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()
+13 -20
View File
@@ -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 后端校验/聚合)
**P354 项)**: 见详单文档(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)
**P325 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5
@@ -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<Mutex<AiSession>>,
conv_id: &str,
app_handle: &AppHandle,
provider: &Box<dyn LlmProvider>,
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,78 +165,68 @@ 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<Option<CompressedSummary>, 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::<AppState>().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 + 关键词摘要塞回首条(非裸裁剪)。
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
//
// 旧行为:消息状态完全不变,降级走 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);
// 原实现在此 `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<dyn LlmProvider>(后者非 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] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
"[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 {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
// 同 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() {
@@ -236,16 +240,122 @@ pub(super) async fn maybe_auto_compress(
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
"[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
return;
}
};
// 压缩调用(复用主 candidate 的 provider_config,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err。
let compress_outcome: Result<Option<CompressedSummary>, 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);
// 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,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().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(上方 line 957 已有)
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
// 误判生成结束(后端 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)"
);
}
}
}
}
});
}
}
false
+4 -3
View File
@@ -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,
+4 -1
View File
@@ -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())
+114 -6
View File
@@ -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<df_storage::models::AiToolExecutionRecord> =
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<df_storage::models::AiToolExecutionRecord> =
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::<crate::state::AppState>().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
);
}
}
}
+34 -11
View File
@@ -24,12 +24,11 @@ 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 回填。
pub(crate) async fn audit_tool_call(
repo: &AiToolExecutionRepo,
/// `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,
@@ -39,10 +38,9 @@ pub(crate) async fn audit_tool_call(
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
) {
) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
if let Err(e) = repo
.insert(AiToolExecutionRecord {
AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
@@ -59,9 +57,34 @@ pub(crate) async fn audit_tool_call(
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
})
.await
{
}
}
/// 写一条工具执行审计记录(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,
tool_call_id: &str,
tool_name: &str,
arguments: &str,
status: &str,
risk_level: RiskLevel,
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
) {
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,
+149 -8
View File
@@ -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<String>,
/// 合批共享缓冲(emit 线程写 / flush 定时读)。
buffer: Arc<StdMutex<CommandBuffer>>,
/// 后台 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<AtomicBool>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl CommandSink {
pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> 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<String>,
buffer: &Arc<StdMutex<CommandBuffer>>,
) {
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<String>,
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::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().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();
}
}
+6 -3
View File
@@ -867,13 +867,16 @@ defineExpose({
@click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></div>
<div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定DOM不重建) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
<!-- 流式:分块渲染(已完成块key稳定DOM不重建)
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
消除currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染的双渲染
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
+58 -4
View File
@@ -64,6 +64,20 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let rafId: number | null = null
let lastStreamText = ''
// ═══ AC-EFF-S1-1 增量渲染状态(2026-08-09) ═══
// 流式追加场景(text.startsWith(_prevStreamText))下,已完成块(除末块外)跨帧稳定:
// splitBlocks 按 \n\n / 已闭合代码围栏切块,块边界随文本追加保持稳定(末块外不重切)。
// 原实现每帧对全文 marked.lexer O(n)(长回复/单块回复病态甚至每帧 O(n) lexer + O(n) parse)。
// 现缓存上一帧「已完成块 + 末块文本」,只对「旧末块 + 增长段」增量 lexer,已完成块直接复用
// (块内容寻址缓存 _blockCache 本就命中,此处省掉对全文的重复 lexer)。
// 非追加(首帧 / 重渲染 / 内容替换)回退全量 splitBlocks(与旧行为一致,渲染结果等价)。
let _prevStreamText = ''
let _prevCompleted: string[] = []
let _prevTail = ''
/// 单块回复病态阈值:整个回复无 \n\n/围栏边界(末块=全文)且超此长度时,末块降级纯文本
/// (escapeFallback,避免每帧 marked.lexer+parse O(n) 卡顿),AiCompleted 后整段 renderMd 完整渲染。
const SINGLE_BLOCK_TEXT_LIMIT = 12000
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
/// (跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
@@ -124,17 +138,46 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
return html
}
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
/// 流式渲染:增量切块 + 块级 memo(前块缓存/末块重 parse)
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
///
/// AC-EFF-S1-1 增量:追加场景复用上一帧已完成块,只对「旧末块+增长段」增量 lexer,
/// 不再每帧全文 lexer;单块回复(整回复无块边界)超阈值降级纯文本防 O(n) parse 卡顿。
function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return []
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
return [{ html: escapeFallback(text), key: 'fallback' }]
}
const blocks = splitBlocks(text)
// 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
// 非追加(首帧/重渲染/内容替换)→ 全量 splitBlocks(与旧行为一致)。
const appendOnly = _prevStreamText.length > 0 && text.startsWith(_prevStreamText)
let blocks: string[]
if (appendOnly) {
const growth = text.slice(_prevStreamText.length)
const combined = _prevTail + growth
const combinedBlocks = splitBlocks(combined)
blocks = combinedBlocks.length <= 1
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长
? _prevCompleted.concat(combinedBlocks)
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
} else {
blocks = splitBlocks(text)
}
// 记录下一帧增量基线:已完成块 = 除末块外全部;末块 = 最后一块(可能不完整)。
if (blocks.length > 1) {
_prevCompleted = blocks.slice(0, -1)
_prevTail = blocks[blocks.length - 1]
} else {
_prevCompleted = []
_prevTail = blocks.length === 1 ? blocks[0] : ''
}
_prevStreamText = text
const n = blocks.length
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
return blocks.map((b, i) => {
@@ -144,7 +187,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade
// AC-EFF-S1-1:单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本,
// 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。
const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT
const html = (degrade || longSingleBlock)
? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
@@ -194,11 +240,15 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线
function clearStreamingState(): void {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
streamingBlocks.value = []
lastStreamText = ''
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -209,8 +259,12 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void {
_blockCache.clear()
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
return {