重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进

This commit is contained in:
lxy
2026-07-31 21:36:12 +08:00
parent 365af554da
commit bd9031d35d
73 changed files with 1421 additions and 1064 deletions
+9
View File
@@ -10,3 +10,12 @@
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]
# ── 主机编译加速(Windows MSVC) ────────────────────────────
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
# 并行编译单元,留部分核心给 Zed UI 和系统
[build]
jobs = 8
+134 -17
View File
@@ -1,41 +1,71 @@
{
// 项目级 Zed 配置:优化 rust-analyzer 性能
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
//
// 背景:本 workspace src-tauri + 7 个 df-* crate,
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染冻结 UI
// 核心策略:关保存即 check、限定 check 范围、隔离 target 目录。
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
// flcheck 单次 600+ 行 artifact JSONZed 解析+渲染冻结 UI
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
// ─────────────────────────────────────────────────────────────────
"lsp": {
"rust-analyzer": {
"initialization_options": {
// ── cargo 编译参数 ───────────────────────────────────
"cargo": {
"features": [], // 不编 default features 之外的重头
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
"targetDir": null, // 留空用 cargo 默认,避免双写
"features": [],
"allTargets": false, // 只为 host target 解析
"noDefaultFeatures": false,
"targetDir": null,
"buildScripts": {
"enable": true,
"invocationStrategy": "once", // 只跑一次,不反复
"rerun": "on-save"
}
},
"extraArgs": []
},
// ── check / flycheck ────────────────────────────────
"check": {
"onSave": false, // ★ 关键!关保存即 check(Zed 卡死主因)
"workspace": false, // 即便手动 check 也只查当前 crate
"command": "check", // 用 cargo check(非 clippy),首次开销低
"onSave": false, // ★ 核心!关保存即 check
"workspace": false, // 手动 check 也只查当前 crate
"command": "check", // 用 check,非 clippy
"invocationStrategy": "once", // 只跑一轮,不反复
"features": [],
"allTargets": false
"allTargets": false,
"ignore": [ // 跳过不常用的目录
"benches",
"examples",
"xtask",
"tests"
]
},
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
"cachePriming": {
"enable": false
},
// ── 诊断 ────────────────────────────────────────────
"diagnostics": {
"enable": true, // 保留 rust-analyzer 自身诊断
"enable": true,
"experimental": {
"enable": false
}
},
"disabled": [ // 关掉非关键诊断,降 CPU
"unresolved-proc-macro",
"inactive-code",
"macro-error"
]
},
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
"procMacro": {
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
"enable": true,
"attributes": {
"enable": true
}
},
// ── 符号检索 ────────────────────────────────────────
"workspace": {
"symbol": {
"search": {
@@ -44,21 +74,108 @@
}
}
},
// ── 补全(限流减少 LSP 计算量) ──────────────────────
"completion": {
"autoclose": true,
"autoimport": {
"enable": true
},
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
},
"limit": 200, // 补全条目上限
"postfix": {
"enable": false // 关 postfix snippet 减少候选
}
},
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
"inlayHints": {
"bindingModeHints": {
"enable": false
},
"chainingHints": {
"enable": false
},
"closingBraceHints": {
"enable": false
},
"closureReturnTypeHints": {
"enable": "never"
},
"constructorHints": "never",
"discriminantHints": {
"enable": "never"
},
"expressionAdjustmentHints": {
"mode": "never"
},
"implicitDrops": "never",
"lifetimeElisionHints": {
"enable": "skip_trivial"
},
"maxLength": 100,
"parameterHints": {
"enable": true
},
"reborrowHints": {
"enable": "never"
},
"renderColons": true,
"typeHints": {
"enable": true,
"hideNamedConstructor": false
}
},
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
"semanticHighlights": {
"strings": {
"enable": false
}
},
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
"highlightRelated": {
"references": {
"enable": false
}
},
// ── 测试解释器(关掉) ───────────────────────────────
"interpret": {
"tests": false
},
// ── rust-analyzer 文件扫描排除 ─────────────────────
"files": {
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
"excludeDirs": [
".cargo",
"target",
"target-musl",
"node_modules",
"dist",
".zed",
".git"
]
}
}
}
}
},
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
"file_scan_exclusions": [
"**/target/**",
"**/target-musl/**",
"**/node_modules/**",
"**/dist/**",
"**/.cargo/**",
"**/.git/**",
"**/build/**"
]
}
+5 -5
View File
@@ -1,4 +1,4 @@
//! 模型能力数据模型 — F-01 阶段1
//! 模型能力数据模型
//!
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
@@ -45,9 +45,9 @@ pub enum Capability {
///
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// B-260618-05 删除 Free 死档变体。
/// Free 死档变体已删除
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -63,7 +63,7 @@ pub enum CostTier {
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -302,7 +302,7 @@ mod tests {
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
}
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High);
}
+8 -9
View File
@@ -90,8 +90,8 @@ impl ChatMessage {
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
///
/// 正面白名单F-15 §3.2:仅认 None / "active",新状态
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
/// 正面白名单:仅认 None / "active",新状态
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool {
@@ -155,10 +155,9 @@ mod tests {
#[test]
fn is_active_whitelist() {
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
// matches! 只认 None/active 自动落入 false,面向未来验证。
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
// None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi");
@@ -174,18 +173,18 @@ mod tests {
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离
// "archived_segment" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 阶段2 待引入,白名单自动隔离
// "compressed" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
}
// ---------- F-260614-05 Phase 2a ContentPart ----------
// ---------- ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test]
@@ -333,7 +332,7 @@ mod tests {
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
}
// ---------- F-260619-04 消息级溯源:id 字段 ----------
// ---------- 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test]
+3 -3
View File
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
///
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
@@ -79,13 +79,13 @@ pub enum ContentPart {
pub struct ChatMessage {
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: MessageRole,
pub content: String,
/// 多模态内容片F-260614-05 Phase 2a
/// 多模态内容片。
///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
+95 -76
View File
@@ -137,11 +137,11 @@ impl AnthropicCompatProvider {
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocksText 片 → {type:text}
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
// 现状:前端Phase2b只产 base64 模式图片片,url 模式当前不可达。
// 现状:前端只产 base64 模式图片片,url 模式当前不可达。
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
//provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
@@ -149,36 +149,7 @@ impl AnthropicCompatProvider {
let blocks: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 datacommands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
})
.map(Self::content_part_to_block)
.collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} else {
@@ -193,7 +164,7 @@ impl AnthropicCompatProvider {
}
if let Some(calls) = &m.tool_calls {
for tc in calls {
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
@@ -215,13 +186,13 @@ impl AnthropicCompatProvider {
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages);
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
@@ -237,7 +208,7 @@ impl AnthropicCompatProvider {
.map(|d| AnthropicToolDef {
name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()),
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
@@ -260,6 +231,43 @@ impl AnthropicCompatProvider {
}
}
/// 单个 ContentPart → Anthropic content blocktext/image)。
/// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 datacommands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
}
}
/// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
@@ -272,7 +280,7 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
}
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
@@ -305,7 +313,7 @@ impl AnthropicCompatProvider {
}
}
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
/// 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
///
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
@@ -342,7 +350,7 @@ impl AnthropicCompatProvider {
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
/// 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String {
let lines: Vec<String> = messages
.iter()
@@ -385,7 +393,7 @@ impl AnthropicCompatProvider {
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
@@ -415,25 +423,7 @@ impl AnthropicCompatProvider {
return Err(format!("#{} user content 空数组", i));
}
for b in blocks {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", i));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
i, tid
));
}
}
_ => {}
}
Self::check_block(b, i, &mut tool_use_ids)?;
}
}
_ => {}
@@ -442,6 +432,36 @@ impl AnthropicCompatProvider {
Ok(())
}
/// 校验单个 content blockprecheck_messages 内部用)。
/// - tool_use: 收集 id,校验 input 为 object
/// - tool_result: 校验 tool_use_id 有前置 tool_use(非 orphan
fn check_block<'a>(
b: &'a serde_json::Value,
idx: usize,
tool_use_ids: &mut Vec<&'a str>,
) -> Result<(), String> {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", idx));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
idx, tid
));
}
}
_ => {}
}
Ok(())
}
/// 统一鉴权头:x-api-key + anthropic-version
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
rb.header("x-api-key", &self.api_key)
@@ -457,7 +477,7 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = false;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
@@ -466,9 +486,8 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang)
// 此处加 60s timeout,与 OpenAI 路径对齐。
// 指数退避重试: 包裹 send + 状态码判定。
// 同时补单请求 timeout(Anthropic 同步路径 timeout 会 hang),此处加 60s,与 OpenAI 路径对齐。
let label = format!("Anthropic[{}]", body.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
@@ -488,7 +507,7 @@ impl LlmProvider for AnthropicCompatProvider {
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
@@ -580,7 +599,7 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = true;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
@@ -589,9 +608,9 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
// 流式长生成任务会被误砍。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self
@@ -638,9 +657,9 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
// 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
// MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
let messages_summary = Self::summarize_messages(&body.messages);
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
@@ -870,7 +889,7 @@ mod tests {
assert!(acc.is_none());
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
// ---------- 多模态 convert_request ----------
/// 含图 user 消息 → content blockstext + image source.base64
#[test]
@@ -931,7 +950,7 @@ mod tests {
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
}
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
@@ -941,7 +960,7 @@ mod tests {
)
}
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 精确复现线上场景——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
@@ -981,7 +1000,7 @@ mod tests {
);
}
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
@@ -1007,7 +1026,7 @@ mod tests {
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
/// 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
@@ -1033,7 +1052,7 @@ mod tests {
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
@@ -1070,7 +1089,7 @@ mod tests {
);
}
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
+3 -3
View File
@@ -20,7 +20,7 @@ fn cfg(max_tokens: u32) -> ContextConfig {
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
@@ -263,7 +263,7 @@ fn system_over_budget_trims_to_protect_zone() {
);
}
// ── F-15 阶段1 辅助方法单测 ──
// ── 辅助方法单测 ──
#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
@@ -430,7 +430,7 @@ fn build_eviction_units_keeps_triplet_atomic_public() {
);
}
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
// ── 消息级溯源:last_assistant/last_user message_id ──
#[test]
fn last_assistant_message_id_returns_latest() {
+9 -9
View File
@@ -165,7 +165,7 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
@@ -191,7 +191,7 @@ impl ContextManager {
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
@@ -214,7 +214,7 @@ impl ContextManager {
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
true,
@@ -350,7 +350,7 @@ impl ContextManager {
self.messages.iter().map(|t| &t.message)
}
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
@@ -417,7 +417,7 @@ impl ContextManager {
&self.config
}
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
@@ -427,7 +427,7 @@ impl ContextManager {
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
@@ -449,7 +449,7 @@ impl ContextManager {
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
@@ -488,7 +488,7 @@ impl ContextManager {
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
@@ -498,7 +498,7 @@ impl ContextManager {
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
+3 -3
View File
@@ -151,7 +151,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitized
};
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
// step 3.5(占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
//
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
@@ -227,7 +227,7 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
filtered
}
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
///
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
@@ -470,7 +470,7 @@ mod tests {
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
#[test]
fn sanitize_drops_reverse_orphan_tool_result() {
+10 -10
View File
@@ -47,7 +47,7 @@ impl Default for TokenEstimator {
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
@@ -58,7 +58,7 @@ impl TokenEstimator {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Image { base64, url, .. } => {
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 {
@@ -145,7 +145,7 @@ pub enum MessageGroup {
/// 带有 token 缓存和分组信息的消息条目
///
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
pub struct TrackedMessage {
@@ -408,7 +408,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
*field = serde_json::Value::String(out.join("\n"));
truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
@@ -439,7 +439,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary {
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。
let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT {
@@ -524,7 +524,7 @@ fn is_error_line(line: &str) -> bool {
/// 淘汰单元:连续消息范围 [..end) + token 总和
///
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
pub struct EvictionUnit {
pub end: usize,
pub token_sum: u32,
@@ -541,7 +541,7 @@ pub const PROTECT_COUNT: usize = 6;
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
/// 占位配对完整性(解 400 orphan)常量开关。
///
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
@@ -1118,7 +1118,7 @@ mod tests {
#[test]
fn extract_key_info_single_huge_line_no_newline_compressed() {
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
@@ -1130,7 +1130,7 @@ mod tests {
#[test]
fn extract_key_info_json_huge_string_field_truncated() {
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
@@ -1243,7 +1243,7 @@ mod tests {
assert!(toks.contains(&"now".to_string()));
}
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
#[test]
fn is_pending_placeholder_new_with_marker() {
+2 -2
View File
@@ -38,9 +38,9 @@ pub mod persona;
pub mod plan_executor;
pub mod provider;
pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
+1 -1
View File
@@ -1,4 +1,4 @@
//! 厂商模型列表拉取 — F-01 阶段3
//! 厂商模型列表拉取
//!
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
+4 -4
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — F-01 阶段2
//! 模型探测器
//!
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
//!
@@ -100,7 +100,7 @@ mod tests {
assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
// B-260618-04:预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
@@ -177,7 +177,7 @@ mod tests {
fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
// B-260618-04:cost/intel 一律中性,不靠名字猜
// cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
@@ -261,7 +261,7 @@ mod tests {
#[test]
fn probe_preset_beats_heuristic() {
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
+3 -3
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
//! 模型探测器 — 纯逻辑子模块
//!
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
///
@@ -49,7 +49,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new();
// 中性默认:不靠模型名猜档位(B-260618-04)
// 中性默认:不靠模型名猜档位
let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
+4 -1
View File
@@ -220,7 +220,10 @@ mod tests {
let p1 = ns.store("tool_a", content);
let p2 = ns.store("tool_b", content);
assert_eq!(ns.len(), 1, "相同内容应去重");
assert_eq!(ns.read(&p1), ns.read(&p2));
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
let r1 = ns.read(&p1).map(str::to_owned);
let r2 = ns.read(&p2).map(str::to_owned);
assert_eq!(r1, r2);
}
#[test]
+11 -11
View File
@@ -114,7 +114,7 @@ impl OpenAICompatProvider {
crate::provider::MessageRole::Assistant => "assistant",
crate::provider::MessageRole::Tool => "tool",
};
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URIbase64)与 http(s) URL。
let content = if m.has_image() {
@@ -176,7 +176,7 @@ impl OpenAICompatProvider {
})
.collect();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
@@ -231,7 +231,7 @@ impl OpenAICompatProvider {
}
}
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
/// 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
@@ -307,8 +307,8 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变(FR-R4)重试是额外层: 3 次 × 60s 最坏 180s
// 指数退避重试: 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变,重试是额外层: 3 次 × 60s 最坏 180s,
// 由 retry_with_backoff 内部 30s 总预算主动止损。
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
@@ -386,7 +386,7 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用");
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self
.client
@@ -414,7 +414,7 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
// 原生 SSE 解析器替代 eventsource-stream 库。
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
@@ -616,7 +616,7 @@ mod tests {
assert!(!c.finished);
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
// ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
#[test]
@@ -672,9 +672,9 @@ mod tests {
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic----------
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
@@ -699,7 +699,7 @@ mod tests {
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
/// 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
+3 -3
View File
@@ -20,7 +20,7 @@ use std::time::Duration;
use rand::Rng;
use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
///
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
@@ -71,8 +71,8 @@ pub fn is_status_retryable(status: u16) -> bool {
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter")避免重写退避逻辑。
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// ±50% jitter,相对 base 时长的浮动比例
+14 -17
View File
@@ -1,26 +1,23 @@
//! 模型路由器 — F-01 阶段4
//! 模型路由器
//!
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
//! ModelRouter 为单元结构,select 是无状态关联函数
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(3 维度)。
///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
@@ -49,7 +46,7 @@ impl ModelRouter {
/// 4. 窗口够大 — context_window >= estimated_context
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
///
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
@@ -63,7 +60,7 @@ impl ModelRouter {
}
}
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
///
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
@@ -106,7 +103,7 @@ mod tests {
}
}
// ── 阶段5 select_model_id helper ──
// ── select_model_id helper ──
#[test]
fn select_model_id_empty_pool_returns_none() {
@@ -248,7 +245,7 @@ mod tests {
);
}
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
#[test]
fn context_window_insufficient() {
@@ -316,13 +313,13 @@ mod tests {
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
//
// 候选:
// a: weight 60 → 通过全部过滤,key=60
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
+1 -1
View File
@@ -1,6 +1,6 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
+1 -1
View File
@@ -51,7 +51,7 @@ impl EnvSnapshot {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
// probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
+3 -3
View File
@@ -33,8 +33,8 @@ pub enum ShellType {
impl Default for ShellType {
fn default() -> Self {
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
@@ -146,7 +146,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
cmd.kill_on_drop(true);
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
#[cfg(windows)]
+5 -5
View File
@@ -7,7 +7,7 @@
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider/ `HeuristicFallback`LLM 调用失败降级)。前端可据此显示评估深度标签。
//!
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
//!
@@ -42,7 +42,7 @@ pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>,
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>,
@@ -54,7 +54,7 @@ impl AdversarialEngine {
Self { provider: Some(provider), model_pool: Vec::new() }
}
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
/// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool }
}
@@ -101,7 +101,7 @@ impl AdversarialEngine {
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea);
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements {
@@ -421,7 +421,7 @@ mod tests {
}
// ────────────────────────────────────────────────────────────
// LLM 路径测试F-260614-03
// LLM 路径测试
// ────────────────────────────────────────────────────────────
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
+1 -1
View File
@@ -84,7 +84,7 @@ pub enum Recommendation {
}
// ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析F-260614-03
// LLM 对抗评估 — prompt 构造 / JSON 解析
// ============================================================
/// LLM 角色 / 输出契约的系统级约束。
+4 -4
View File
@@ -45,7 +45,7 @@ impl Node for AiNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
@@ -117,12 +117,12 @@ impl Node for AiNode {
"properties": {
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
"provider_id": { "type": "string", "description": "AI Provider IDFR-S1密钥经 df_storage::secret 解析,不进 config;留空走默认 provider" },
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 df_storage::secret 解析,不进 config;留空走默认 provider" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model" },
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096" },
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_idFR-S1 下经 secret 解析" }
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id密钥经 secret 解析" }
},
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
"required": []
@@ -231,7 +231,7 @@ mod tests {
}
// ============================================================
// resolve_provider 双路径测试(FR-S1 注入链核心)
// resolve_provider 双路径测试(注入链核心)
// ============================================================
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
+10 -10
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use df_ai::df_ai_core::model::{Modality, ModelConfig};
use df_ai::provider::LlmProvider;
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
use df_ai::router::{select_model_id, TaskRequirements};
use df_storage::crud::AiProviderRepo;
@@ -22,7 +22,7 @@ use df_workflow::node::{NodeOutput};
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
///
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 configFR-S1**。
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config**。
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
#[derive(Debug)]
pub(crate) struct AiNodeParams {
@@ -37,7 +37,7 @@ pub(crate) struct AiNodeParams {
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
///
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
/// api_key 字段:明文,全程不出 AiNode 进程内存(不进 config/output/schema)。
#[derive(Debug, Clone)]
pub(crate) struct ResolvedProvider {
/// 协议类型:openai_compat(默认)/ anthropicGLM 订阅 / Claude 官方)— 从 record.provider_type 映射
@@ -47,12 +47,12 @@ pub(crate) struct ResolvedProvider {
pub api_key: String,
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
pub default_model: String,
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
/// 候选模型池(来自 record.model_configs)。parse_params 路由用:
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
pub model_pool: Vec<ModelConfig>,
}
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(注入链核心)。
///
/// 三路径(优先级从高到低):
/// 1. **provider_id 优先**config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
@@ -114,7 +114,7 @@ pub(crate) async fn resolve_provider(
let plain_key = config.get("api_key").and_then(|v| v.as_str());
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
tracing::warn!(
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id. \
"
);
let base_url = base_url_str.to_string();
@@ -201,7 +201,7 @@ pub(crate) fn parse_params(
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: promptconfig 或上游输入均无)"))?;
// ── 可选参数 ──
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
// model 解析优先级:config.model 显式指定 > 路由选优(provider.model_pool 非空时)
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
@@ -213,7 +213,7 @@ pub(crate) fn parse_params(
let model = if !config_model.is_empty() {
config_model
} else {
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
let node_req = TaskRequirements {
@@ -289,10 +289,10 @@ pub(crate) fn truncate_for_summary(s: &str) -> String {
format!("{truncated}")
}
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
/// 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
///
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
/// 与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
gate && verdict == "fail"
}
+8 -8
View File
@@ -74,7 +74,7 @@ impl Node for AiSelfReviewNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
// ── 读任务(需求 + 产出) ──
@@ -195,7 +195,7 @@ impl Node for AiSelfReviewNode {
"model": response.model,
}));
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
// ── 自审闸门 ──
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
@@ -214,7 +214,7 @@ impl Node for AiSelfReviewNode {
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
// config 开关(默认 false = 辅助模式行为不变,模板/前端零强制改动,向后兼容)。
let gate_enabled = ctx
.config
.get("gate")
@@ -241,10 +241,10 @@ impl Node for AiSelfReviewNode {
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 secret 解析不进 config;留空走默认 provider)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
},
"required": ["task_id", "provider_id"]
}),
@@ -411,18 +411,18 @@ mod tests {
}
// ============================================================
// 阶段3 自审闸门(gate_should_block)单测
// 自审闸门(gate_should_block)单测
// ============================================================
//
// gate 决策矩阵:
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
// gate=false(默认,辅助模式行为) → 任何 verdict 都放行(辅助模式)
// gate=true + verdict=pass → 放行
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
#[test]
fn gate_disabled_never_blocks_any_verdict() {
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
// gate 默认 false:无论 verdict 如何都不阻断,自审仅辅助展示
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
+27 -25
View File
@@ -40,7 +40,7 @@ impl Node for HumanNode {
.and_then(|v| v.as_u64())
.unwrap_or(3600);
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
// 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
Some("multiple") => SelectType::Multiple,
_ => SelectType::Single,
@@ -74,7 +74,7 @@ impl Node for HumanNode {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, decisions, comment,
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// select_type=Single → 决策数必须 =1
// select_type=Multiple → 决策数必须 ≥1
// options 空 → 允许自由文本(仅受数量约束);
@@ -91,24 +91,26 @@ impl Node for HumanNode {
let each_valid = picked.iter().all(|d| !d.is_empty())
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
if count_ok && each_valid {
// F-260616-06 阶段2: 拒绝语义化。
// 拒绝语义化。
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
// 下游无法据 failed 触发退回/重做。
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
// → 工作流 failed 状态 → 推进链可据 failed 触发退回。
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
if contains_reject(&picked) {
let primary = picked.first().cloned().unwrap_or_default();
let comment_str = comment.unwrap_or_default();
// 意见后缀:空 comment 不拼接,非空才追加(避免空括号)
let suffix = if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
};
return Err(anyhow::anyhow!(
"人工审批被拒绝(用户选择: {}){}",
primary,
if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
}
suffix,
));
}
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
@@ -225,7 +227,7 @@ mod tests {
}
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
/// 单选调用方仅填 decision;多选调用方填 decisions。
async fn send_response(
event_bus: &EventBus,
execution_id: &str,
@@ -244,7 +246,7 @@ mod tests {
.await;
}
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
/// 多选响应发送助手(填 decisions 数组,decision 留空)
async fn send_response_multi(
event_bus: &EventBus,
execution_id: &str,
@@ -568,9 +570,9 @@ mod tests {
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
}
// ===== F-260615-01: 多选审批覆盖 =====
// ===== 多选审批覆盖 =====
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
/// select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
#[tokio::test]
async fn multiple_select_returns_decisions_array() {
let bus = EventBus::new();
@@ -596,7 +598,7 @@ mod tests {
assert_eq!(out.data["comment"], json!("多选"));
}
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
/// select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
#[tokio::test]
async fn single_select_rejects_multiple_decisions_then_timeout() {
let bus = EventBus::new();
@@ -618,7 +620,7 @@ mod tests {
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
/// select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
#[tokio::test]
async fn multiple_select_invalid_option_ignored_then_timeout() {
let bus = EventBus::new();
@@ -644,7 +646,7 @@ mod tests {
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
#[tokio::test]
async fn default_single_with_legacy_decision_single_value() {
@@ -664,10 +666,10 @@ mod tests {
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
}
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
// ===== 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
/// 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
/// 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
#[tokio::test]
async fn reject_decision_returns_error() {
let bus = EventBus::new();
@@ -697,7 +699,7 @@ mod tests {
);
}
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
/// 同一 options 下选「同意」→ Ok(通过路径不回归)。
#[tokio::test]
async fn approve_decision_still_ok() {
let bus = EventBus::new();
@@ -718,7 +720,7 @@ mod tests {
assert_eq!(out.data["decision"], json!("同意"));
}
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
/// 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
#[tokio::test]
async fn english_reject_keyword_returns_error() {
@@ -744,7 +746,7 @@ mod tests {
);
}
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// (选了「驳回」即驳回,即便同时选了「同意」)。
#[tokio::test]
async fn multiple_select_with_one_reject_returns_error() {
@@ -776,7 +778,7 @@ mod tests {
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
#[tokio::test]
async fn empty_options_free_text_reject_keyword_still_errors() {
@@ -793,7 +795,7 @@ mod tests {
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
/// options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
/// empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
#[tokio::test]
async fn empty_options_non_reject_free_text_still_ok() {
@@ -810,7 +812,7 @@ mod tests {
assert_eq!(out.data["decision"], json!("再讨论一下"));
}
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
/// 单元: 关键字判定函数归一化(去空白+小写)与边界。
#[test]
fn reject_keyword_detection_normalized() {
assert!(is_reject_decision("拒绝"));
+2 -2
View File
@@ -3,9 +3,9 @@
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
//!
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
//! 拒绝语义化保留(executor set_failed → 推进链退回)。
/// F-260616-06 阶段2: 拒绝语义化关键字。
/// 拒绝语义化关键字。
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
///
/// 识别范围(避免误伤):
+1 -1
View File
@@ -1,4 +1,4 @@
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
//! 任务推进节点 — advance_task 推进链触发器
//!
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
//! 1. 读当前 TaskRecord(取 from status)
+3 -3
View File
@@ -1,8 +1,8 @@
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
//! 任务推进状态机 — 7 态合法转换定义
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
//! (原子写 SQL 在 task_advance_node.rs 完成)。
//!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
@@ -124,7 +124,7 @@ pub fn is_regression(from: &str, to: &str) -> bool {
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
}
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)
/// 工作流联动任务「失败退一步」的目标态映射。
///
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
/// - testing → in_review(测试失败退回重审)
@@ -1,4 +1,4 @@
//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03)
//! 任务推进链 DAG 模板
//!
//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边,
//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。
@@ -51,7 +51,7 @@ fn in_progress_template() -> DagDef {
/// in_review → testing:AiNode 自审 → HumanNode 核对。
///
/// 拓扑:ai → human 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
/// 拓扑:ai → human 串行。ai_self_review 启用 gate:true:
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
@@ -59,9 +59,9 @@ fn in_progress_template() -> DagDef {
/// 通过则完成回调(②-3)推进 status 到 testing。
fn testing_template() -> DagDef {
let mut dag = DagDef::new();
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// 决策:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
// gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
dag.add_node(
"ai_self_review",
"ai_self_review",
@@ -135,13 +135,13 @@ mod tests {
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
assert_eq!(ai.node_type, "ai_self_review");
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
// gate=true 启用自审闸门(verdict=fail 阻断下游)
let gate = ai
.config
.get("gate")
.and_then(|v| v.as_bool())
.expect("ai_self_review config 应含 gate");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(闸门)");
let human = dag.nodes.get("human_review").expect("human_review 存在");
assert_eq!(human.node_type, "human");
// 边方向:ai → human
+1 -1
View File
@@ -7,7 +7,7 @@
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
/// 当前 ChatMessage.content:String(多模态未做)走纯文本降级,
/// 此结构仅为采样层留接口,不读 base64。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
@@ -53,7 +53,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
config: row.get("config")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
// F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
@@ -95,7 +95,7 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
Ok(AiToolExecutionRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
// F-260619-04:message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
message_id: row.get("message_id").unwrap_or(None),
tool_call_id: row.get("tool_call_id")?,
@@ -124,7 +124,7 @@ impl_repo!(
let is_default = if rec.is_default { 1i32 } else { 0i32 };
// model_configs:Vec<ModelConfig> → JSON 字符串落 TEXT 列
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
// F-260614-04: enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
// enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
let enabled_i = if rec.enabled { 1i32 } else { 0i32 };
let weight_i = rec.weight.min(100) as i32;
conn.execute(
@@ -420,7 +420,7 @@ mod tests {
use crate::models::AiProviderRecord;
use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig};
/// model_configs DB roundtrip + 老库空兼容(F-01 阶段1)
/// model_configs DB roundtrip + 老库空兼容
#[tokio::test]
async fn ai_provider_model_configs_roundtrip_and_old_db_compat() {
let db = Database::open_in_memory().await.expect("open_in_memory");
+5 -5
View File
@@ -20,7 +20,7 @@ use super::{now_millis_str, storage_err, validate_column_name};
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口:集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
///
@@ -38,7 +38,7 @@ const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 同 KNOWLEDGE_COLS 的列漂移防护(CR-260615-03):idea 表 INSERT/UPDATE/from_row 三处
/// 同 KNOWLEDGE_COLS 的列漂移防护:idea 表 INSERT/UPDATE/from_row 三处
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
@@ -147,7 +147,7 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
// ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
// IdeaQuery — 多条件查询入参(status 下沉 + 关键词 + 排序 + 分页)
// ============================================================
/// 灵感多条件查询入参。
@@ -256,7 +256,7 @@ impl_repo!(
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页。
///
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
@@ -925,7 +925,7 @@ mod tests {
use super::*;
use crate::db::Database;
// ---------- COLS 漂移防护(CR-260615-03) ----------
// ---------- COLS 漂移防护 ----------
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
+2 -3
View File
@@ -1,4 +1,4 @@
//! AI 消息 Repo — ai_messages 表(F-260619-03 消息拆分存储)
//! AI 消息 Repo — ai_messages 表(消息拆分存储)
//!
//! 每条 ChatMessage 一行的独立表,替代 `ai_conversations.messages` 整对话 JSON 列存。
//! 全专用方法(insert_batch / list_by_conversation / delete_range / update_status /
@@ -239,7 +239,6 @@ impl AiMessageRepo {
/// 全量重写对话的消息(单事务 DELETE + INSERT OR IGNORE,原子)。
///
/// F-260619-03 批次 B(save_conversation 写路径切 ai_messages)的核心方法:
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
///
@@ -472,7 +471,7 @@ mod tests {
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
}
// ---------- replace_conversation(F-260619-03 批次 B)----------
// ---------- replace_conversation ----------
/// replace_conversation 全量重写:删旧 + 插新原子,list 一致
#[tokio::test]
+1 -1
View File
@@ -12,7 +12,7 @@
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
+2 -2
View File
@@ -17,7 +17,7 @@ use super::impl_repo;
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
// ============================================================
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
// 项目查询入参(P2/P3 查询维度补全)
// ============================================================
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
@@ -204,7 +204,7 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
///
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
+7 -7
View File
@@ -119,8 +119,8 @@ impl SettingsRepo {
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table {
"ideas" => &[
// IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间)
// id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (对标 tasks 白名单同款防护,防篡改主键/伪造创建时间)
"title", "description", "status", "priority", "score", "tags", "source",
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
],
@@ -141,15 +141,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。
"project_id", "title", "description", "priority", "branch_name",
"assignee",
// workflow_def_id / base_branch: 预留字段,阶段4 Git/workflow_defs 集成前无写入路径。
// workflow_def_id / base_branch: 预留字段,Git/workflow_defs 集成前无写入路径。
// 当前推进链用硬编码三模板(task_workflow_templates.rs,不建 workflow_defs 表,
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为阶段4 预留 +
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为预留 +
// 允许手动/未来填充,勿判死代码删除。base_branch 同理(code kind 闸门接 git 前预留)。
"workflow_def_id", "base_branch",
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
"output_json",
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// idea_id(任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
"idea_id",
// 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。
@@ -160,7 +160,7 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// 走专用方法 set_status_for_aggregation,不经通用 update_field。
"queue", "parent_id", "content_json",
"updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// TODO: project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
],
@@ -218,7 +218,7 @@ pub(crate) fn validate_column_name(field: &str, table: &str) -> Result<()> {
match allowed_columns_for(table) {
Some(cols) if cols.contains(&field) => Ok(()),
Some(_) => Err(Error::Storage(format!("{} 不允许的字段名: {}", table, field))),
// 未登记表保守拒绝(FR-S6: 原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐)
// 未登记表保守拒绝:原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐
None => Err(Error::Storage(format!("{} 未登记列白名单,拒绝防注入", table))),
}
}
+4 -4
View File
@@ -47,7 +47,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
}
// ============================================================
// 任务列表查询入参(F-260621-02 查询维度补全)
// 任务列表查询入参(查询维度补全)
// ============================================================
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
@@ -213,7 +213,7 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
@@ -296,7 +296,7 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
/// 动态条件列出未删除任务(查询维度补全)。
///
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
@@ -528,7 +528,7 @@ impl TaskRepo {
///
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标跨表校验下沉思路)。
pub async fn count_children_by_status(
&self,
parent_id: &str,
+17 -17
View File
@@ -23,12 +23,12 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id;
// V20 = 任务关联灵感 idea_id;V21 = 消息拆分存储 + audit message_id;
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
// V26 = 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
// V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
@@ -290,7 +290,7 @@ fn migrate_v14(conn: &Connection) -> Result<()> {
Ok(())
}
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04)
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数)
///
/// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1,
/// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。
@@ -343,7 +343,7 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
Ok(())
}
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1)
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置)
///
/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容:
/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。
@@ -358,7 +358,7 @@ fn migrate_v18(conn: &Connection) -> Result<()> {
Ok(())
}
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(F-260614-04 多 Provider 负载均衡池)
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(多 Provider 负载均衡池)
///
/// - `enabled INTEGER NOT NULL DEFAULT 1`:provider 是否进入负载均衡池。
/// 老库行迁移后默认 1(所有现存 provider 默认启用,单 provider 路径零变化)。
@@ -374,21 +374,21 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
"ALTER TABLE ai_providers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1",
[],
)?;
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池,F-260614-04)");
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池)");
}
if !column_exists(conn, "ai_providers", "weight") {
conn.execute(
"ALTER TABLE ai_providers ADD COLUMN weight INTEGER NOT NULL DEFAULT 50",
[],
)?;
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池,F-260614-04)");
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?;
tracing::info!("迁移 v19 完成");
Ok(())
}
/// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,1对1 单向)
/// V20:幂等补 tasks.idea_id 列(任务关联灵感,1对1 单向)
///
/// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式
/// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord
@@ -401,7 +401,7 @@ fn migrate_v20(conn: &Connection) -> Result<()> {
"ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)",
[],
)?;
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感,F-260619-01)");
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?;
tracing::info!("迁移 v20 完成");
@@ -433,7 +433,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
// 1. 建 ai_messages 表(IF NOT EXISTS 幂等)
conn.execute_batch(V21_SQL)?;
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit,F-260619-04)
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit)
// 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。
// 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。
let tool_exec_table_exists: bool = conn
@@ -564,7 +564,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
///
/// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea,
/// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝
/// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。
/// 与追加型审计语义一致(历史不改),不开放通用写路径。
fn migrate_v22(conn: &Connection) -> Result<()> {
conn.execute_batch(V22_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?;
@@ -661,7 +661,7 @@ fn migrate_v25(conn: &Connection) -> Result<()> {
Ok(())
}
/// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口)
/// V26:补建 tasks 表 priority/assignee 索引(索引缺口)
///
/// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee),
/// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status /
@@ -1219,7 +1219,7 @@ CREATE TABLE IF NOT EXISTS tasks (
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT,
assignee TEXT,
-- F-260619-01 (11 , projects.idea_id ) V20
-- (11 , projects.idea_id ) V20
idea_id TEXT REFERENCES ideas(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -1265,7 +1265,7 @@ CREATE TABLE IF NOT EXISTS node_executions (
--
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
-- V26 (F-260621-02 ):priority/assignee ,;
-- V26 ():priority/assignee ,;
-- migrate_v26 CREATE INDEX IF NOT EXISTS ,
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
@@ -1395,7 +1395,7 @@ CREATE TABLE IF NOT EXISTS ai_tool_executions (
decided_by TEXT
);
-- F-260619-03 : ChatMessage
-- : ChatMessage
-- V21 SQL (V21 ALTER,)
-- (V21_SQL )
CREATE TABLE IF NOT EXISTS ai_messages (
@@ -1451,7 +1451,7 @@ CREATE TABLE IF NOT EXISTS app_settings (
";
// ============================================================
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03)
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态)
// ============================================================
#[cfg(test)]
@@ -1673,7 +1673,7 @@ mod tests {
}
// ============================================================
// V20 迁移幂等安全(F-260619-01 任务关联灵感)
// V20 迁移幂等安全(任务关联灵感)
// ============================================================
/// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。
+7 -7
View File
@@ -94,7 +94,7 @@ pub struct TaskRecord {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub output_json: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。
/// 关联灵感 ID(1对1 单向,任务→灵感)。
/// 复用 projects.idea_id 模式(REFERENCES ideas(id) 外键),可空(任务可不关联灵感)。
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
@@ -291,7 +291,7 @@ pub struct AiProviderRecord {
pub base_url: String,
pub default_model: String,
pub models: Option<String>, // JSON array of model names
/// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。
/// 模型能力配置数组(4 维度 + 路由控制)。
/// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。
/// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。
#[serde(default, deserialize_with = "deserialize_model_configs")]
@@ -300,11 +300,11 @@ pub struct AiProviderRecord {
pub config: Option<String>, // JSON extra config
pub created_at: String,
pub updated_at: String,
/// provider 是否进入负载均衡池(F-260614-04)。false = 仅作为配置存在,不参与主链路由/选池。
/// provider 是否进入负载均衡池。false = 仅作为配置存在,不参与主链路由/选池。
/// 老库迁移默认 1(单 provider 路径零变化)。
#[serde(default = "default_enabled")]
pub enabled: bool,
/// provider 在负载均衡池中的选择权重(0-100,F-260614-04)。高权重优先被选为主;
/// provider 在负载均衡池中的选择权重(0-100)。高权重优先被选为主;
/// 同权重时退化近似轮询。老库迁移默认 50。
#[serde(default = "default_weight")]
pub weight: u32,
@@ -371,9 +371,9 @@ pub struct AiConversationRecord {
pub struct AiToolExecutionRecord {
pub id: String,
pub conversation_id: Option<String>,
/// 消息级溯源:工具调用所属的 ChatMessage.id(F-260619-04)
/// 消息级溯源:工具调用所属的 ChatMessage.id。
/// NULL = 老库行 / 消息级溯源未启用期的记录 / 无法关联的调用。
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入(P1 接入,P0 只建列)
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
pub tool_call_id: String,
@@ -387,7 +387,7 @@ pub struct AiToolExecutionRecord {
pub decided_by: Option<String>, // human/auto
}
/// 消息记录(ai_messages 表,F-260619-03 消息拆分存储)。
/// 消息记录(ai_messages 表,消息拆分存储)。
///
/// 每条 ChatMessage 一行,替代 `ai_conversations.messages` 的整对话 JSON 列存。
/// 主键 `id` = ChatMessage.id(构造时 ULID 风格生成),(conversation_id, seq) UNIQUE
+7 -7
View File
@@ -1,4 +1,4 @@
//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
//! api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
//!
//! **下沉层(方案 B,2026-06-16)**:原位于 `src-tauri/src/commands/ai/secret.rs`,
//! 下沉纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)到 df-storage,
@@ -67,7 +67,7 @@ fn write_failcounts(map: &HashMap<String, u32>) {
text.push('\n');
}
if let Err(e) = fs::write(failcount_path(), text) {
tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e);
tracing::debug!("[密钥迁移] 迁移失败计数文件写入失败(忽略): {}", e);
}
}
@@ -172,14 +172,14 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
let n = record_migration_fail(&p.id);
if n >= MIGRATION_FAIL_THRESHOLD {
tracing::warn!(
"[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
"[密钥迁移] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
:1) OS (Win Credential Manager / macOS Keychain);\
2) keyring ;3) ",
p.id, n
);
} else {
tracing::warn!(
"[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
"[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
p.id, n, MIGRATION_FAIL_THRESHOLD, e
);
}
@@ -188,14 +188,14 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
let pid = p.id.clone();
p.api_key.clear();
if let Err(e) = repo.insert(p).await {
tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
tracing::warn!("[密钥迁移] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
}
// 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
clear_migration_failcount(&pid);
migrated += 1;
}
if migrated > 0 {
tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated);
tracing::info!("[密钥迁移] {} 条 provider 密钥迁移至 OS keyring", migrated);
}
Ok(migrated)
}
@@ -241,7 +241,7 @@ mod tests {
#[test]
fn resolve_prefers_db_when_non_empty() {
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库)
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(兼容未迁移老库)
// 纯逻辑路径,不碰 OS keyring,CI 任意 OS 安全。
let rec = AiProviderRecord {
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
+1 -1
View File
@@ -84,7 +84,7 @@ pub trait TunnelClient: Send + Sync {
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
///
/// Phase3 阶段2(D2 全 19 变体透传):EventBus subscriber 把 AiChatEvent Value 经此方法
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
/// 后者保留作高频子集快捷方式(D5 强类型保留)。非阻塞入队,连接断开返 NotConnected。
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
+3 -3
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::types::{ExecutionId, NodeId};
/// 人工审批选择类型(F-260615-01)
/// 人工审批选择类型
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
/// - Multiple: 多选(decisions 数组)
///
@@ -100,7 +100,7 @@ pub enum WorkflowEvent {
title: String,
description: String,
options: Vec<String>,
/// F-260615-01: 选择类型,缺省 Single(向后兼容)
/// 选择类型,缺省 Single(向后兼容)
#[serde(default)]
select_type: SelectType,
},
@@ -109,7 +109,7 @@ pub enum WorkflowEvent {
execution_id: ExecutionId,
node_id: NodeId,
decision: String,
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
/// 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
#[serde(default)]
decisions: Vec<String>,
comment: Option<String>,
+2 -2
View File
@@ -6,7 +6,7 @@ use std::collections::HashMap;
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DagDef {
// BUG-260621-01: nodes/edges 加 #[serde(default)]。
// nodes/edges 加 #[serde(default)]。
// 前端 run_workflow 推进链传空 dag `{}`(设计意图:空 dag + target_status →
// workflow.rs:91 dag.nodes.is_empty() 进 template_for 自动选模板)。原无 default
// 时,`{}` 在 Tauri IPC 入口反序列化 DagDef 即报 `missing field nodes`,挡在选模板
@@ -91,7 +91,7 @@ impl Default for DagDef {
mod tests {
use super::*;
// BUG-260621-01: 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
// 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
// 成空 DagDef(非报 missing field),后续 workflow.rs:91 dag.nodes.is_empty()
// 进 template_for 选模板。nodes/edges 加 #[serde(default)] 前此用例会失败。
#[test]
+20 -20
View File
@@ -98,27 +98,27 @@ impl DagExecutor {
let mut any_edge_passed = false; // 任一入边放行(含无条件边)
if let Some(preds) = adjacency_in.get(node_id) {
for (pred_id, cond) in preds {
if let Some(out) = outputs.get(pred_id) {
if eval_conditions {
if let Some(cond) = cond {
has_any_cond_edge = true;
// 以 source output.data 为 context 求值;失败保守 false
let passed = ConditionEngine::evaluate(cond, &out.data)
.unwrap_or(false);
if passed {
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
} else {
// 无 condition 的入边:无条件放行(必收集)
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
} else {
// flag 关:condition 完全忽略,旧行为全收集
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
let Some(out) = outputs.get(pred_id) else {
continue;
};
// flag 关:condition 完全忽略,旧行为全收集;
// flag 开 + 无 condition 入边:无条件放行(必收集);
// flag 开 + 有 condition 入边:以 source output.data 为 context 求值,
// 失败保守 false(对齐引擎语义)。
let passed = match (eval_conditions, cond) {
(false, _) => true,
(true, None) => true,
(true, Some(c)) => {
ConditionEngine::evaluate(c, &out.data).unwrap_or(false)
}
};
if eval_conditions && cond.is_some() {
has_any_cond_edge = true;
}
if passed {
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
}
} else {
@@ -4,7 +4,7 @@
//!
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
//!
//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
@@ -17,11 +17,11 @@
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
//!
//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
//! 安全:复用 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 调用前后均复位)。
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)
//! messages 操作改 per_conv。
use std::sync::Arc;
@@ -51,7 +51,7 @@ const PROTECT_COUNT: usize = 6;
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true;
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
///
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
@@ -100,7 +100,7 @@ pub(super) async fn maybe_auto_compress(
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
let (active_msgs, lang) = {
let mut session = session_arc.lock().await;
@@ -168,7 +168,7 @@ pub(super) async fn maybe_auto_compress(
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
Ok(None) => {
@@ -216,7 +216,7 @@ pub(super) async fn maybe_auto_compress(
);
}
}
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
@@ -15,9 +15,9 @@
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
//!
//! # 收口后(批3+)
//! # 当前架构
//!
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。`generating` bool
//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
//!
@@ -52,7 +52,7 @@ use serde::{Deserialize, Serialize};
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
///
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
/// 序列化(`Serialize`/`Deserialize`):前端经事件总线读 enum 视图时使用(本批未接,
/// 预留,避免后续改动序列化兼容性)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -150,9 +150,7 @@ impl ConvState {
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
/// 故本方法返回 true——审批挂起不算「停止生成」。
///
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
/// 不再标 allow(dead_code)(有真实消费方)。
/// 已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,不再标 allow(dead_code)
pub fn is_active(self) -> bool {
matches!(self, ConvState::Generating | ConvState::Compressed)
}
@@ -162,8 +160,8 @@ impl ConvState {
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
///
/// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
/// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
/// chat 域入口拦截(ai_regenerate / ai_chat_send /
/// ai_chat_edit)已接入,作真实读侧方法消费。
pub fn can_accept_request(self) -> bool {
matches!(self, ConvState::Idle | ConvState::Error)
}
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
has_pending: bool,
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -1,4 +1,4 @@
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
//! 审批门控逻辑。
//!
//! 审批策略(方案 B:Persona 维度 + 扩展点):
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
@@ -88,12 +88,12 @@ pub(super) fn should_auto_for_persona(
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
}
/// AE-2025-04 检查会话信任命中。
/// 检查会话信任命中。
///
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
pub(super) async fn check_trust_hits(
draft: &ToolCallDraft,
@@ -118,7 +118,7 @@ pub(super) async fn check_trust_hits(
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
pub(super) async fn insert_pending_approval(
@@ -132,7 +132,7 @@ pub(super) async fn insert_pending_approval(
db: &Arc<Database>,
current_message_id: Option<&str>,
) {
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
// (路径 B):write_file 挂起审批前预读旧文件生成 diff。
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
@@ -142,7 +142,7 @@ pub(super) async fn insert_pending_approval(
None
};
// 阶段4容错/恢复,开关 df-ai-approval-retry:同 tc_id 重试检测。
// (容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
// High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
@@ -179,12 +179,12 @@ pub(super) async fn insert_pending_approval(
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
// 普通风险审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count,
created_at: Some(std::time::SystemTime::now()),
});
// 阶段2占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan
// 占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
}
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
@@ -203,7 +203,7 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// 阶段4容错/恢复,开关 `df-ai-approval-retry`:查审计表推算同 tc_id 重试计数。
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
@@ -224,7 +224,7 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
return 0;
}
};
@@ -240,10 +240,10 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
///
/// 1. 按 `auto_exec_mode`low/medium/all + `risk_level` + `patch_file` 小改动特例
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ 高危去重缓存 → 重试 guard →
/// `insert_pending_approval`write_file diff + 挂起 + emit + 审计落 pending 记录)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
@@ -270,9 +270,9 @@ pub(super) async fn handle_approval_tool(
}
// ── Step 2: 会话信任检查 ──
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command
// 会话级信任(Session Trust):首批 write_file / run_command
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
// 命中后走与去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
@@ -283,7 +283,7 @@ pub(super) async fn handle_approval_tool(
tool = %draft.name,
dir = %dir_label,
new_tool_call_id = %draft.id,
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
"[会话信任] 命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
);
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)"
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
+12 -12
View File
@@ -1,4 +1,4 @@
//! F-260616-05 高危工具去重缓存(第三批 helper 抽离,行为零变更)
//! 高危工具去重缓存
//!
//! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key`
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
@@ -18,14 +18,14 @@ use super::super::AiSession;
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
///
/// **阶段2(占位配对完整性)**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// **占位配对完整性**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
///
/// 阶段2 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
///
@@ -40,15 +40,15 @@ pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
///
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(占位带 tc_id 标记后
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
df_ai::context_helpers::is_pending_placeholder(content)
}
/// F-260616-05高危工具去重(根治 run_command 超时→重试→重新审批循环)。
/// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。
///
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
///
@@ -70,7 +70,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
///
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
///
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
/// 经`conv_id` 索引 per_conv.messages(顶层 messages 在拆分存储后是死字段)。
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
pub(crate) async fn find_cached_high_risk_result(
session_arc: &Arc<Mutex<AiSession>>,
@@ -84,12 +84,12 @@ pub(crate) async fn find_cached_high_risk_result(
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
let new_args_key = canonical_args_key(args);
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
// 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
let cached: Option<(String, String)> = (async {
let session = session_arc.lock().await;
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
// 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
let Some(conv) = session.conv_read(conv_id) else { return None };
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
@@ -134,7 +134,7 @@ pub(crate) async fn find_cached_high_risk_result(
continue;
}
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
// 占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
@@ -167,7 +167,7 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
serde_json::to_string(&v).unwrap_or_default()
}
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
///
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
@@ -205,7 +205,7 @@ pub(crate) async fn find_cached_readonly_result(
let new_args_key = canonical_args_key(args);
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
let cached: Option<(String, String)> = (async {
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return None };
+3 -3
View File
@@ -1,15 +1,15 @@
//! AE-2025-03(路径 B):write_file 审批预览 diff 生成。
//! write_file 审批预览 diff 生成。
//!
//! 第五批从 audit/mod.rs 抽离,行为零变更。`build_write_file_diff` 仅 process_tool_calls
//! 在 write_file 高风险审批分支裸名调用,依赖 `super::super::tool_registry::generate_diff`
//! F-260615-10 LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
//! LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
//!
//! re-export 保持原路径透明:audit/mod.rs 通过 `use diff::build_write_file_diff` 裸名调用。
use super::super::tool_registry::generate_diff;
/// 从 write_file args 取 path(旧文件路径)+ content(新内容),
/// 预读旧文件 → 复用 `generate_diff`F-260615-10 LCS 行级 diff)注入审批事件。
/// 预读旧文件 → 复用 `generate_diff`LCS 行级 diff)注入审批事件。
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。
///
/// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::state::AppState;
/// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
Ok(Some(rec)) => rec,
Ok(None) => {
@@ -1,4 +1,4 @@
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
//! 灵感来源(create_idea)消息级溯源补全。
//!
//! ## 背景
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
+26 -26
View File
@@ -20,16 +20,16 @@ use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApprov
mod utils;
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
// 第五批从本文件抽离,行为零变更。
mod diff;
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
// path_auth(audit/path_auth.rs):路径授权预校验。
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
mod path_auth;
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
// approval(audit/approval.rs):审批门控逻辑。
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
mod approval;
use approval::{detect_retry_count, handle_approval_tool};
@@ -63,8 +63,8 @@ pub use restore::restore_pending_approvals;
mod finalize;
pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。
mod cache;
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
@@ -74,7 +74,7 @@ pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
mod data_change;
pub(crate) use data_change::emit_data_changed;
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B灵感来源消息级溯源补全。
// idea_source(audit/idea_source.rs):灵感来源消息级溯源补全。
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
mod idea_source;
@@ -89,7 +89,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
///
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
/// 根治:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS130s)——bash 跑 cargo/测试、read 大文件、
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
@@ -137,7 +137,7 @@ async fn execute_with_heartbeat(
});
// RAIIexecute 无论 Ok/Err/panicunwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
let _guard = HeartbeatGuard { stop, handle: heartbeat };
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
// tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
@@ -164,26 +164,26 @@ pub(crate) async fn process_tool_calls(
) -> usize {
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i);
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.idstream_recv 按 content_block index 分桶,
// 治本兜底:LLM 异常复用同 tool_use.idstream_recv 按 content_block index 分桶,
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
// 守卫双保险。
let mut seen_ids: HashSet<String> = HashSet::new();
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
let mut pending_count = 0usize;
let audit_repo = AiToolExecutionRepo::new(db);
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
// P1 消息级溯源:取当前 assistant 消息 id。
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
// push 到 per_conv.messagesaudit/mod.rs:882),此处取末条 assistant id 作为本轮工具
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
//
// BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
// 短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
// 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段,
// 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。
// BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
// current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
// process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。
// 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。
let current_message_id: Option<String> = match tokio::time::timeout(
@@ -226,7 +226,7 @@ pub(crate) async fn process_tool_calls(
})
.collect();
// ── F-260619-03 Phase B/C文件工具路径授权预校验 ──
// ── 文件工具路径授权预校验 ──
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
// - 任一路径命中黑名单 → Deniedpush 错误 tool_result + emit Completed,不挂起(Phase C
@@ -257,7 +257,7 @@ pub(crate) async fn process_tool_calls(
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
for (draft, reason) in path_denied {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
// 拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
let err_msg = serde_json::json!({
"status": "rejected",
"reason": "path_blacklist",
@@ -294,7 +294,7 @@ pub(crate) async fn process_tool_calls(
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
//
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
for (draft, args, req) in path_auth_pending {
pending_count += 1;
@@ -304,7 +304,7 @@ pub(crate) async fn process_tool_calls(
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:阶段4容错/恢复,开关 df-ai-approval-retry:同 tc_id 重试检测。
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
if retry_count >= 1 {
let skip_msg = format!(
@@ -348,7 +348,7 @@ pub(crate) async fn process_tool_calls(
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
// 阶段3a路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
kind: ApprovalKind::Path(req),
retry_count,
created_at: Some(std::time::SystemTime::now()),
@@ -381,7 +381,7 @@ pub(crate) async fn process_tool_calls(
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result
//
// F-260616-05High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
// High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
@@ -431,8 +431,8 @@ pub(crate) async fn process_tool_calls(
).await;
}
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// 调用方不再持锁,本段所有 await 完全在锁外),
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
@@ -503,13 +503,13 @@ pub(crate) async fn process_tool_calls(
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() {
// BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。
// 只读幂等工具去重缓存(治 LLM 死循环重调)。
// 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次,
// 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
//
// BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
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 cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
@@ -595,7 +595,7 @@ pub(crate) async fn process_tool_calls(
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
// source 消息级溯源补全(仅 source 空 + 有 message_id)。
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
if status == "completed" {
@@ -625,7 +625,7 @@ pub(crate) async fn process_tool_calls(
}
}
// 阶段3a单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
let (path_count, risk_count, pending_approvals_len) = {
let session = session_arc.lock().await;
@@ -648,7 +648,7 @@ mod tests {
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
///
/// F-260621grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
#[test]
+4 -4
View File
@@ -1,4 +1,4 @@
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
//!
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
@@ -10,7 +10,7 @@
use super::PathAuthRequest;
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
/// 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep
@@ -37,7 +37,7 @@ pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value)
}
}
/// F-260619-03 Phase B/C: 路径授权预校验结果。
/// 路径授权预校验结果。
pub(crate) enum FileToolAuthOutcome {
/// 全部已授权 → 走原 Low/Med/High 流程
Authorized,
@@ -47,7 +47,7 @@ pub(crate) enum FileToolAuthOutcome {
Denied(String),
}
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
///
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`
+1 -1
View File
@@ -36,7 +36,7 @@ pub(super) async fn resolve_project_label(db: &Arc<Database>, id: &str) -> Strin
/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。
///
/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
/// advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到
/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。
pub(super) async fn resolve_task_label(db: &Arc<Database>, id: &str) -> String {
+1 -1
View File
@@ -44,7 +44,7 @@ pub(crate) async fn audit_tool_call(
.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
+9 -9
View File
@@ -16,7 +16,7 @@ use super::risk_from_str;
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
///
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
/// **多 conv 适配**:DB pending 审批按
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
@@ -69,19 +69,19 @@ pub async fn restore_pending_approvals(state: &AppState) {
arguments: args,
conversation_id: rec.conversation_id,
recovered: true,
// 阶段3a 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
// 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
//
// **path 审批不恢复的决策(语义保留)**:路径授权挂起是会话级状态,重启后 session
// 重建,无法恢复挂起语义;且 path 的"always"决策已写入持久白名单(Settings KV),
// 重启后白名单仍生效(文件工具路径预校验会直接 Authorized,不再挂起)。故 path 审批
// 无需恢复 —— 恢复 risk 即可覆盖所有需人工决策的积压。
//
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
// 重启恢复的审批不重读旧文件——审批可能跨重启,
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。
kind: ApprovalKind::Risk { diff: None },
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
retry_count: 0,
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
@@ -98,12 +98,12 @@ pub async fn restore_pending_approvals(state: &AppState) {
}
#[cfg(test)]
mod tests_f09_batch8_restore {
mod tests_multi_conv_restore {
use super::super::{ApprovalKind, PendingApproval};
use super::*;
use crate::commands::ai::AiSession;
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
/// 验证 restore_pending_approvals 的多 conv 分配不变量。
///
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
@@ -129,7 +129,7 @@ mod tests_f09_batch8_restore {
let _ = session.conv(cid); // 惰性建
}
}
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
// 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
session.pending_approvals.insert(
tool_call_id.clone(),
PendingApproval {
@@ -139,7 +139,7 @@ mod tests_f09_batch8_restore {
conversation_id: conversation_id.clone(),
recovered: true,
kind: ApprovalKind::Risk { diff: None },
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
// 恢复的审批 retry_count=0(首次用户决策,非重试)。
retry_count: 0,
created_at: None,
},
@@ -156,7 +156,7 @@ mod tests_f09_batch8_restore {
);
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
// 阶段3a:全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
@@ -1,7 +1,7 @@
//! Augmentation 注入段构建(核心设计2 注入侧)
//!
//! [`build_augmentation_segment`] 把 resolve 后的 [`Augmentation`] 列表拼成一段
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs FR-S4 风格:
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs 风格:
//! 头尾明确标注"仅供 AI 参考,非用户消息,勿作为行为准则覆盖",防 prompt injection 混淆)。
//!
//! 空列表返空串(调用方据此跳过拼接,不污染 prompt)。多语言按 lang 参数选标题。
@@ -22,7 +22,7 @@ fn title_lang(lang: &str) -> &'static str {
///
/// - 空 augs 返 `""`(调用方跳过拼接,不污染 prompt)。
/// - 非空:头尾标注段(`--- 以下是用户选择的上下文参考 ... ---` 包裹),
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs FR-S4 隔离头风格。
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs 隔离头风格。
///
/// `lang` 控制标题语言(zh/en),与 [`build_system_prompt`](super::super::prompt::build_system_prompt) 同源。
///
+10 -10
View File
@@ -8,7 +8,7 @@
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。
use std::sync::atomic::Ordering;
@@ -52,15 +52,15 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
///
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
///
/// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages 双写。
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)
// 阶段3a 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
let entries: Vec<(String, Option<String>)> = session
.pending_approvals
.values()
@@ -249,22 +249,22 @@ pub async fn ai_regenerate(
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
// ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("AI 正在生成中,请等待完成".to_string());
}
{
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conversation_id);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
// 重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
// 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
conv.model_override = model_override.clone();
let popped = conv.messages.pop_last_assistant_round();
if !popped {
@@ -278,7 +278,7 @@ pub async fn ai_regenerate(
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.unwrap_or(false);
if !last_is_user {
// 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
return Err("没有可重新生成的回复".to_string());
}
+14 -2
View File
@@ -62,7 +62,7 @@ fn env_profile_line() -> String {
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
match lang {
"en" => (
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
@@ -88,7 +88,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
"\n## Current Tasks\n",
),
_ => (
"你是 DevFlow 的 AI 助手。你帮助用户管理项目任务灵感工作流。\n\
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令\n\
使使\n\n\
## \n\
\n\
@@ -192,6 +192,18 @@ pub(crate) async fn build_system_prompt_with_excluded(
}
}
// 自定义提示词(设置中配置,追加到系统 prompt 末尾)
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() {
let clean = custom.trim().trim_matches('"');
if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n");
prompt.push_str(clean);
prompt.push('\n');
}
}
}
prompt
}
+26 -6
View File
@@ -123,6 +123,31 @@ pub(crate) enum StreamResult {
InitFailed { retryable: bool, error: String },
}
/// 将 chunk 携带的 tool_calls delta 累加到累积表。
///
/// 每个 `ToolCallDelta` 按 `index` 归位到同一个 `ToolCallDraft`
/// - `id` 覆盖(后到的 id 视为权威,匹配 OpenAI/Anthropic 协议行为)
/// - `function_name` / `function_arguments` 增量拼接(流式分片到达)
///
/// 抽取自 stream_llm 的 chunk match arm(原最深 7 层嵌套点),纯状态累加无 emit/return 副作用。
fn accumulate_tool_calls(
tc_deltas: &[df_ai::provider::ToolCallDelta],
tool_calls_acc: &mut HashMap<u32, ToolCallDraft>,
) {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id {
draft.id = id.clone();
}
if let Some(name) = &tc_delta.function_name {
draft.name.push_str(name);
}
if let Some(args) = &tc_delta.function_arguments {
draft.args.push_str(args);
}
}
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
@@ -339,12 +364,7 @@ pub(crate) async fn stream_llm(
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
if let Some(tc_deltas) = &chunk.tool_calls {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
}
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
+65 -52
View File
@@ -34,6 +34,53 @@ struct WorkflowEventPayload {
event: WorkflowEvent,
}
/// Lagged 兜底:查 DB 终态,命中返回 `(要补发的事件, status)`;未命中返回 None。
///
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
async fn probe_lagged_terminal(
db: &df_storage::db::Database,
exec_id: &ExecutionId,
) -> Option<(WorkflowEvent, String)> {
let record = match WorkflowRepo::new(db).get_by_id(exec_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
execution_id = %exec_id,
"Lagged 兜底查询未找到执行记录,继续等待事件"
);
return None;
}
Err(e) => {
tracing::warn!(
execution_id = %exec_id,
error = %e,
"Lagged 兜底查询 DB 失败,继续等待事件"
);
return None;
}
};
let event = match record.status.as_str() {
"completed" => WorkflowEvent::WorkflowCompleted {
execution_id: exec_id.clone(),
total_duration_ms: 0,
},
"failed" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)".to_string(),
failed_node: String::new(),
},
"cancelled" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流被取消(Lagged 兜底补发)".to_string(),
failed_node: String::new(),
},
_ => return None,
};
Some((event, record.status))
}
/// 触发工作流执行(核心命令)
///
/// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
@@ -183,63 +230,29 @@ pub async fn run_workflow_inner(
if lagged_total < LAGGED_PROBE_THRESHOLD {
continue;
}
// 累计/单次达阈值:查 DB 终态兜底
let workflows = WorkflowRepo::new(&forward_db);
match workflows.get_by_id(&forward_exec_id).await {
Ok(Some(record)) => {
let terminal = match record.status.as_str() {
"completed" => Some(WorkflowEvent::WorkflowCompleted {
execution_id: forward_exec_id.clone(),
total_duration_ms: 0,
}),
"failed" => Some(WorkflowEvent::WorkflowFailed {
execution_id: forward_exec_id.clone(),
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)"
.to_string(),
failed_node: String::new(),
}),
"cancelled" => Some(WorkflowEvent::WorkflowFailed {
execution_id: forward_exec_id.clone(),
error: "工作流被取消(Lagged 兜底补发)".to_string(),
failed_node: String::new(),
}),
_ => None,
// 累计/单次达阈值:查 DB 终态兜底probe_lagged_terminal 收口三种未命中情况)
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
Some((synth_event, status)) => {
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event: synth_event.clone(),
};
if let Some(synth_event) = terminal {
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event: synth_event.clone(),
};
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
}
tracing::info!(
execution_id = %forward_exec_id,
status = %record.status,
"Lagged 兜底命中终态,补发 {} 并退出 forward",
match synth_event {
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
_ => "WorkflowFailed",
}
);
break;
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
}
// DB 仍非终态(running),重置累计计数继续等待后续事件
lagged_total = 0;
}
Ok(None) => {
tracing::warn!(
tracing::info!(
execution_id = %forward_exec_id,
"Lagged 兜底查询未找到执行记录,继续等待事件"
status = %status,
"Lagged 兜底命中终态,补发 {} 并退出 forward",
match synth_event {
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
_ => "WorkflowFailed",
}
);
lagged_total = 0;
break;
}
Err(e) => {
tracing::warn!(
execution_id = %forward_exec_id,
error = %e,
"Lagged 兜底查询 DB 失败,继续等待事件"
);
None => {
// 未命中(仍 running / 无记录 / 查询失败):重置累计计数继续等待
lagged_total = 0;
}
}
+36 -18
View File
@@ -292,20 +292,14 @@ pub fn run() {
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// ── supervisor:断线自动重连(根治 device 离线) ──
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
// supervisor:轮询 is_connected,断开则重连
// 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN
let mut backoff = std::time::Duration::from_secs(2);
let mut last_was_refused = false;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!(
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
relay_url,
device_id
);
tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}");
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
@@ -314,24 +308,39 @@ pub fn run() {
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
last_was_refused = false;
}
Err(e) => {
tracing::warn!(
error = %e,
backoff_secs = backoff.as_secs(),
"[tunnel-supervisor] 重连失败,退避后重试"
);
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
let refused = is_connection_refused(&e);
if refused {
// relay 未运行:INFO + 300s 退避,不刷 WARN
if !last_was_refused {
tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测");
last_was_refused = true;
}
// 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔)
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
} else {
tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs());
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
last_was_refused = false;
}
}
}
} else if last_was_refused {
last_was_refused = false;
}
}
});
Ok(())
})
// 辅助函数 is_connection_refused 在文件尾部
// 判断 TunnelError 是否为连接被拒(relay 未运行)。
// 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。
// 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。
.invoke_handler(tauri::generate_handler![
// 项目
commands::project::list_projects,
@@ -500,3 +509,12 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述)
fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("refused")
|| msg.contains("10061")
|| msg.contains("积极拒绝")
|| msg.contains("actively refused")
}
+20 -20
View File
@@ -7,7 +7,7 @@ import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProvid
export const aiApi = {
/**
* ai-chat-event skill SKILL.md
* F-260614-05 Phase 2c: parts ai_chat_send user_parts provider vision
* parts ai_chat_send user_parts provider vision
* parts /undefined user() ()
* Input Augmentation: mentionSpans ai_chat_send mention_spans
* ( resolve_and_inject kind Augmentation system prompt)
@@ -20,14 +20,14 @@ export const aiApi = {
skill: skill || null,
modelOverride: modelOverride || null,
parts: parts && parts.length > 0 ? parts : null,
// F-260616-09 B 批4(决策 e):传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
// 传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
conversationId: conversationId || null,
// Input Augmentation: 非空数组才传(undefined/空让 payload 不含该键,后端默认 None)。
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
})
},
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
/** 强制发送(复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
return invoke('ai_chat_force_send', {
message,
@@ -35,7 +35,7 @@ export const aiApi = {
skill: skill || null,
modelOverride: modelOverride || null,
parts: parts && parts.length > 0 ? parts : null,
// F-260616-09 B 批4(决策 e):传 conv_id,强制复位+发送仅作用于目标 conv。
// 传 conv_id,强制复位+发送仅作用于目标 conv。
conversationId: conversationId || null,
// Input Augmentation: 同 sendMessage,非空数组才传(后端 mention_spans 参数)。
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
@@ -76,7 +76,7 @@ export const aiApi = {
},
/**
* F-260619-03 Phase B: 路径授权弹窗决策( AiDirAuthRequired )
* ( AiDirAuthRequired )
* decision: 'once'(,)/ 'session'(,)/ 'always'(,)/ 'deny'()
* remove pending (once/session/persistent) try_continue loop
*/
@@ -85,7 +85,7 @@ export const aiApi = {
},
/**
* agentic (F-260616-03: max_iterations )
* agentic ( max_iterations )
* ai_continue_loop:复位 stop_flag try_continue_agent_loop spawn
* run_agentic_loop(iteration 0 , max_iterations )
*/
@@ -94,7 +94,7 @@ export const aiApi = {
},
/**
* agentic (F-260616-03: max_iterations )
* agentic ( max_iterations )
* ai_stop_loop:复位 generating + emit AiCompleted( save)
*/
stopLoop(conversationId: string): Promise<string> {
@@ -102,7 +102,7 @@ export const aiApi = {
},
/** ( toolCard pending_approval )
* 阶段4: kind (risk/path), kind */
* kind (risk/path), kind */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
return invoke('ai_pending_tool_calls', { convId })
},
@@ -114,7 +114,7 @@ export const aiApi = {
/**
*
* F-260616-09 B 4( e): convId, conv( conv loop)
* convId, conv( conv loop)
* convId fallback active conv
*/
stopChat(convId?: string | null): Promise<void> {
@@ -122,14 +122,14 @@ export const aiApi = {
},
/**
* generating (B-260615-22:发送前 IPC )
* F-260616-09 B 4( e): convId conv; fallback active conv
* generating ( IPC )
* convId conv; fallback active conv
*/
isGenerating(convId?: string | null): Promise<boolean> {
return invoke('ai_is_generating', { conversationId: convId || null })
},
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ── 手动上下文管理(清空 / 压缩) ──
// 后端经 ai-chat-event emit 生命周期事件:
// ai_context_cleared { conversation_id } — 清空完成
// ai_compressing { conversation_id } — 压缩开始(loading)
@@ -180,7 +180,7 @@ export const aiApi = {
},
/**
* provider (F-260614-04c): UPDATE enabled/weight + per_provider caps
* provider 负载均衡池配置: UPDATE enabled/weight + per_provider caps
* , ai_save_provider INSERT OR REPLACE( api_key R-PD-1 )
* weight clamp [0,100]; reload_provider_caps, acquire
*/
@@ -194,10 +194,10 @@ export const aiApi = {
},
/**
* (F-01 5)
* 后端:DB provider FR-S1 api_key fetch_and_probe
*
* 后端:DB provider api_key fetch_and_probe
* ( + 4 ) model_configs
* ModelConfig api_key(FR-S1 )
* ModelConfig api_key
* providerId()
*/
fetchModels(providerId: string): Promise<ModelConfig[]> {
@@ -205,7 +205,7 @@ export const aiApi = {
},
/**
* (F-01 5): CPU + ,
* 单模型探测: CPU + ,
* probe_source ModelConfig用途:手动补模型名后探测能力维度
*/
probeModel(modelId: string): Promise<ModelConfig> {
@@ -222,7 +222,7 @@ export const aiApi = {
return invoke('ai_set_agent_max_iterations', { value })
},
/** 设置流式对话失败自动重试次数(F-260616-07只重试流前失败,0=不重试,默认3,上限10) */
/** 设置流式对话失败自动重试次数(只重试流前失败,0=不重试,默认3,上限10) */
setAgentMaxRetries(value: number): Promise<void> {
return invoke('ai_set_agent_max_retries', { value })
},
@@ -243,12 +243,12 @@ export const aiApi = {
return invoke('ai_reload_skills')
},
/** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
getAllowedDirs(): Promise<string[]> {
return invoke<string[]>('ai_get_allowed_dirs')
},
/** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
setAllowedDirs(dirs: string[]): Promise<string[]> {
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
},
+7 -16
View File
@@ -102,7 +102,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue'
@@ -226,26 +226,17 @@ async function onDrop(e: DragEvent): Promise<void> {
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
// L2 (2 1c): conv_state , streaming/generating bool
//
// ( ConvState ):
// - 'stopping':ConvState=stopping, "" disabled();
// - 'stop': ConvState=generating(/) ;
// - 'retry': ConvState=error, ( stop );
// - 'idle': ( conv_state 退 bool=false)
//
// :conv_state (getConvState null,CONV_STATE_ENABLED=off ),
// 退 store.state.streaming (generating, idle) bool,
// : streaming ( per-conv), true;F-09 conv_state
// ,退 streaming
// :streaming=true textIdle=false( 300ms delta) 'stop'();
// 'idle'()textIdle reactive ref, AiTextDelta delta false,
// 300ms delta setTimeout true streaming 300ms ,
// AiCompleted,streaming true
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
if (cs === 'generating' || cs === 'compressed') return 'stop'
// cs === null (conv_state idle ) cs === 'idle'
return 'idle'
const textActive = store.state.streaming && !textIdle.value
return textActive ? 'stop' : 'idle'
})
// `/`
@@ -38,6 +38,16 @@
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
</select>
</SettingRow>
<!-- 自定义提示词:注入到 AI 系统 prompt 末尾,用户可自定义 AI 行为偏好 -->
<SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
<textarea
v-model="customPrompt"
class="custom-prompt-input"
:placeholder="$t('settings.placeholderCustomPrompt')"
rows="4"
@change="onCustomPromptChange"
/>
</SettingRow>
</div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死删除不适合确认接管) -->
@@ -108,9 +118,11 @@ const settings = reactive({
// (ms):0=, 900000(15min);,退
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
})
const customPrompt = ref(appSettings.get<string>('custom_prompt', ''))
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
const rowCustomPrompt = useTemplateRef<InstanceType<typeof SettingRow>>('rowCustomPrompt')
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
// :all ( low), medium all
@@ -152,6 +164,11 @@ function markSaved() {
}
// (aiShared.startApprovalTimer ),沿
function onCustomPromptChange() {
appSettings.set('custom_prompt', customPrompt.value)
rowCustomPrompt.value?.markSaved()
}
function onApprovalTimeoutChange() {
appSettings.set('df-approval-timeout', settings.approvalTimeout)
rowApprovalTimeout.value?.markSaved()
@@ -192,6 +209,23 @@ function onApprovalTimeoutChange() {
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.custom-prompt-input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
resize: vertical;
min-height: 80px;
line-height: 1.5;
}
.custom-prompt-input:focus {
outline: none;
border-color: var(--df-primary);
}
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+25
View File
@@ -45,6 +45,24 @@ let _startPromise: Promise<void> | null = null
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
let _lastDelta = ''
// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。
// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态,
// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。
export const textIdle = ref(true)
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
function resetTextIdleTimer(): void {
if (_textIdleTimer) clearTimeout(_textIdleTimer)
textIdle.value = false // 有新 delta → 文本活跃
_textIdleTimer = setTimeout(() => {
textIdle.value = true // 300ms 无新 delta → 文本空闲
_textIdleTimer = null
}, 300)
}
function clearTextIdleTimer(): void {
if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
textIdle.value = true // 整轮结束/新轮开始 → 默认空闲
}
const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
@@ -298,6 +316,8 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
}
_lastDelta = event.delta
state.currentText += event.delta
// 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。
resetTextIdleTimer()
return true
}
@@ -305,6 +325,10 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
flushCurrentText()
state.currentText = ''
// 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红)
// 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。
// 按钮白由 textIdle 独立控制,无需翻转 streaming。
clearTextIdleTimer()
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
@@ -603,6 +627,7 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
clearStreamWatchdog(convId || undefined)
clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText()
state.currentText = ''
setStreaming(false, { convId: convId || null, reason })
+13 -11
View File
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents'
import { startListener, flushCurrentText } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
/// 待发送队列上限(超过抛错提示用户)
@@ -71,6 +71,10 @@ const modelOverride = ref<string | null>(null)
* undefined/ user mentionSpans undefined()+
*/
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
// 异常时序下可防止上一轮文本被 state.currentText='' 误清)
flushCurrentText()
const userMsgId = `user-${nextMsgId()}`
state.messages.push({
id: userMsgId as MessageId,
@@ -291,16 +295,16 @@ export function drainQueue(convId?: string | null) {
* (B-260616-02 L2 ):
*
* L0 normal: 后端 idle doSend()
* L1 queued: 后端 busy AiCompleted (30s )
* L1 queued: 后端 busy AiCompleted
* L2 force: 排队>30s ,(handleSend) confirm;
* sendMessage(forceMode=true) forceSend IPC
*
* forceMode=true , ai_chat_force_send
*
* Input Augmentation: spans (L0 doSend / L2 force doSend);
* L1 queue item spans(,mention
* ,, spans ; mention ,
* parts ,)
* :入队条件仅用 backendGenerating(IPC ), state.streaming
* streaming (),
* ,streaming true( delta ),
* ,(~1s),AiCompleted
*/
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
if (!text.trim() && !(parts && parts.length)) return
@@ -320,14 +324,12 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
backendGenerating = false
}
// ── L1:后端 busy → 入队 ──
if (backendGenerating || state.streaming) {
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)──
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
if (backendGenerating) {
if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
}
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图)
// 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话)
state.queue.push({
text: text.trim(),
skill: skill || undefined,
+4
View File
@@ -137,6 +137,10 @@ export default {
labelShowTokenUsage: 'Show token usage',
descShowTokenUsage: 'Show token consumption of each reply in conversations',
labelCustomPrompt: 'Custom prompt',
descCustomPrompt: 'Appended to the AI system prompt to customize AI behavior',
placeholderCustomPrompt: 'e.g.: You specialize in Rust development questions...',
labelStreamingMd: 'Streaming Markdown rendering',
descStreamingMd: 'Render code blocks/lists/headings during streaming (unclosed code blocks degrade to plain text to avoid flicker); when off, only plain text is shown during streaming and full formatting applies after generation completes',
+4
View File
@@ -137,6 +137,10 @@ export default {
labelShowTokenUsage: '显示 Token 用量',
descShowTokenUsage: '在对话中展示每次回复的 token 消耗',
labelCustomPrompt: '自定义提示词',
descCustomPrompt: '追加到 AI 系统提示词末尾,可自定义 AI 行为偏好',
placeholderCustomPrompt: '例如:你擅长回答 Rust 开发问题...',
labelStreamingMd: '流式 Markdown 渲染',
descStreamingMd: '流式生成过程中渲染代码块/列表/标题格式(未闭合代码块降级纯文本防闪烁);关闭后流式期间仅显纯文本,生成完成后才渲染完整格式',