优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账
This commit is contained in:
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
|||||||
format!("{}…(已截断)", truncated)
|
format!("{}…(已截断)", truncated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
/// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
|
||||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
/// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)。
|
||||||
pub fn normalize_path(p: &str) -> String {
|
pub fn normalize_for_compare(p: &str) -> String {
|
||||||
match Path::new(p).canonicalize() {
|
match Path::new(p).canonicalize() {
|
||||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||||
Err(_) => p
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -111,4 +127,30 @@ mod tests {
|
|||||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||||
assert_eq!(n, n.to_lowercase(), "未小写: {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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,22 @@
|
|||||||
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
|
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
|
||||||
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
|
//! - `/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]
|
#[tokio::main]
|
||||||
async fn main() -> df_relay::Result<()> {
|
async fn main() -> df_relay::Result<()> {
|
||||||
tracing_subscriber::fmt::init();
|
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 集成)。
|
// 监听地址: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());
|
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)");
|
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
||||||
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
|
//! 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 + 方向转发。
|
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
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::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||||
use crate::error::{RelayError, Result};
|
use crate::error::{RelayError, Result};
|
||||||
|
|
||||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
|
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
|
||||||
|
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
|
||||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||||
fn expected_token() -> String {
|
fn expected_token() -> Option<String> {
|
||||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
|
std::env::var("DF_RELAY_TOKEN").ok()
|
||||||
panic!("必须设置环境变量 DF_RELAY_TOKEN")
|
}
|
||||||
})
|
|
||||||
|
/// 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;
|
let _ = socket_tx.close().await;
|
||||||
return;
|
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!(
|
tracing::warn!(
|
||||||
device_id = %hello.device_id,
|
device_id = %hello.device_id,
|
||||||
"握手失败:token 校验不通过"
|
"握手失败:token 校验不通过"
|
||||||
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
|||||||
maybe_msg = socket_rx.next() => {
|
maybe_msg = socket_rx.next() => {
|
||||||
match maybe_msg {
|
match maybe_msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
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 {
|
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
|
||||||
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
|
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())),
|
_ => 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 =
|
let hello: Hello =
|
||||||
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
||||||
Ok(hello)
|
Ok(hello)
|
||||||
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
|
|||||||
.map(|d| d.as_millis() as i64)
|
.map(|d| d.as_millis() as i64)
|
||||||
.unwrap_or(0)
|
.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 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。
|
||||||
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
|
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
|
||||||
impl AiToolExecutionRepo {
|
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 查最新一条审计记录(审批回填定位用)。
|
/// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。
|
||||||
pub async fn find_by_tool_call_id(
|
pub async fn find_by_tool_call_id(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
//!
|
//!
|
||||||
//! ## 边界
|
//! ## 边界
|
||||||
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
|
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
|
||||||
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
|
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
|
||||||
|
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
|
||||||
|
//! 原语 + `backoff_delay` 退避工具)
|
||||||
//!
|
//!
|
||||||
//! ## 模块
|
//! ## 模块
|
||||||
//! - [`events`]:隧道透传事件/指令/消息包装类型
|
//! - [`events`]:隧道透传事件/指令/消息包装类型
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! df-tunnel 隧道客户端(Phase2 完整实现)
|
//! df-tunnel 隧道客户端(Phase2 完整实现)
|
||||||
//!
|
//!
|
||||||
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
|
//! 设计依据:设计文档「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)
|
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
|
||||||
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
|
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
|
||||||
@@ -130,9 +132,9 @@ enum OutFrame {
|
|||||||
|
|
||||||
/// 默认 WS 隧道客户端(完整实现)
|
/// 默认 WS 隧道客户端(完整实现)
|
||||||
pub struct WsTunnelClient {
|
pub struct WsTunnelClient {
|
||||||
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
|
/// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
|
||||||
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
|
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
|
||||||
/// 该字段保留以备 Phase3「断线后台自动重连」消费。
|
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。
|
||||||
server_url: Mutex<Option<String>>,
|
server_url: Mutex<Option<String>>,
|
||||||
device_id: Mutex<Option<String>>,
|
device_id: Mutex<Option<String>>,
|
||||||
token: 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)> {
|
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
|
||||||
let url = self.server_url.lock().await.clone()?;
|
let url = self.server_url.lock().await.clone()?;
|
||||||
let device_id = self.device_id.lock().await.clone()?;
|
let device_id = self.device_id.lock().await.clone()?;
|
||||||
@@ -344,8 +346,9 @@ impl WsTunnelClient {
|
|||||||
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
|
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
|
||||||
/// 4. 任一端断开 → 退出 task,置 connected=false
|
/// 4. 任一端断开 → 退出 task,置 connected=false
|
||||||
///
|
///
|
||||||
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
|
/// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
|
||||||
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
|
/// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params) 取参数
|
||||||
|
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
|
||||||
async fn run_loop(
|
async fn run_loop(
|
||||||
mut sink: futures_util::stream::SplitSink<
|
mut sink: futures_util::stream::SplitSink<
|
||||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||||
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
|
/// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
|
||||||
#[allow(dead_code)]
|
///
|
||||||
fn backoff_delay(attempt: u32) -> Duration {
|
/// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
|
||||||
|
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
|
||||||
|
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||||
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
||||||
let secs = RECONNECT_BASE
|
let secs = RECONNECT_BASE
|
||||||
.as_secs()
|
.as_secs()
|
||||||
|
|||||||
+13
-20
@@ -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-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-1** — ✅ 已修(0300764):save_conversation 无变化捷径提前判定(无变化不 clone/truncate/构建 records)
|
||||||
- [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接)
|
- [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接)
|
||||||
- [ ] **AC-EFF-L0-3** — 🟡 自动压缩同步阻塞整轮(留设计,后台化+防重入)
|
- [x] **AC-EFF-L0-3** — ✅ 已修(c41f4ad):压缩后台化 tokio::spawn fire-and-forget + is_compressing 防重入,当前轮正常走不阻塞
|
||||||
- [ ] **AC-EFF-T1-1** 审计串行 INSERT 需批量事务 / **T1-2** 只读缓存 O(1) 索引 / **T1-3** git 工具不进缓存 —— 🟡 待后续批(DB 层改造)
|
- [x] **AC-EFF-T1-1** ✅ 已修(c41f4ad):AiToolExecutionRepo.insert_batch 单事务批量,审计砍串行尾巴 / **T1-2** ✅ 已修:只读缓存轮内去重(同轮重复 (tool,args_key) 只真执行一次) / **T1-3** 🟡 git 工具不进缓存(留待后续,module.rs git 缓存未共享)
|
||||||
|
|
||||||
**前端效率(流式 + 交互 · P1×4)**:
|
**前端效率(流式 + 交互 · 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 自触发+无防抖
|
- [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 全量)
|
**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-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-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验
|
||||||
- [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验
|
- [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验
|
||||||
- [ ] **LW-5** count_tasks 与 list_tasks 过滤维度不一致(count 超算致翻页空页)
|
- [x] **LW-5** ✅ 已修(7795fe6):count_by_query 补齐 assignee/queue/parent_id/module_id 维度
|
||||||
- [ ] **LW-6** update_field 可更新已软删任务(get_by_id 不过滤 deleted_at)
|
- [x] **LW-6** ✅ 已修(7795fe6):update_field_active(WHERE id AND deleted_at IS NULL) 软删拒改
|
||||||
- [ ] **LW-7** evaluate_idea 无条件覆盖 pending_review(打回终态)
|
- [x] **LW-7** ✅ 已修(7795fe6):evaluate_idea 起始 guard 终态返 Err
|
||||||
- [ ] **LW-8** promote_idea 读-改-写竞态(双击重复立项+补偿非原子)
|
- [x] **LW-8** ✅ 已修(7795fe6):claim_promotion CAS(WHERE id AND promoted_to IS NULL) 防双击重复立项
|
||||||
- [ ] **LW-9** task create/reparent/move 不重算父聚合(父 status 陈旧)
|
- [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 后端校验/聚合)
|
**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)
|
**P3(54 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45)
|
||||||
@@ -682,20 +682,13 @@ graph TD
|
|||||||
|
|
||||||
**P1**:
|
**P1**:
|
||||||
- [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口)
|
- [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口)
|
||||||
- [x] **MC-2** 🟡 待定(跨端批 4d):df-project normalize_path 存库
|
- [x] **MC-2** ✅ 已修(4387695):df-project normalize_path 拆 normalize_for_compare/canonicalize_for_store,存库保留大小写(调用点迁移留后续)
|
||||||
- [x] **MC-3** 🟡 待定(跨端批 4d):df-tunnel 自动重连
|
- [x] **MC-3** ✅ 已修(4387695):df-tunnel 文档更正——自动重连已在 src-tauri supervisor 实现(lib.rs:292 轮询+指数退避),避免双重竞争;backoff_delay 改 pub 供 supervisor 复用
|
||||||
- [x] **MC-4** 🟡 待定(跨端批 4d):df-relay per-device 鉴权
|
- [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-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 依赖树)
|
- [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-7** 🟡 df-ideas 自洽性校验 / df-types Priority from_i32(待后续批)
|
||||||
- [ ] **MC-2** df-project normalize_path 无条件 to_lowercase 被 bind_directory 存库(大小写敏感系统路径失效,需确认目标平台)
|
- [x] **RLY 附加** ✅ 已修(4387695):token 固定时间比较 + 入站帧 1MB 上限 + 未设 token 改错误退出(不再 panic)
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
|
**P3(25 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
|
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
|
||||||
//!
|
//!
|
||||||
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更。
|
//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。
|
||||||
//!
|
//!
|
||||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||||
//!
|
//!
|
||||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
//! 流程(对齐 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 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
||||||
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||||
//!
|
//!
|
||||||
@@ -14,14 +14,21 @@
|
|||||||
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
||||||
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
||||||
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
||||||
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长
|
||||||
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
//! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len,
|
||||||
|
//! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。
|
||||||
//!
|
//!
|
||||||
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
//! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for` 重建
|
||||||
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
|
//! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次
|
||||||
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
|
//! 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)不触发,零行为变化。
|
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
|
||||||
//! messages 操作改 per_conv。
|
//! messages 操作改 per_conv。
|
||||||
|
//!
|
||||||
|
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
|
||||||
|
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -29,7 +36,7 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
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 df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
use crate::state::{AppState, LlmConcurrency};
|
use crate::state::{AppState, LlmConcurrency};
|
||||||
@@ -53,19 +60,26 @@ const KEYWORD_FALLBACK_ENABLED: bool = true;
|
|||||||
|
|
||||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
/// 自动压缩(智能裁剪)——在 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)。
|
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
|
||||||
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
|
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
|
||||||
///
|
///
|
||||||
/// 参数沿用原内联代码引用的局部:
|
/// 参数沿用原内联代码引用的局部:
|
||||||
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。
|
/// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经
|
||||||
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。
|
/// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次
|
||||||
|
/// keyring resolve,在后台线程不阻塞 loop)。
|
||||||
/// - `iteration`:当前轮次(仅用于日志)。
|
/// - `iteration`:当前轮次(仅用于日志)。
|
||||||
pub(super) async fn maybe_auto_compress(
|
pub(super) async fn maybe_auto_compress(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
app_handle: &AppHandle,
|
app_handle: &AppHandle,
|
||||||
provider: &Box<dyn LlmProvider>,
|
|
||||||
provider_config: &AiProviderRecord,
|
provider_config: &AiProviderRecord,
|
||||||
llm_concurrency: &LlmConcurrency,
|
llm_concurrency: &LlmConcurrency,
|
||||||
iteration: usize,
|
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 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
||||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
||||||
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
||||||
@@ -151,101 +165,197 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
// L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing
|
||||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
// 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义。
|
||||||
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() {
|
if active_msgs.is_empty() {
|
||||||
Ok(None)
|
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||||
} else {
|
return false;
|
||||||
compress_via_llm(
|
}
|
||||||
provider.as_ref(),
|
|
||||||
provider_config,
|
|
||||||
active_msgs,
|
|
||||||
&lang,
|
|
||||||
conv_id,
|
|
||||||
llm_concurrency,
|
|
||||||
).await.map(Some)
|
|
||||||
};
|
|
||||||
|
|
||||||
match compress_outcome {
|
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
|
||||||
Ok(Some(summary)) => {
|
//
|
||||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
|
||||||
{
|
// 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。
|
||||||
let mut session = session_arc.lock().await;
|
//
|
||||||
let conv = session.conv(&conv_id);
|
// 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false:
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
// - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮);
|
||||||
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
|
// - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要),
|
||||||
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
|
// 下一轮 build_for_request 生效;
|
||||||
conv.messages.set_compressing(false);
|
// - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删)
|
||||||
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
|
// 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩。
|
||||||
}
|
//
|
||||||
tracing::info!(
|
// 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经
|
||||||
conv_id = %conv_id,
|
// session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后
|
||||||
iteration,
|
// 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len
|
||||||
pre_tokens = pre_compress_tokens,
|
// (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化,
|
||||||
"[ai] 自动压缩成功,摘要已插首位"
|
// 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免
|
||||||
);
|
// 与 loop 共享 Box<dyn LlmProvider>(后者非 Clone,无法移入 task)。
|
||||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
//
|
||||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
// 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底,
|
||||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
// 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream。
|
||||||
let ev = AiChatEvent::AiAutoCompressed {
|
let session_arc = session_arc.clone();
|
||||||
conversation_id: Some(conv_id.to_string()),
|
let app_handle = app_handle.clone();
|
||||||
summary: summary.nl_summary,
|
let conv_id = conv_id.to_string();
|
||||||
};
|
let provider_config = provider_config.clone();
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
let llm_concurrency = llm_concurrency.clone();
|
||||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
tokio::spawn(async move {
|
||||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
|
||||||
}
|
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
|
||||||
Ok(None) => {
|
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
|
||||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
// 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)。
|
||||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
let provider = match super::super::secret::build_provider_for(&provider_config) {
|
||||||
}
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
tracing::warn!(
|
||||||
//
|
conv_id = %conv_id,
|
||||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
error = %e,
|
||||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
keyword_fallback_len = keyword_fallback.len(),
|
||||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
);
|
||||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
|
||||||
// 持久化语义不变: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 = {
|
|
||||||
let mut session = session_arc.lock().await;
|
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<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 conv = session.conv(&conv_id);
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||||
if !keyword_fallback.is_empty() {
|
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
|
||||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
|
||||||
true
|
conv.messages.set_compressing(false);
|
||||||
} else {
|
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
|
||||||
false
|
}
|
||||||
}
|
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 {
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
tracing::info!(
|
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||||
conv_id = %conv_id,
|
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||||
pre_tokens = pre_compress_tokens,
|
}
|
||||||
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
|
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
|
false
|
||||||
|
|||||||
@@ -1498,15 +1498,16 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
|
|
||||||
// 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。
|
// 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。
|
||||||
//
|
//
|
||||||
// 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return
|
// 抽取自本函数原内联块。返回 true = conv 已删除,loop 应立即 return
|
||||||
// (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。
|
// (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。
|
||||||
// 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见
|
// 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见
|
||||||
// context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。
|
// context_lifecycle.rs 顶部文档与函数体内注释。
|
||||||
|
// L0-3(2026-08-09):压缩 LLM 调用已后台化(fire-and-forget),本调用不再同步阻塞本轮,
|
||||||
|
// 返回 false 让当前轮用未压缩上下文正常走;压缩结果下一轮生效(见 context_lifecycle.rs)。
|
||||||
if maybe_auto_compress(
|
if maybe_auto_compress(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&provider,
|
|
||||||
&provider_config,
|
&provider_config,
|
||||||
&llm_concurrency,
|
&llm_concurrency,
|
||||||
iteration,
|
iteration,
|
||||||
|
|||||||
@@ -215,7 +215,10 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
|||||||
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
|
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
|
||||||
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
|
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
|
||||||
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
|
/// 其他只读工具仍走全参 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") {
|
if matches!(tool_name, "list_directory" | "list_dir") {
|
||||||
args.get("path")
|
args.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use approval::{detect_retry_count, handle_approval_tool};
|
|||||||
// (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。
|
// (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。
|
||||||
pub mod record;
|
pub mod record;
|
||||||
#[allow(unused_imports)]
|
#[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)]
|
#[allow(unused_imports)]
|
||||||
pub use record::{
|
pub use record::{
|
||||||
list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery,
|
list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery,
|
||||||
@@ -70,7 +70,7 @@ pub(crate) use finalize::audit_finalize;
|
|||||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||||
// 第三批从本文件抽离,行为零变更。
|
// 第三批从本文件抽离,行为零变更。
|
||||||
mod cache;
|
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 数据变更联动刷新。
|
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||||
@@ -594,16 +594,37 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
|
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
|
||||||
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
|
// 安全边界见 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 查,本段不持锁。
|
// 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());
|
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 {
|
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;
|
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
|
||||||
if let Some((cached_content, hit_count)) = cached {
|
if let Some((cached_content, hit_count)) = cached {
|
||||||
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源),
|
// 缓存命中:存轮内缓存(供后续同 key 复用)+ 直接 push tool_result + 审计
|
||||||
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。
|
// (decided_by=cache_hit 标记缓存来源),不走真执行 + 不重emit Started/Completed。
|
||||||
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
|
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
|
||||||
// tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result),
|
// 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);
|
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content);
|
||||||
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
|
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
@@ -680,7 +701,12 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
})).await;
|
})).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 {
|
for (draft, raw_result, risk_level, outcome) in results {
|
||||||
let (status, content) = match outcome {
|
let (status, content) = match outcome {
|
||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
@@ -717,7 +743,89 @@ pub(crate) async fn process_tool_calls(
|
|||||||
RiskLevel::Medium => "auto_takeover_medium",
|
RiskLevel::Medium => "auto_takeover_medium",
|
||||||
RiskLevel::High => "auto_takeover_all",
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,50 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
use super::risk_str;
|
use super::risk_str;
|
||||||
|
|
||||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
/// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。
|
||||||
///
|
///
|
||||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
/// `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<String>,
|
||||||
|
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(
|
pub(crate) async fn audit_tool_call(
|
||||||
repo: &AiToolExecutionRepo,
|
repo: &AiToolExecutionRepo,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
@@ -40,28 +80,11 @@ pub(crate) async fn audit_tool_call(
|
|||||||
decided_by: Option<&str>,
|
decided_by: Option<&str>,
|
||||||
message_id: Option<&str>,
|
message_id: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
let record = build_audit_record(
|
||||||
if let Err(e) = repo
|
conv_id, tool_call_id, tool_name, arguments,
|
||||||
.insert(AiToolExecutionRecord {
|
status, risk_level, result, decided_by, message_id,
|
||||||
id: new_id(),
|
);
|
||||||
conversation_id: Some(conv_id.to_string()),
|
if let Err(e) = repo.insert(record).await {
|
||||||
// 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
|
|
||||||
{
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||||||
conv_id,
|
conv_id,
|
||||||
|
|||||||
@@ -13,38 +13,179 @@
|
|||||||
//! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler),
|
//! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler),
|
||||||
//! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。
|
//! 命中则改走 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,
|
//! 未注入 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 tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use super::AiChatEvent;
|
use super::AiChatEvent;
|
||||||
use df_execute::shell::StreamKind;
|
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 事件所需上下文)。
|
/// 一次 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 {
|
pub struct CommandSink {
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
tool_call_id: String,
|
tool_call_id: String,
|
||||||
conversation_id: Option<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 {
|
impl CommandSink {
|
||||||
pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self {
|
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)。
|
/// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。
|
||||||
/// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
|
///
|
||||||
|
/// AC-EFF-S2-5:不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush
|
||||||
|
/// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
|
||||||
fn emit(&self, kind: StreamKind, line: &str) {
|
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 {
|
let ev = AiChatEvent::AiCommandOutput {
|
||||||
id: self.tool_call_id.clone(),
|
id: tool_call_id.to_string(),
|
||||||
stream: kind.as_str().to_string(),
|
stream: kind.as_str().to_string(),
|
||||||
line: line.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 _ = app.emit("ai-chat-event", ev.clone());
|
||||||
let _ = self.app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -867,13 +867,16 @@ defineExpose({
|
|||||||
@click="toggleJson(item.msg.id)"
|
@click="toggleJson(item.msg.id)"
|
||||||
><pre>{{ item.msg.content }}</pre></div>
|
><pre>{{ item.msg.content }}</pre></div>
|
||||||
<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 ai-msg-bubble--ai ai-md"
|
||||||
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
|
: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="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||||
>
|
>
|
||||||
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建) -->
|
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
|
||||||
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
|
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>
|
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
let rafId: number | null = null
|
let rafId: number | null = null
|
||||||
let lastStreamText = ''
|
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 字符串)整体一块
|
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
|
||||||
/// (跨双换行不切),非代码段按双换行切段。
|
/// (跨双换行不切),非代码段按双换行切段。
|
||||||
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||||
@@ -124,17 +138,46 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
|
/// 流式渲染:增量切块 + 块级 memo(前块缓存/末块重 parse)
|
||||||
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
|
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
|
||||||
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
|
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
|
||||||
|
///
|
||||||
|
/// AC-EFF-S1-1 增量:追加场景复用上一帧已完成块,只对「旧末块+增长段」增量 lexer,
|
||||||
|
/// 不再每帧全文 lexer;单块回复(整回复无块边界)超阈值降级纯文本防 O(n) parse 卡顿。
|
||||||
function renderStreamingBlocks(text: string): StreamBlock[] {
|
function renderStreamingBlocks(text: string): StreamBlock[] {
|
||||||
if (!text) return []
|
if (!text) return []
|
||||||
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
||||||
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
||||||
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
||||||
|
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
|
||||||
return [{ html: escapeFallback(text), key: 'fallback' }]
|
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
|
const n = blocks.length
|
||||||
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
|
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
|
||||||
return blocks.map((b, i) => {
|
return blocks.map((b, i) => {
|
||||||
@@ -144,7 +187,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
|
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
|
||||||
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
|
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
|
||||||
const degrade = isTail && isUnclosedCodeFence(b)
|
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)
|
? escapeFallback(b)
|
||||||
: isTail ? parseBlockNoCache(b) : parseBlock(b)
|
: isTail ? parseBlockNoCache(b) : parseBlock(b)
|
||||||
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
|
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
|
||||||
@@ -194,11 +240,15 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
|
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
|
||||||
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。
|
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线。
|
||||||
function clearStreamingState(): void {
|
function clearStreamingState(): void {
|
||||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||||
streamingBlocks.value = []
|
streamingBlocks.value = []
|
||||||
lastStreamText = ''
|
lastStreamText = ''
|
||||||
|
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。
|
||||||
|
_prevStreamText = ''
|
||||||
|
_prevCompleted = []
|
||||||
|
_prevTail = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
|
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
|
||||||
@@ -209,8 +259,12 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
|
|
||||||
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
|
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
|
||||||
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
|
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
|
||||||
|
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
|
||||||
function resetStreamCache(): void {
|
function resetStreamCache(): void {
|
||||||
_blockCache.clear()
|
_blockCache.clear()
|
||||||
|
_prevStreamText = ''
|
||||||
|
_prevCompleted = []
|
||||||
|
_prevTail = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user