重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复

- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
2026-06-15 05:14:42 +08:00
parent 04032a2a8d
commit 2de0c6ecb7
37 changed files with 2457 additions and 484 deletions

30
Cargo.lock generated
View File

@@ -731,6 +731,7 @@ dependencies = [
"df-storage",
"df-workflow",
"futures",
"keyring",
"serde",
"serde_json",
"tauri",
@@ -2157,6 +2158,20 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2351,7 +2366,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -3376,6 +3391,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"

View File

@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
};
@@ -102,7 +102,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => {
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -121,7 +121,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 消息增量output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
@@ -131,14 +131,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 文本增量
"content_block_delta" => {
if let Some(delta) = v.get("delta") {
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None };
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -154,10 +154,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
function_arguments: Some(partial),
}]),
usage: None,
error: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
@@ -185,10 +186,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
function_arguments: None,
}]),
usage: None,
error: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
@@ -196,15 +198,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
},
// 错误事件
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None },
}
}
@@ -236,7 +240,7 @@ impl AnthropicCompatProvider {
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client: {}", e);
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self {
@@ -508,12 +512,8 @@ impl LlmProvider for AnthropicCompatProvider {
"anthropic-compat"
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.messages_url()
}
}
@@ -663,17 +663,29 @@ mod tests {
assert!(!c.finished);
}
/// error 事件 → finished=true 终态空 chunk
/// error 事件 → error=Some + finished=falseR-P1-1避免残缺响应被当正常完成入库
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
#[test]
fn anthropic_error_event_finishes_stream() {
fn anthropic_error_event_yields_error_not_finished() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(10), &mut acc);
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
assert!(c.finished, "error 应终止流");
assert!(!c.finished, "error 不走 finished 完成路径,否则残缺响应会被当正常完成");
assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息");
assert!(c.usage.is_none(), "error 不带出 usage");
assert!(c.delta.is_empty(), "error 不带文本增量");
assert!(acc.is_some(), "error 不应清空已累积的 usage与原实现一致");
}
/// error 事件无 error.message 字段时兜底为 "stream error"
#[test]
fn anthropic_error_event_missing_message_falls_back() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc);
assert_eq!(c.error.as_deref(), Some("stream error"));
assert!(!c.finished);
}
/// ping / content_block_stop 等事件 → 空且不 finished
#[test]
fn anthropic_ping_and_block_stop_yield_empty_chunk() {

View File

@@ -10,7 +10,7 @@
use serde::{Deserialize, Serialize};
use crate::provider::{ChatMessage, MessageRole};
use crate::provider::{ChatMessage, MessageRole, ToolCall};
// ============================================================
// Token 估算器(零依赖粗估)
@@ -155,6 +155,10 @@ pub struct ContextManager {
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
const PROTECT_COUNT: usize = 6;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176
/// 此类 id 必然无匹配 tool_result是历史中毒的标志sanitize 时据此剔除畸形三元组。
const TOOL_MISSING_PREFIX: &str = "tool_missing_";
impl ContextManager {
pub fn new(config: ContextConfig) -> Self {
Self {
@@ -224,9 +228,9 @@ impl ContextManager {
);
}
// 未超预算 → 直接返回全量
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
return (self.all_messages_clone(), false);
return (Self::sanitize_messages(self.all_messages_clone()), false);
}
// 超预算 → 视图裁剪(不修改 self.messages保证 all_messages_clone 仍返回全量)
@@ -260,7 +264,137 @@ impl ContextManager {
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
trim_end, removed
);
(msgs, true)
(Self::sanitize_messages(msgs), true)
}
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
///
/// 根因Anthropic 流式 `tool_use` 块缺 id 时anthropic_compat.rs 170-176
/// 生成 `tool_missing_{idx}` 占位 id对应的 `tool_result` 一旦推入历史,
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
/// 不改持久化self.messages 全量保留),让卡死的会话能自愈继续。
///
/// provider 协议铁律assistant 头里每个 tool_call.id 都必须在后续有对应的
/// tool_result否则服务端 400/500。本函数按此自愈分三档处理每个头
///
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
/// - 部分闭合:保留头但只留已闭合的 tool_call丢弃未闭合的 tool_call 及其
/// 无主 tool_result「不能整条删」——保住已发生的合法工具交互历史
///
/// 单次遍历、保序过滤、不重排user/assistant/tool 角色交替不被打乱)。
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
// step 1已闭合的 tool_call_id 集合role=Tool 消息全部提供过的 id
let resolved_ids: HashSet<&str> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.collect();
// step 2判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
// orphaned_ids未闭合的 tool_call.id含占位 id其 tool_result 要丢
// partial_heads部分闭合的头——保留但需重写 tool_calls只留已闭合的
// full_drop_heads全未闭合的头——整条丢弃
let mut orphaned_ids: HashSet<String> = HashSet::new();
let mut full_drop_heads = 0u32;
let mut partial_heads = 0u32;
// 记录部分闭合头中「应保留的 id」用于 step3 精确重写
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
std::collections::HashMap::new();
for (i, m) in messages.iter().enumerate() {
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
let Some(calls) = m.tool_calls.as_ref() else { continue };
if calls.is_empty() {
continue;
}
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
.iter()
.partition(|c| {
!c.id.starts_with(TOOL_MISSING_PREFIX)
&& resolved_ids.contains(c.id.as_str())
});
match (resolved.is_empty(), unresolved.is_empty()) {
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
(true, false) => {
full_drop_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
}
// 全闭合 → 原样保留
(false, true) => {}
// 部分闭合 → 保留头,只留 resolved 的 tool_callunresolved 入 orphaned_ids
(false, false) => {
partial_heads += 1;
for c in &unresolved {
orphaned_ids.insert(c.id.clone());
}
partial_keep.insert(i, resolved.into_iter().cloned().collect());
}
// resolved/unresolved 都空不可能calls 非空已保证)
(true, true) => {}
}
}
if orphaned_ids.is_empty() && partial_keep.is_empty() {
return messages;
}
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -355,7 +489,6 @@ struct EvictionUnit {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ToolCall;
fn cfg(max_tokens: u32) -> ContextConfig {
ContextConfig {
@@ -365,6 +498,168 @@ mod tests {
}
}
#[test]
fn sanitize_drops_placeholder_id_triplet() {
// 中毒场景assistant 头带 tool_missing_ 占位 id + 无主 tool_result
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("调用工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
assert_eq!(msgs[0].content, "调用工具");
// 持久化全量保留(自愈只改发送视图)
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_drops_unresolved_tool_call_head() {
// 真实 id 但全无 tool_result流式中断/异常)→ 全未闭合档,整头丢弃
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 两个 call 都无 result → 整头丢弃
let (msgs, _trimmed) = mgr.build_for_request(0);
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"全未闭合的 assistant 头应被剔除");
assert!(msgs.is_empty(), "全未闭合应整条删");
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
}
#[test]
fn sanitize_partial_resolve_rewrites_head() {
// 多 tool_call部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
// provider 协议铁律head 的每个 tool_call.id 必须都有 tool_result。
// 「不能整条删」——保住已发生的合法工具交互call_b历史。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("触发工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
));
// 只有 call_b 有 result → call_a 未闭合
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
let assistant_heads: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.collect();
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
assert!(
head_calls.iter().all(|c| c.id != "call_a"),
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
);
// tool_result 只剩 call_b 的user 前置保留
let tool_msgs: Vec<_> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.collect();
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
assert!(
msgs.iter()
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
"无关 user 消息不应被误删"
);
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
}
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组head 的每个 tool_call 都有匹配 tool_result → 不剔除
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除");
}
#[test]
fn sanitize_mixed_multi_message_sequence() {
// 场景 4连续多条消息混合——
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
// 预期全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("开场"));
// 全闭合三元组
mgr.push(ChatMessage::assistant_with_tools(
"调1",
vec![ToolCall::new("ok_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
// 中毒占位 id 三元组(全未闭合)→ 整删
mgr.push(ChatMessage::assistant_with_tools(
"调2",
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
// 部分闭合头keep_1 闭合drop_1 未闭合 → 重写为只剩 keep_1
mgr.push(ChatMessage::assistant_with_tools(
"调3",
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
mgr.push(ChatMessage::user("收尾"));
let (msgs, _trimmed) = mgr.build_for_request(0);
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
let head_ids: Vec<Vec<String>> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
.map(|m| {
m.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect()
})
.collect();
assert!(
head_ids.iter().all(|ids| {
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
}),
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
);
let tool_ids: Vec<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.clone())
.collect();
// ok_1全闭合保留、keep_1部分闭合重写后保留应在占位/drop_1 不在
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
assert!(
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
);
// 两条 user 消息原样保留
let user_contents: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.map(|m| m.content.as_str())
.collect();
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));

View File

@@ -1,4 +1,4 @@
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools;
pub mod anthropic_compat;
@@ -6,8 +6,6 @@ pub mod context;
pub mod coordinator;
pub mod openai_compat;
pub mod provider;
pub mod router;
pub mod stream;
use provider::LlmProvider;

View File

@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
CompletionRequest, CompletionResponse, LlmProvider, StreamChunk, StreamResult,
TokenUsage, ToolCall, ToolCallDelta,
};
@@ -148,6 +148,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: true,
tool_calls: None,
usage: usage_accum.take(),
error: None,
};
}
@@ -184,6 +185,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished,
tool_calls,
usage: None,
error: None,
}
} else {
// choices 为空 = usage-only chunk不输出文本usage 已累积)
@@ -192,6 +194,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: false,
tool_calls: None,
usage: None,
error: None,
}
}
}
@@ -202,6 +205,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
finished: false,
tool_calls: None,
usage: None,
error: None,
}
}
}
@@ -233,7 +237,7 @@ impl OpenAICompatProvider {
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout: {}", e);
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
Self {
@@ -495,12 +499,8 @@ impl LlmProvider for OpenAICompatProvider {
&self.default_model
}
fn supported_features(&self) -> ProviderFeatures {
ProviderFeatures {
streaming: true,
function_calling: true,
vision: false,
}
fn endpoint(&self) -> String {
self.chat_url()
}
}

View File

@@ -151,14 +151,6 @@ pub struct TokenUsage {
pub total_tokens: u32,
}
/// Provider 支持的特性标志
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderFeatures {
pub streaming: bool,
pub function_calling: bool,
pub vision: bool,
}
/// 流式输出的 chunk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamChunk {
@@ -172,6 +164,10 @@ pub struct StreamChunk {
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
/// 非空表示流中途出错不应视为正常完成finished 路径),由 stream_llm 转 AiError。
#[serde(skip)]
pub error: Option<String>,
}
/// 工具调用增量(流式中的片段)
@@ -216,6 +212,10 @@ pub trait LlmProvider: Send + Sync {
/// Provider 名称
fn name(&self) -> &str;
/// 支持的特性
fn supported_features(&self) -> ProviderFeatures;
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages
/// 默认回落 `name()`provider 实现覆盖返真实 URL供 401/网络错误诊断打印
/// —— 旧路径只能近似打印 provider_type看不到实际请求端点。
fn endpoint(&self) -> String {
self.name().to_string()
}
}

View File

@@ -1,51 +0,0 @@
//! 模型路由 — 根据任务类型选择最优模型
use serde::{Deserialize, Serialize};
/// 任务类型
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskType {
/// 代码生成
CodeGeneration,
/// 代码审查
CodeReview,
/// 文档生成
Documentation,
/// 分析推理
Analysis,
/// 摘要总结
Summarization,
/// 通用对话
Chat,
}
/// 模型路由器
///
/// 根据任务类型、成本、延迟等选择最优模型
pub struct ModelRouter {
/// 默认模型
default_model: String,
}
impl ModelRouter {
/// 创建路由器
pub fn new(default_model: &str) -> Self {
Self {
default_model: default_model.to_string(),
}
}
/// 根据任务类型路由到合适的模型
pub fn route(&self, task_type: &TaskType) -> String {
// TODO: 实现基于规则的模型路由
match task_type {
TaskType::CodeGeneration => self.default_model.clone(),
TaskType::CodeReview => self.default_model.clone(),
TaskType::Documentation => self.default_model.clone(),
TaskType::Analysis => self.default_model.clone(),
TaskType::Summarization => self.default_model.clone(),
TaskType::Chat => self.default_model.clone(),
}
}
}

View File

@@ -1,45 +0,0 @@
//! 流式处理 — LLM 响应的流式输出管理
use crate::provider::StreamChunk;
/// 流式响应收集器
pub struct StreamCollector {
/// 已收集的文本
text: String,
/// 是否完成
finished: bool,
}
impl StreamCollector {
/// 创建收集器
pub fn new() -> Self {
Self {
text: String::new(),
finished: false,
}
}
/// 追加一个 chunk
pub fn push(&mut self, chunk: &StreamChunk) {
self.text.push_str(&chunk.delta);
if chunk.finished {
self.finished = true;
}
}
/// 获取已收集的文本
pub fn text(&self) -> &str {
&self.text
}
/// 是否已完成
pub fn is_finished(&self) -> bool {
self.finished
}
}
impl Default for StreamCollector {
fn default() -> Self {
Self::new()
}
}

View File

@@ -4,12 +4,31 @@ use serde::{Deserialize, Serialize};
use crate::types::NodeId;
/// 人工审批选择类型(F-260615-01)
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
/// - Multiple: 多选(decisions 数组)
///
/// serde rename_all="snake_case" → "single"/"multiple"
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SelectType {
#[default]
Single,
Multiple,
}
/// 人工审批响应
///
/// 兼容字段(decision 单值):仅 select_type=Single 时使用;
/// 新字段 decisions 数组:Single 时长度=1,Multiple 时长度≥1。
/// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HumanApprovalResponse {
pub execution_id: String,
pub node_id: NodeId,
pub decision: String,
#[serde(default)]
pub decisions: Vec<String>,
pub comment: Option<String>,
}
@@ -63,12 +82,18 @@ pub enum WorkflowEvent {
title: String,
description: String,
options: Vec<String>,
/// F-260615-01: 选择类型,缺省 Single(向后兼容)
#[serde(default)]
select_type: SelectType,
},
/// 人工审批响应
HumanApprovalResponse {
execution_id: String,
node_id: NodeId,
decision: String,
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
#[serde(default)]
decisions: Vec<String>,
comment: Option<String>,
},
}

View File

@@ -3,3 +3,6 @@
pub mod error;
pub mod events;
pub mod types;
// 顶层 re-export:常用时间工具(跨 crate 调用方用 df_core::now_millis,避免 types:: 前缀)
pub use types::now_millis;

View File

@@ -27,6 +27,21 @@ pub type ExecutionId = String;
/// 决策 ID
pub type DecisionId = String;
// ============================================================
// 时间工具
// ============================================================
/// 当前 Unix 时间戳(毫秒)
///
/// 统一时间获取入口,避免 commands 层与 df-storage 层各写一份 SystemTime 调用(DRY)。
/// 需要字符串形式的调用方自行 `.to_string()`。
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
// ============================================================
// 状态枚举
// ============================================================
@@ -143,6 +158,29 @@ impl TaskStatus {
TaskStatus::Cancelled => "cancelled",
}
}
/// 校验字符串是否为合法的 TaskStatus 存储值(小写 snake_case
/// 用于落库前拦截拼写错误(如 "in-progess"、"in progress"、大小写错等),
/// 防止任意字符串静默落库导致按枚举查询全漏、任务"消失"。
pub fn is_valid(s: &str) -> bool {
matches!(
s,
"todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled"
)
}
/// 合法 status 值列表,用于错误提示。
pub fn valid_values() -> &'static [&'static str] {
&[
"todo",
"in_progress",
"in_review",
"testing",
"done",
"blocked",
"cancelled",
]
}
}
impl Default for TaskStatus {
@@ -299,3 +337,96 @@ impl Default for Priority {
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::TaskStatus;
// 合法值:覆盖全部枚举变体(与 as_str 一致)
#[test]
fn is_valid_accepts_all_variants() {
assert!(TaskStatus::is_valid("todo"));
assert!(TaskStatus::is_valid("in_progress"));
assert!(TaskStatus::is_valid("in_review"));
assert!(TaskStatus::is_valid("testing"));
assert!(TaskStatus::is_valid("done"));
assert!(TaskStatus::is_valid("blocked"));
assert!(TaskStatus::is_valid("cancelled"));
}
// as_str 与 is_valid 必须自洽:每个变体的存储值都应被 is_valid 接受
#[test]
fn is_valid_consistent_with_as_str() {
let all = [
TaskStatus::Todo,
TaskStatus::InProgress,
TaskStatus::InReview,
TaskStatus::Testing,
TaskStatus::Done,
TaskStatus::Blocked,
TaskStatus::Cancelled,
];
for v in all {
assert!(
TaskStatus::is_valid(v.as_str()),
"as_str({:?}) = {:?} 未被 is_valid 接受",
v,
v.as_str()
);
}
}
// 拼写错误(典型回归场景)
#[test]
fn is_valid_rejects_typos() {
assert!(!TaskStatus::is_valid("in-progess")); // 漏 r + 连字符
assert!(!TaskStatus::is_valid("in-progress")); // 连字符非下划线
assert!(!TaskStatus::is_valid("in progress")); // 空格
assert!(!TaskStatus::is_valid("inprogres")); // 漏 s
assert!(!TaskStatus::is_valid("tod")); // 截断
assert!(!TaskStatus::is_valid("don")); // 截断
assert!(!TaskStatus::is_valid("canceled")); // 美式拼写(单 l
assert!(!TaskStatus::is_valid("complete")); // 近义词
assert!(!TaskStatus::is_valid("pending")); // 近义词
}
// 大小写敏感
#[test]
fn is_valid_case_sensitive() {
assert!(!TaskStatus::is_valid("Todo"));
assert!(!TaskStatus::is_valid("TODO"));
assert!(!TaskStatus::is_valid("InProgress"));
assert!(!TaskStatus::is_valid("IN_PROGRESS"));
assert!(!TaskStatus::is_valid("Done"));
}
// 边界:空串、空白、前后空格、带前后缀
#[test]
fn is_valid_rejects_empty_and_whitespace() {
assert!(!TaskStatus::is_valid(""));
assert!(!TaskStatus::is_valid(" "));
assert!(!TaskStatus::is_valid(" todo")); // 前导空格
assert!(!TaskStatus::is_valid("todo ")); // 尾随空格
assert!(!TaskStatus::is_valid(" todo ")); // 两端空格
assert!(!TaskStatus::is_valid("todo\n")); // 换行
}
// 边界:非 ASCII / 注入尝试
#[test]
fn is_valid_rejects_garbage() {
assert!(!TaskStatus::is_valid("'; DROP TABLE tasks;--"));
assert!(!TaskStatus::is_valid("待开始")); // 中文近义
assert!(!TaskStatus::is_valid("todo;done"));
assert!(!TaskStatus::is_valid("todo,done"));
}
// valid_values 必须与 is_valid 自洽(防清单漏项)
#[test]
fn valid_values_all_pass_is_valid() {
for &v in TaskStatus::valid_values() {
assert!(TaskStatus::is_valid(v), "valid_values 含 {:?} 但 is_valid 拒绝", v);
}
// 数量应等于枚举变体数
assert_eq!(TaskStatus::valid_values().len(), 7);
}
}

View File

@@ -52,14 +52,24 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
cmd.env(key, value);
}
// kill_on_drop 确保 child 句柄被 drop 时(包括 timeout 取消)自动 kill 子进程,
// 防止 stdout/stderr pipe fd 泄漏 + 孤儿/僵尸进程累积。
cmd.kill_on_drop(true);
let child = cmd.spawn()?;
// wait_with_output 消费 child 并回收所有 pipe + 等待退出(避免僵尸)。
let output = match request.timeout_secs {
Some(secs) => tokio::time::timeout(
Some(secs) => match tokio::time::timeout(
std::time::Duration::from_secs(secs),
cmd.output(),
child.wait_with_output(),
)
.await
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
None => cmd.output().await?,
{
Ok(res) => res?,
// timeout 触发:此处 child 已被 wait_with_output 消费,但因 kill_on_drop
// 在构建时已设,spawn 出的底层进程在 child 句柄 drop 时自动 kill。
Err(_) => return Err(anyhow::anyhow!("命令执行超时: {}秒", secs)),
},
None => child.wait_with_output().await?,
};
let duration = start.elapsed().as_millis() as u64;

View File

@@ -1,14 +1,18 @@
//! 想法晋升 — 将想法转为项目
//!
//! 历史的 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套
//! 是空壳 TODOdo_promote 从未接入 df-project误接入会得虚假成功
//! 实际晋升路径由 src-tauri/src/commands/idea.rs::promote_idea 直接复用
//! df-project::ProjectManager::create_from_idea 完成。这里仅保留作为
//! IPC 返回类型的 PromotionResult。
//!
//! 死代码已清,详见 docs/todo.md R-PD-14。
use anyhow::Result;
use serde::Serialize;
use df_core::types::{IdeaId, ProjectId};
use crate::adversarial::Recommendation;
use crate::capture::Idea;
/// 晋升结果
/// 晋升结果promote_idea IPC 返回类型)
#[derive(Debug, Clone, Serialize)]
pub struct PromotionResult {
pub idea_id: IdeaId,
@@ -16,77 +20,3 @@ pub struct PromotionResult {
pub promoted: bool,
pub reason: String,
}
/// 晋升策略
#[derive(Debug, Clone, Copy)]
pub enum PromotionPolicy {
/// 自动晋升(评分达标时自动转为项目)
Auto,
/// 手动确认(需要人工审批)
Manual,
/// 半自动AI 评估 + 人工确认)
SemiAuto,
}
/// 想法晋升器
pub struct IdeaPromoter {
policy: PromotionPolicy,
}
impl IdeaPromoter {
/// 创建晋升器
pub fn new(policy: PromotionPolicy) -> Self {
Self { policy }
}
/// 评估并尝试晋升想法
///
/// TODO: 接入 df-project 创建项目
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
match self.policy {
PromotionPolicy::Auto => {
if matches!(recommendation, Recommendation::ImmediateAction | Recommendation::Soon) {
self.do_promote(idea)
} else {
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: format!("评估建议 {:?},未达到自动晋升标准", recommendation),
})
}
}
PromotionPolicy::Manual => {
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: "手动模式,等待人工审批".to_string(),
})
}
PromotionPolicy::SemiAuto => {
// TODO: AI 评估 + 人工确认流程
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id: String::new(),
promoted: false,
reason: "半自动模式,等待确认".to_string(),
})
}
}
}
/// 执行晋升操作
fn do_promote(&self, idea: &Idea) -> Result<PromotionResult> {
let project_id = df_core::types::new_id();
// TODO: 调用 df-project 创建项目
tracing::info!("想法 {} 晋升为项目 {}", idea.id, project_id);
Ok(PromotionResult {
idea_id: idea.id.clone(),
project_id,
promoted: true,
reason: "评估达标,自动晋升".to_string(),
})
}
}

View File

@@ -47,6 +47,10 @@ fn parse_params(
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
.to_string();
// 空 key 早失败:避免空 key 发请求吃 401,错误伪装成"API Key 无效"(与 src-tauri secret::ensure_resolved_key 行为对齐)
if api_key.trim().is_empty() {
anyhow::bail!("AiNode api_key 为空(请检查节点配置或密钥注入链路),已阻止请求避免 401 误导");
}
// ── prompt必填优先取上游节点 "prompt" 输出,回退 config.prompt ──
let prompt = inputs

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use std::time::Duration;
use tokio::sync::broadcast;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use df_core::events::WorkflowEvent;
use df_core::events::{SelectType, WorkflowEvent};
/// 人工审批节点(阻塞节点)
pub struct HumanNode;
@@ -33,6 +33,12 @@ impl Node for HumanNode {
.and_then(|v| v.as_u64())
.unwrap_or(3600);
// F-260615-01: 解析 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,
};
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
// 若 subscribe 晚于 sendResponse 会在 receiver 建位前发出而丢失,节点死等到超时。
let mut rx = ctx.event_bus.subscribe();
@@ -46,6 +52,7 @@ impl Node for HumanNode {
title: title.to_string(),
description: description.to_string(),
options: options.clone(),
select_type,
})
.await;
@@ -58,19 +65,43 @@ impl Node for HumanNode {
tokio::select! {
recv = rx.recv() => match recv {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, comment,
execution_id, node_id, decision, decisions, comment,
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
// decision 合法性校验:
// options 空 → 允许自由文本决策;
// options 非空 → 强制 decision ∈ options否则报错而非静默放行。
// 空 decision 始终非法。
if !decision.is_empty() && (options.is_empty() || options.contains(&decision)) {
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// select_type=Single → 决策数必须 =1
// select_type=Multiple → 决策数必须 ≥1
// options 空 → 允许自由文本(仅受数量约束);
// options 非空 → 每项必须 ∈ options
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
let mut picked: Vec<String> = decisions;
if picked.is_empty() && !decision.is_empty() {
picked.push(decision.clone());
}
let count_ok = match select_type {
SelectType::Single => picked.len() == 1,
SelectType::Multiple => !picked.is_empty(),
};
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 {
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
let primary = picked.first().cloned().unwrap_or_default();
return Ok(NodeOutput::from_value(serde_json::json!({
"decision": decision,
"decision": primary,
"decisions": picked,
"comment": comment.unwrap_or_default(),
})));
}
return Err(anyhow::anyhow!("审批决策非法: {}", decision));
tracing::warn!(
node_id = %ctx.node_id,
execution_id = %ctx.execution_id,
select_type = ?select_type,
decision = %decision,
decisions = ?picked,
options = ?options,
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
);
continue;
}
Ok(_) => continue, // 其他节点/类型的事件,忽略
Err(broadcast::error::RecvError::Lagged(n)) => {
@@ -108,6 +139,11 @@ impl Node for HumanNode {
"options": {
"type": "array",
"items": { "type": "string" }
},
"select_type": {
"type": "string",
"enum": ["single", "multiple"],
"default": "single"
}
},
"required": ["title"]
@@ -116,6 +152,7 @@ impl Node for HumanNode {
"type": "object",
"properties": {
"decision": { "type": "string" },
"decisions": { "type": "array", "items": { "type": "string" } },
"comment": { "type": "string" }
}
}),
@@ -134,7 +171,10 @@ impl Node for HumanNode {
#[cfg(test)]
mod tests {
use super::*;
use df_core::types::NodeStatus;
use df_workflow::dag::Dag;
use df_workflow::eventbus::EventBus;
use df_workflow::executor::DagExecutor;
use df_workflow::state::StateMachine;
use serde_json::json;
use std::collections::HashMap;
@@ -158,6 +198,7 @@ mod tests {
}
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
async fn send_response(
event_bus: &EventBus,
execution_id: &str,
@@ -170,6 +211,26 @@ mod tests {
execution_id: execution_id.to_string(),
node_id: node_id.to_string(),
decision: decision.to_string(),
decisions: vec![],
comment: comment.map(String::from),
})
.await;
}
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
async fn send_response_multi(
event_bus: &EventBus,
execution_id: &str,
node_id: &str,
decisions: &[&str],
comment: Option<&str>,
) {
event_bus
.send(WorkflowEvent::HumanApprovalResponse {
execution_id: execution_id.to_string(),
node_id: node_id.to_string(),
decision: String::new(),
decisions: decisions.iter().map(|s| s.to_string()).collect(),
comment: comment.map(String::from),
})
.await;
@@ -279,14 +340,15 @@ mod tests {
}
#[tokio::test]
async fn invalid_decision_rejected() {
// options 非空decision ∉ options → Err 决策非法
async fn invalid_decision_ignored_then_timeout() {
// R-P1-6: options 非空decision ∉ options → 不立即 Err(一次手误不应杀死节点)
// warn 记录 + continue 续等下一条合法 Response此处仅发一条非法的短超时验证 → Err 超时。
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-1",
"node-h",
json!({ "options": ["同意", "拒绝"] }),
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
);
let bus_clone = bus.clone();
@@ -298,8 +360,7 @@ mod tests {
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("非法"), "非法 decision 应报错, 实际: {}", err);
assert!(err.contains("随便"), "错误信息应含 decision 值, 实际: {}", err);
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
}
#[tokio::test]
@@ -325,10 +386,10 @@ mod tests {
}
#[tokio::test]
async fn empty_decision_rejected_even_with_empty_options() {
// 空 decision 始终非法(即使 options 空也不允许空 decision
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move {
@@ -339,10 +400,240 @@ mod tests {
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("非法"), "空 decision 应报错, 实际: {}", err);
assert!(err.contains("超时"), "空 decision 应被忽略后超时, 实际: {}", err);
}
// Closed 分支覆盖execute 持有的 ctx 含 EventBus clone即一个 sender
// 在 execute 运行期间通道无法 Closed至少该 sender 存活)。
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
// ===== B-03b-R8: executor 级端到端集成测试 =====
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
/// 简单测试节点sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode作 human 的前驱)
struct SleepNode {
sleep_ms: u64,
}
#[async_trait]
impl Node for SleepNode {
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::Value::Null,
output: serde_json::Value::Null,
}
}
fn node_type(&self) -> &str {
"sleep"
}
}
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
/// 验证Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
#[tokio::test]
async fn end_to_end_human_approval_completes_workflow() {
let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 executor sendbroadcast 不回放)
let exec_id = "exec-e2e-1";
let mut dag = Dag::new();
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
dag.add_node("b".to_string(), Box::new(HumanNode));
dag.add_edge("a".to_string(), "b".to_string());
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let sm = executor.state_machine(); // 共享状态机spawn 后仍可读)
let run_handle = tokio::spawn(async move {
executor
.run(
&dag,
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
)
.await
});
// 等 a 层完成、b 层 human subscribe + send Request。
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
let request = tokio::time::timeout(Duration::from_millis(1000), async {
loop {
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
return ev;
}
// NodeStarted/NodeCompleted 等其他事件,继续等
}
})
.await;
assert!(
request.is_ok(),
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
);
match request.unwrap() {
WorkflowEvent::HumanApprovalRequest { node_id, title, options, .. } => {
assert_eq!(node_id, "b");
assert_eq!(title, "确认发布");
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
}
other => panic!("期望 HumanApprovalRequest收到 {:?}", other),
}
// 外部 IPC 模拟:审批通过
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
let outputs = run_handle.await.unwrap().unwrap();
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
assert_eq!(outputs["b"].data["decision"], json!("同意"));
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
}
/// B-03b-R8: executor 驱动 human DAG外部 set_cancelled(模拟 cancel_workflow_node IPC)
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态R1)
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
#[tokio::test]
async fn end_to_end_human_approval_cancelled() {
let bus = EventBus::new();
let mut rx = bus.subscribe(); // subscribe 先于 executor send
let exec_id = "exec-e2e-cancel";
let mut dag = Dag::new();
dag.add_node("h".to_string(), Box::new(HumanNode));
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
let sm = executor.state_machine();
let run_handle = tokio::spawn(async move {
executor.run(&dag, json!({ "timeout_secs": 5 })).await
});
// 等 human 发出 Request确认已 subscribe + send + 进入 select! 轮询)
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
})
.await
.is_ok();
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
// 外部 IPC 模拟:取消节点(写共享 state_machinehuman cancel_tick 会读到)
sm.set_cancelled("h".to_string());
let result = run_handle.await.unwrap();
assert!(result.is_err(), "取消应致 run 返回 Err");
let err = result.unwrap_err().to_string();
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
assert!(
err.contains("取消") || err.contains("执行失败"),
"应返回取消相关错误,实际: {}",
err
);
// 状态保持 Cancelled未被 set_failed 覆盖为 Failed—— R1 修法端到端验证
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
}
// ===== F-260615-01: 多选审批覆盖 =====
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
#[tokio::test]
async fn multiple_select_returns_decisions_array() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi",
"node-h",
json!({
"options": ["A", "B", "C"],
"select_type": "multiple"
}),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
send_response_multi(&bus_clone, "exec-multi", "node-h", &["A", "C"], Some("多选")).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
assert_eq!(out.data["decisions"], json!(["A", "C"]));
assert_eq!(out.data["comment"], json!("多选"));
}
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
#[tokio::test]
async fn single_select_rejects_multiple_decisions_then_timeout() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-single-multi",
"node-h",
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// single 模式却发多个 → 非法
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
#[tokio::test]
async fn multiple_select_invalid_option_ignored_then_timeout() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi-bad",
"node-h",
json!({
"options": ["A", "B"],
"select_type": "multiple",
"timeout_secs": 1
}),
);
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// 一项合法一项不合法 → 整批非法
send_response_multi(&bus_clone, "exec-multi-bad", "node-h", &["A", "X"], None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
#[tokio::test]
async fn default_single_with_legacy_decision_single_value() {
let bus = EventBus::new();
// 注意:不传 select_type,验证缺省=Single
let ctx = make_ctx(&bus, "exec-leg", "node-h", json!({ "options": ["同意", "拒绝"] }));
let bus_clone = bus.clone();
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
tokio::time::sleep(Duration::from_millis(50)).await;
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("同意"));
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
}
}

View File

@@ -16,6 +16,39 @@ use crate::models::{
TaskRecord, WorkflowRecord,
};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。与 `df_project::scan::normalize_path` **同算法镜像**(df-storage
/// 不依赖 df-project,故独立实现;改动须同步)。防 `C:\a\b` vs `C:/a/b/` 绕过。
fn normalize_stored_path(p: &str) -> String {
match std::path::Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
// ============================================================
// 知识库 SELECT 列清单(防 COLS 漂移)
// ============================================================
/// `knowledges` 表对应 `KnowledgeRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
#[cfg_attr(not(test), allow(dead_code))]
const KNOWLEDGE_COL_COUNT: usize = 14;
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
"source_project,source_ref,reasoning,created_at,updated_at,embedding"
);
// ============================================================
// 辅助宏 — 消除 6 个 Repo 的重复样板
// ============================================================
@@ -355,11 +388,8 @@ pub fn is_allowed_column(table: &str, field: &str) -> bool {
// ============================================================
fn now_millis_str() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string()
// 转发 df_core::now_millis(DRY:时间获取统一入口在 df-core,避免本 crate 与 commands 层各写一份 SystemTime 调用)
df_core::now_millis().to_string()
}
// ============================================================
@@ -617,6 +647,27 @@ impl ProjectRepo {
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
///
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
pub async fn find_path_conflict(
&self,
norm_path: &str,
exclude_id: Option<&str>,
) -> Result<Option<ProjectRecord>> {
let projects = self.list_active().await?;
Ok(projects.into_iter().find(|p| {
let excluded = exclude_id.is_some_and(|eid| p.id == eid);
!excluded && p.path.as_ref().is_some_and(|pp| {
// 路径规范化由调用方完成目标侧;这里为已存项目路径补规范化
// (历史路径写法可能不规范,统一规范化比较避免误判/漏判)
normalize_stored_path(pp) == norm_path
})
}))
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序
pub async fn list_deleted(&self) -> Result<Vec<ProjectRecord>> {
let conn = self.conn.clone();
@@ -1068,10 +1119,9 @@ impl KnowledgeRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut results = Vec::new();
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
if let Some(k) = &kind {
let mut stmt = guard
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -1081,7 +1131,7 @@ impl KnowledgeRepo {
}
} else {
let mut stmt = guard
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1190,12 +1240,10 @@ impl KnowledgeRepo {
let query_vec = query_vec.to_vec();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用)
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,\
source_project,source_ref,reasoning,created_at,updated_at,embedding";
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用,不入 KnowledgeRecord)
let mut stmt = guard
.prepare(&format!(
"SELECT {COLS} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
@@ -1410,6 +1458,33 @@ mod tests {
use crate::db::Database;
use crate::models::KnowledgeRecord;
// ---------- COLS 漂移防护(CR-260615-03) ----------
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
#[test]
fn test_knowledge_cols_matches_record() {
let count = KNOWLEDGE_COLS.split(',').count();
assert_eq!(
count, KNOWLEDGE_COL_COUNT,
"KNOWLEDGE_COLS({count}列) ≠ KNOWLEDGE_COL_COUNT({KNOWLEDGE_COL_COUNT}); \
修改一处须同步另一处"
);
// search_vector 多一列 embedding
let count_with_emb = KNOWLEDGE_COLS_WITH_EMBEDDING.split(',').count();
assert_eq!(
count_with_emb,
KNOWLEDGE_COL_COUNT + 1,
"KNOWLEDGE_COLS_WITH_EMBEDDING({count_with_emb}列) ≠ KNOWLEDGE_COL_COUNT+1({}); \
embedding 列应单独追加",
KNOWLEDGE_COL_COUNT + 1
);
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
for col in KNOWLEDGE_COLS.split(',') {
assert!(!col.is_empty(), "KNOWLEDGE_COLS 含空列名段");
}
}
// ---------- 向量纯函数 ----------
#[test]

View File

@@ -61,24 +61,6 @@ impl Dag {
});
}
/// 获取指定节点的所有上游节点 ID
pub fn predecessors(&self, node_id: &NodeId) -> Vec<NodeId> {
self.edges
.iter()
.filter(|e| &e.target == node_id)
.map(|e| e.source.clone())
.collect()
}
/// 获取指定节点的所有下游节点 ID
pub fn successors(&self, node_id: &NodeId) -> Vec<NodeId> {
self.edges
.iter()
.filter(|e| &e.source == node_id)
.map(|e| e.target.clone())
.collect()
}
/// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级
///
/// 返回 Vec<Vec<NodeId>>,每层内的节点可以并行执行。

View File

@@ -3,8 +3,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::dag::Dag;
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DagDef {
@@ -73,28 +71,6 @@ impl DagDef {
condition: Some(condition.into()),
});
}
/// 从已有的运行时 Dag 提取定义(忽略节点实例,仅保留元数据)
///
/// 注意:此方法只能提取边的定义,节点配置无法从 trait object 反推
pub fn from_dag_edges(dag: &Dag) -> Self {
let edges: Vec<EdgeDef> = dag.edges.iter().map(|e| EdgeDef {
source: e.source.clone(),
target: e.target.clone(),
condition: e.condition.clone(),
}).collect();
let nodes: HashMap<String, NodeDef> = dag.nodes.iter().map(|(id, node)| {
(id.clone(), NodeDef {
id: id.clone(),
node_type: node.node_type().to_string(),
config: serde_json::Value::Null,
label: None,
})
}).collect();
Self { nodes, edges }
}
}
impl Default for DagDef {

View File

@@ -123,7 +123,13 @@ impl DagExecutor {
for (node_id, result, duration) in results {
match result {
Ok(output) => {
self.state_machine.set_completed(node_id.clone())?;
// 已取消的节点跳过 set_completed:
// Ok 后 join_all 让出执行权,窗口内 cancel_workflow_node IPC → set_cancelled 改 Cancelled,
// 随后此处 set_completed 走 transition 命中(Cancelled→Completed)非法 → bail 致已批准审批报失败(R-P1-3 TOCTOU)
// 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态
if !self.state_machine.is_cancelled(&node_id) {
self.state_machine.set_completed(node_id.clone())?;
}
self.event_bus
.send(WorkflowEvent::NodeCompleted {
node_id: node_id.clone(),
@@ -314,4 +320,53 @@ mod tests {
NodeStatus::Cancelled
);
}
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Ok
/// 模拟 HumanNode select! 收合法 HumanApprovalResponse 后 return Ok,但 join_all 让出窗口内
/// cancel_workflow_node IPC 把节点改 Cancelled 的 TOCTOU 场景。
struct CancelSelfThenOkNode;
#[async_trait]
impl Node for CancelSelfThenOkNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
ctx.node_status.set_cancelled(ctx.node_id.clone());
Ok(NodeOutput::empty())
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::Value::Null,
output: serde_json::Value::Null,
}
}
fn node_type(&self) -> &str {
"cancel_self_then_ok"
}
}
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
#[tokio::test]
async fn test_cancelled_node_skips_set_completed() {
use df_core::types::NodeStatus;
let mut dag = Dag::new();
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
let result = executor.run(&dag, serde_json::Value::Null).await;
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
assert!(
result.is_ok(),
"Ok 后取消应短路 set_completed 而非 bail,实际 err: {:?}",
result.err()
);
// 状态保持 Cancelled(未被 set_completed 覆盖为 Completed,也不 bail)—— R-P1-3 修法核心验证
assert_eq!(
executor.state_machine.get(&"y".to_string()),
NodeStatus::Cancelled
);
}
}

View File

@@ -42,14 +42,23 @@ impl NodeRegistry {
pub fn build_dag(&self, def: &DagDef) -> anyhow::Result<Dag> {
let mut dag = Dag::new();
// 创建所有节点实例
// 创建所有节点实例,同时收集节点 ID 集合用于边校验
let mut node_ids: std::collections::HashSet<&String> = std::collections::HashSet::new();
for (id, node_def) in &def.nodes {
let node = self.create(&node_def.node_type, &node_def.config)?;
dag.add_node(id.clone(), node);
node_ids.insert(id);
}
// 添加所有边
// 添加所有边 — 校验两端节点均存在,野边早返回
for edge_def in &def.edges {
if !node_ids.contains(&edge_def.source) || !node_ids.contains(&edge_def.target) {
anyhow::bail!(
"边 source/target 节点不存在: {} -> {}",
edge_def.source,
edge_def.target
);
}
match &edge_def.condition {
Some(cond) => dag.add_edge_with_condition(
edge_def.source.clone(),
@@ -62,16 +71,6 @@ impl NodeRegistry {
Ok(dag)
}
/// 检查是否已注册指定类型
pub fn is_registered(&self, type_name: &str) -> bool {
self.factories.contains_key(type_name)
}
/// 列出所有已注册的节点类型
pub fn registered_types(&self) -> Vec<&str> {
self.factories.keys().map(|s| s.as_str()).collect()
}
}
// 不实现 Default:原 Default 注册了一个会 panic 的 script 工厂(unimplemented!),

View File

@@ -83,26 +83,15 @@ impl StateMachine {
self.transition(node_id, NodeStatus::Failed)
}
/// 设置节点为等待中(暂未纳入转换校验
pub fn set_waiting(&self, node_id: NodeId) {
self.states
.lock()
.expect("状态机锁中毒")
.insert(node_id, NodeStatus::Waiting);
}
/// 设置节点为已跳过(暂未纳入转换校验)
pub fn set_skipped(&self, node_id: NodeId) {
self.states
.lock()
.expect("状态机锁中毒")
.insert(node_id, NodeStatus::Skipped);
}
/// 设置节点为已取消(暂未纳入转换校验,同 set_waiting/set_skipped 模式)
/// 设置节点为已取消 — 唯一受控旁路:不经 transition 合法性校验
///
/// 人工审批取消场景由前端 cancel_workflow_node IPC 触发
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测到后返回 Err。
/// 设计理由:人工审批取消场景由前端 cancel_workflow_node IPC 异步触发
/// 此时节点可能处于 Running 之外的任意态(如 Pending 排队中、阻塞等待审批中),
/// 若走 transition 校验会被 is_legal(Pending→Cancelled) 拒绝。
/// 故 set_cancelled 作为唯一受控旁路直接置位 Cancelled
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测后返回 Err。
///
/// 注:原 set_waiting/set_skipped 同为旁路置位但全仓零调用,已删除。
pub fn set_cancelled(&self, node_id: NodeId) {
self.states
.lock()
@@ -177,7 +166,7 @@ mod tests {
#[test]
fn test_set_cancelled_marks_node_cancelled() {
// set_cancelled 直接置位,不经转换校验(同 set_waiting/set_skipped 模式)
// set_cancelled 为唯一受控旁路,不经转换校验直接置位 Cancelled
let sm = StateMachine::new();
let id = "human-1".to_string();

View File

@@ -34,3 +34,7 @@ df-ai = { path = "../crates/df-ai" }
df-ideas = { path = "../crates/df-ideas" }
df-project = { path = "../crates/df-project" }
futures = "0.3"
# keyring v3 默认不带任何 platform 后端,必须显式启 feature——
# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空(=FR-S1 密钥存了等于没存,根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md)
# Win+mac 启用原生后端;linux(secret-service)按需补
keyring = { version = "3", features = ["windows-native", "apple-native"] }

View File

@@ -25,7 +25,62 @@ use super::audit::process_tool_calls;
use super::{AiChatEvent, AiSession};
/// Agentic 循环最大迭代次数
pub(crate) const MAX_AGENT_ITERATIONS: usize = 10;
///
/// 默认 10 轮。未来可配置接入点:接入 AppState(新增 `agent_config` 字段)/df-storage settings KV
/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表
/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。
/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传。
pub const MAX_AGENT_ITERATIONS: usize = 10;
// ============================================================
// B-260615-09: generating 状态 RAII guard
// ============================================================
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
///
/// 两路复位:
/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序,
/// 前端收事件时后端已可接下一条)。
/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。
///
/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持
/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。
struct GeneratingGuard {
session: Arc<Mutex<AiSession>>,
done: bool,
}
impl GeneratingGuard {
fn new(session: Arc<Mutex<AiSession>>) -> Self {
Self { session, done: false }
}
/// 显式复位 generating=false。emit 前调用保证顺序。幂等。
async fn reset(&mut self) {
if !self.done {
self.session.lock().await.generating = false;
self.done = true;
}
}
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
/// (B-260615-26: 修复审批执行后对话不续生成回归)
fn disarm(&mut self) {
self.done = true;
}
}
impl Drop for GeneratingGuard {
fn drop(&mut self) {
if !self.done {
let session = self.session.clone();
tauri::async_runtime::spawn(async move {
session.lock().await.generating = false;
});
}
}
}
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
///
@@ -44,11 +99,44 @@ pub(crate) async fn run_agentic_loop(
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
) {
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone());
// FR-S1: resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂
// 空 key 早失败(逻辑见 secret::ensure_resolved_key 单测):避免空 key 发请求吃 401,错误伪装成"API Key 无效"
//
// B-260615-17:resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret
// 取 key_len(重复 keyring resolve)。现 resolve 一次:既供 key_len 诊断日志,又供 build_provider,
// 去重复 keyring resolve 调用。逻辑等价于 secret::build_provider_for(resolve→ensure→build 三步),
// 仅因 build_provider_for 隐藏 resolved key 无法复用而在此内联(未改 secret.rs 锁边界)。
let resolved_key = super::secret::resolve_provider_secret(&provider_config);
let key_len = resolved_key.len();
let provider: Box<dyn LlmProvider> = match super::secret::ensure_resolved_key(
&provider_config.name, &resolved_key,
) {
Ok(()) => df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&resolved_key,
&provider_config.default_model,
),
Err(msg) => {
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: msg,
conversation_id: Some(conv_id.clone()),
});
return;
}
};
// 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文)
tracing::info!(
provider = %provider_config.name,
provider_type = %provider_config.provider_type,
base_url = %provider_config.base_url,
model = %provider_config.default_model,
key_len = key_len,
"[ai] 发起 LLM 请求"
);
let tool_defs = tools_arc.tool_definitions();
// 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁
@@ -57,6 +145,10 @@ pub(crate) async fn run_agentic_loop(
// token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库)
let mut tokens = TokenAccumulator::default();
// 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true;
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
for iteration in 0..MAX_AGENT_ITERATIONS {
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
if stop_flag.load(Ordering::SeqCst) {
@@ -69,13 +161,27 @@ pub(crate) async fn run_agentic_loop(
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
let mut session = session_arc.lock().await;
session.generating = false;
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
return;
}
// B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
{
let session = session_arc.lock().await;
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
tracing::warn!(
stale_conv = %conv_id,
active_conv = ?session.active_conversation_id,
"[ai] 对话已切换,旧 loop 退出(B-260615-11)避免污染新对话"
);
return;
}
}
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
if iteration > 0 {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound {
@@ -119,8 +225,7 @@ pub(crate) async fn run_agentic_loop(
Some(result) => result,
None => {
// 错误已在 stream_llm 中 emit直接结束
let mut session = session_arc.lock().await;
session.generating = false;
guard.reset().await;
return;
}
};
@@ -136,6 +241,16 @@ pub(crate) async fn run_agentic_loop(
let has_tool_calls = !tool_calls_acc.is_empty();
{
let mut session = session_arc.lock().await;
// B-260615-11: push 前再校验(stream_llm 期间用户可能新建对话)。
// 读端读到被 clear 的空历史不致命,但 push 写回新对话是污染,必须挡。
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
tracing::warn!(
stale_conv = %conv_id,
active_conv = ?session.active_conversation_id,
"[ai] stream 后对话已切换,丢弃本轮 push(B-260615-11)避免污染新对话"
);
return;
}
if has_tool_calls {
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
@@ -165,15 +280,14 @@ pub(crate) async fn run_agentic_loop(
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
let mut session = session_arc.lock().await;
session.generating = false;
guard.reset().await;
// generating 复位后再 emit Completed保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
return;
}
// 无工具调用 → 最终文本响应,循环结束
if !has_tool_calls { break; }
// 无工具调用 → 最终文本响应,正常收敛退出
if !has_tool_calls { converged = true; break; }
// 处理工具调用Low 自动执行 / Medium+High 待审批)
let pending_count = {
@@ -189,12 +303,30 @@ pub(crate) async fn run_agentic_loop(
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
guard.disarm();
return; // generating 保持 true
}
// 全部自动执行完成 → 继续下一轮
}
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户
// 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示
if !converged {
tracing::warn!(
conv_id = %conv_id,
max_iter = MAX_AGENT_ITERATIONS,
"[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成",
MAX_AGENT_ITERATIONS,
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS),
conversation_id: Some(conv_id.clone()),
});
}
// 正常完成
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
@@ -224,24 +356,72 @@ pub(crate) async fn run_agentic_loop(
});
}
let mut session = session_arc.lock().await;
session.generating = false;
guard.reset().await;
// generating 复位后再 emit Completed落库/标题/提炼已在后台,前端立即感知完成
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
///
/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。
/// 各 return 点的语义判断:
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
/// (streaming=true 由 AiCompleted 清理)
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
/// (前端审批态 watchdog 已 clear,不卡)
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
let should_continue = {
let (is_generating, has_pending) = {
let session = state.ai_session.lock().await;
session.generating && session.pending_approvals.is_empty()
(session.generating, !session.pending_approvals.is_empty())
};
let should_continue = is_generating && !has_pending;
if !should_continue { return; }
if !should_continue {
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
if is_generating {
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
tracing::info!("[ai] try_continue 跳过:仍有待审批,转审批等待态");
} else {
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
tracing::info!("[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
let conv_id = {
let session = state.ai_session.lock().await;
session.active_conversation_id.clone().unwrap_or_default()
};
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: Some(conv_id),
});
}
return;
}
let provider_config = match get_active_provider(state).await {
Ok(p) => p,
Err(_) => return,
Err(e) => {
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态),
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
let mut session = state.ai_session.lock().await;
session.generating = false;
let conv_id = session.active_conversation_id.clone().unwrap_or_default();
drop(session);
tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider");
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e,
conversation_id: Some(conv_id),
});
return;
}
};
let (lang, conv_id) = {
let session = state.ai_session.lock().await;

View File

@@ -259,6 +259,33 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
}
// 流式生成中:置位让 loop 自行收尾
session.stop_flag.store(true, Ordering::SeqCst);
let conv_id = session.active_conversation_id.clone();
drop(session);
// B-260615-13 兜底任务loop 若 panic/异常退出漏发收尾stop_flag 无人读,
// 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检 generating
// 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。
// 正常路径(loop 活自行复位)此时 generating 已 false无操作退出。
let session_arc = state.ai_session.clone();
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let mut session = session_arc.lock().await;
if session.generating {
session.generating = false;
drop(session); // 释放锁后再 emit避免持锁调 runtime emit
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: conv_id,
},
);
}
});
Ok(())
}
@@ -281,11 +308,15 @@ fn mask_api_key(key: &str) -> String {
#[tauri::command]
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
let mut providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
for p in &mut providers {
if !p.api_key.is_empty() {
p.api_key = mask_api_key(&p.api_key);
}
let real = if !p.api_key.is_empty() {
p.api_key.clone() // 未迁移(老明文)
} else {
super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring
};
p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) };
}
Ok(providers)
}
@@ -319,20 +350,45 @@ pub async fn ai_save_provider(
.map_err(|e| e.to_string())?
.iter().any(|p| p.is_default),
};
// api_key 空(编辑不改 key,前端 list 拿到 mask 故留空)→保留原 DB 值(FR-S1)
let api_key = if api_key.is_empty() {
match &id {
Some(pid) => state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?
.map(|p| p.api_key)
.unwrap_or_default(),
None => String::new(),
// FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。
// api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。
let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() {
// 显式改/填密钥 → 写 keyring(现状不变)
if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
}
} else {
api_key
};
} else if let Some(pid) = &id {
// 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。
// 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key,
// 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。
// 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态);
// 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。
let old = state.ai_providers.get_by_id(pid).await
.map_err(|e| e.to_string())?;
if let Some(old) = old {
if !old.api_key.is_empty()
&& super::secret::get_provider_secret(pid).is_none()
{
// DB 有明文 且 keyring 无 → 即时迁移补密钥
if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
return Err(format!(
"检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\
已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
e
));
}
tracing::info!(
"[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)",
pid
);
}
// else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全
}
}
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
let record = AiProviderRecord {
id: id.unwrap_or_else(new_id),
id: provider_id,
name,
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
api_key,
@@ -391,6 +447,11 @@ pub async fn ai_delete_provider(
provider_id: String,
) -> Result<(), String> {
state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?;
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
if let Err(e) = super::secret::delete_provider_secret(&provider_id) {
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
}
// 删除的若是当前默认,清空 active 指向,避免悬空
let mut session = state.ai_session.lock().await;
if session.active_provider_id.as_deref() == Some(&provider_id) {
@@ -405,17 +466,34 @@ pub async fn ai_delete_provider(
/// 创建新对话
#[tauri::command]
pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
pub async fn ai_conversation_create(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
// 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。
// 首条消息发送后由 save_conversation upsert 写入。
let id = new_id();
let now = now_millis();
let mut session = state.ai_session.lock().await;
// 生成中(含审批等待态)禁止新建对话:否则 clear 会清空 pending_approvals,
// 用户审批时 ai_approve 找不到 pending → generating 永不复位 → 面板卡死需重启
// B-260615-10: 生成中软复位取代硬拦——强制结束当前生成,让用户能立即新建对话。
// 旧 loop 经 B-260615-11 一致性校验(active_conversation_id 变更)自动退出,不污染新对话;
// stop_flag 置位作双保险,让 streaming 中的旧 loop 也尽快收尾。
if session.generating {
return Err("生成中无法新建对话,请先停止或等待完成".to_string());
let old_conv = session.active_conversation_id.clone();
session.generating = false;
session.pending_approvals.clear();
session.stop_flag.store(true, Ordering::SeqCst);
drop(session);
if let Some(old_conv) = old_conv {
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: Some(old_conv),
});
}
session = state.ai_session.lock().await;
}
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);

View File

@@ -23,9 +23,11 @@ pub(crate) struct TokenAccumulator {
impl TokenAccumulator {
/// 叠加一轮用量(round_usage 为本轮流式末 chunk 的累计用量)
///
/// saturating_add:恶意/异常 provider 返回巨大值时,避免 u32 += 溢出回绕打乱后续 budget 判定。
pub(crate) fn add(&mut self, prompt: u32, completion: u32) {
self.prompt += prompt;
self.completion += completion;
self.prompt = self.prompt.saturating_add(prompt);
self.completion = self.completion.saturating_add(completion);
}
pub(crate) fn prompt(&self) -> u32 {
@@ -37,7 +39,7 @@ impl TokenAccumulator {
}
pub(crate) fn total(&self) -> u32 {
self.prompt + self.completion
self.prompt.saturating_add(self.completion)
}
}

View File

@@ -29,10 +29,15 @@ async fn resolve_embed_provider(
return None;
}
};
Some((
df_ai::build_provider(&rec.provider_type, &rec.base_url, &rec.api_key, &rec.default_model),
model,
))
// build_provider_for 含空 key 早失败:Err → 返回 None 触发 LIKE 降级(与原 embed 失败降级行为一致)
let provider = match super::secret::build_provider_for(&rec) {
Ok(p) => p,
Err(e) => {
tracing::warn!("embedding provider 密钥不可用(降级 LIKE): {}", e);
return None;
}
};
Some((provider, model))
}
/// 生成文本嵌入(向量检索用)
@@ -317,12 +322,11 @@ async fn extract_knowledge_from_conversation(
tool_choice: None,
};
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
);
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(provider_config) {
Ok(p) => p,
// 空 key 早失败:上层(maybe_spawn_extraction/trigger_extraction_now)已 warn log 降级,语义一致
Err(e) => return Err(anyhow::anyhow!("provider 密钥不可用: {}", e)),
};
// LLM 并发限流(知识提炼属独立调用,纳入双层 Semaphore
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;

View File

@@ -26,6 +26,7 @@ pub mod commands;
pub mod conversation;
pub mod knowledge_inject;
pub mod prompt;
pub mod secret;
pub mod skills;
pub mod stream_recv;
pub mod title;
@@ -84,12 +85,32 @@ pub enum AiChatEvent {
AiError { error: String, conversation_id: Option<String> },
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> },
}
// ============================================================
// 会话状态(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 会话读侧状态视图(只读枚举,由 [`AiSession::session_state`] 推导)
///
/// 这是 **只读视图**,不替代 `AiSession` 上 `generating` / `pending_approvals` /
/// `stop_flag` 等字段定义——字段仍是唯一真相源,调用方写状态时继续写字段,
/// 读状态时统一走 `session_state()` 收敛判断逻辑(避免散布的 `if generating` /
/// `if !pending_approvals.is_empty()` 三路组合在各调用点各自实现)。
///
/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval
/// 即使同时在流式)> `generating`Streaming> 都不满足Idle
pub enum SessionState {
/// 空闲:既未生成、也无挂起审批
Idle,
/// 流式生成中generating 为 true 且无挂起审批
Streaming,
/// 等待审批pending_approvals 非空
AwaitingApproval,
}
/// AI 会话内状态Mutex 保护)
pub struct AiSession {
/// 对话历史ContextManager唯一消息真相源裁剪仅影响发送视图不影响持久化
@@ -100,7 +121,17 @@ pub struct AiSession {
pub active_conversation_id: Option<String>,
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
pub active_conv_created_at: Option<String>,
/// 挂起的审批tool_call_id → 审批信息)
/// 挂起的审批
///
/// **结构**:单层 `HashMap<tool_call_id, PendingApproval>`,按 `tool_call_id`
/// 路由审批结果(`tool_call_id` 是路由键)。`PendingApproval.conversation_id`
/// 是业务语义哪个对话产生的审批而非路由键——IPC 端按 `tool_call_id` 精确命中。
///
/// **现状**`ai_pending_tool_calls` 等读取路径按 `conversation_id` 做 O(n) 线性
/// 过滤。在典型场景(单对话、少量并发审批)下 n 极小O(n) 可接受,无需二级索引。
///
/// **未来**:若多会话并发、审批量显著增大,可考虑加二级索引
/// `conversation_id → Vec<tool_call_id>`)把按会话过滤降到 O(1)。
pub pending_approvals: HashMap<String, PendingApproval>,
/// 是否正在生成
pub generating: bool,
@@ -123,6 +154,31 @@ impl AiSession {
stop_flag: Arc::new(AtomicBool::new(false)),
}
}
/// 推导会话读侧状态视图(只读,不写任何字段)。
///
/// 优先级:`pending_approvals` 非空 → [`SessionState::AwaitingApproval`]
/// 否则 `generating` → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。
///
/// 收敛点:调用方读「会话处于什么阶段」时统一走本方法,替代散布的
/// `if !pending_approvals.is_empty() / if generating` 三路组合。
///
/// # 待替换调用点(本次仅新增视图,不替换调用点)
///
/// - `agentic.rs:283` — agent loop 入口/恢复处对 generating + 审批的组合判断
/// - `commands.rs:250` — `ai_pending_tool_calls` 等读取前的状态门控
/// - `commands.rs:451` — 审批提交/会话复位路径的状态判断
///
/// 上述三处替换由 P0 任务承接,本方法先就位供其切换。
pub fn session_state(&self) -> SessionState {
if !self.pending_approvals.is_empty() {
SessionState::AwaitingApproval
} else if self.generating {
SessionState::Streaming
} else {
SessionState::Idle
}
}
}
/// 待审批的工具调用

View File

@@ -0,0 +1,228 @@
//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串
//!
//! 设计:
//! - keyring entry: service=`devflow-ai-provider`, username=provider_id
//! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring
//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试)
//! - 消费点(build_provider)经 `resolve_provider_secret` 取:keyring 优先,fallback DB(兼容未迁移)
//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use df_storage::crud::AiProviderRepo;
use df_storage::models::AiProviderRecord;
use keyring::Entry;
const KEYRING_SERVICE: &str = "devflow-ai-provider";
/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险。
/// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。
const MIGRATION_FAIL_THRESHOLD: u32 = 3;
/// 迁移失败计数 sidecar 文件(<cwd>/.devflow-keyring-failcount):逐行 `provider_id=count`。
/// cwd 未必是稳定路径,但 R-PD-4 目标仅是「检测到反复失败/滞留时告警」,误读为 0 即按未达阈值处理,无副作用。
fn failcount_path() -> PathBuf {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".devflow-keyring-failcount")
}
/// 读取全部失败计数(id → count)。文件缺失/损坏 → 空 map(按未达阈值处理)。
fn read_failcounts() -> HashMap<String, u32> {
let mut map = HashMap::new();
if let Ok(text) = fs::read_to_string(failcount_path()) {
for line in text.lines() {
let mut parts = line.splitn(2, '=');
let id = parts.next().unwrap_or("").trim();
let cnt = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
if !id.is_empty() {
if let Some(c) = cnt {
map.insert(id.to_string(), c);
}
}
}
}
map
}
/// 持久化全部失败计数。写入失败仅 log,不阻断迁移主流程。
fn write_failcounts(map: &HashMap<String, u32>) {
let mut text = String::new();
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0)); // 稳定顺序,减少无谓 diff
for (id, cnt) in entries {
text.push_str(id);
text.push('=');
text.push_str(&cnt.to_string());
text.push('\n');
}
if let Err(e) = fs::write(failcount_path(), text) {
tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e);
}
}
/// 记录一次迁移失败并返回累计失败次数。持久化失败也不影响返回值(仍递增内存计数用于本次告警)。
fn record_migration_fail(id: &str) -> u32 {
let mut map = read_failcounts();
let next = map.get(id).copied().unwrap_or(0).saturating_add(1);
map.insert(id.to_string(), next);
write_failcounts(&map);
next
}
/// 清零某 provider 的失败计数(迁移成功后调用,避免历史失败在后续再触发误告警)。
fn clear_migration_failcount(id: &str) {
let mut map = read_failcounts();
if map.remove(id).is_some() {
write_failcounts(&map);
}
}
fn entry_for(id: &str) -> anyhow::Result<Entry> {
Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e))
}
/// 读取 provider 密钥(优先 keyring;无则 None)
pub fn get_provider_secret(id: &str) -> Option<String> {
let entry = entry_for(id).ok()?;
match entry.get_password() {
Ok(s) if !s.is_empty() => Some(s),
_ => None,
}
}
/// 消费点用:解析 provider 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库)
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key.clone();
}
get_provider_secret(&record.id).unwrap_or_default()
}
/// 写入密钥到 keyring(覆盖)
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e))
}
/// 删除 keyring 密钥(provider 删除时清理)
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
let entry = entry_for(id)?;
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e))
}
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
let providers = repo.list_all().await?;
let mut migrated = 0;
for mut p in providers {
if p.api_key.is_empty() {
continue; // 已迁移或无密钥
}
if let Err(e) = set_provider_secret(&p.id, &p.api_key) {
// 累计失败次数:达阈值(默认 3)升级告警,提示明文密钥长期滞留 SQLite(无加密)风险。
// 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文。
let n = record_migration_fail(&p.id);
if n >= MIGRATION_FAIL_THRESHOLD {
tracing::warn!(
"[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\
2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入",
p.id, n
);
} else {
tracing::warn!(
"[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
p.id, n, MIGRATION_FAIL_THRESHOLD, e
);
}
continue;
}
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);
}
// 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
clear_migration_failcount(&pid);
migrated += 1;
}
if migrated > 0 {
tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated);
}
Ok(migrated)
}
/// 消费点统一入口:resolve_provider_secret → ensure_resolved_key → build_provider 三步打包。
///
/// 替代散落各处的「resolve + build_provider」二件套复制(漏 ensure_resolved_key → keyring 读不到时
/// 空 key 照发请求吃 401,用户看「key 已保存」反复重试无解)。返 Result 让调用方按场景处理:
/// - 主链(agentic loop):空 key 早失败 emit AiError
/// - 后台 task(title/knowledge 提炼/embedding):Err 上层降级/记 log
pub fn build_provider_for(
record: &AiProviderRecord,
) -> Result<Box<dyn df_ai::provider::LlmProvider>, String> {
let api_key = resolve_provider_secret(record);
ensure_resolved_key(&record.name, &api_key)?;
Ok(df_ai::build_provider(
&record.provider_type,
&record.base_url,
&api_key,
&record.default_model,
))
}
/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。
/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> {
if resolved.trim().is_empty() {
Err(format!(
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
provider_name
))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_resolved_key_rejects_empty() {
assert!(ensure_resolved_key("GLM", "").is_err());
}
#[test]
fn ensure_resolved_key_rejects_whitespace() {
// 纯空白也视为无密钥(防粘贴时只有空格)
assert!(ensure_resolved_key("GLM", " ").is_err());
}
#[test]
fn ensure_resolved_key_accepts_nonempty() {
assert!(ensure_resolved_key("GLM", "sk-abc").is_ok());
}
#[test]
fn ensure_resolved_key_error_mentions_provider_name() {
let err = ensure_resolved_key("我的提供商", "").unwrap_err();
assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位");
}
#[test]
fn resolve_prefers_db_when_non_empty() {
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库)
use df_storage::models::AiProviderRecord;
let rec = AiProviderRecord {
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
default_model: "m".into(), models: None, is_default: false,
config: None, created_at: "0".into(), updated_at: "0".into(),
};
assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");
}
}

View File

@@ -6,17 +6,104 @@ use std::time::Duration;
use tauri::{AppHandle, Emitter};
use futures::StreamExt;
use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
///
/// `provider.stream()` 失败有两类来源:
/// - 业务层provider 在 non-2xx 时 `bail!("LLM 流式 API 错误 {status}: {body}")`,原始文本已含状态码,
/// 此处用正则从文本抠 `HTTP <code>` 或裸 `<code>`provider 串已含 status code
/// - 传输层reqwest不直接命名以避免给 src-tauri 加依赖):从 Display 文本识别
/// timeout/connect 关键词分类。
///
/// 返回 `(status_or_class, raw)`status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
let raw = e.to_string();
// 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。
// R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic
// (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断,
// 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。
// 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配):
const HTTP_CODES: &[&str] = &[
"400", "401", "403", "404", "408", "409", "413",
"422", "429", "500", "502", "503", "504",
];
// 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全
let chars: Vec<char> = raw.chars().collect();
let n = chars.len();
let mut i = 0;
while i + 3 <= n {
// 窗口必须是三个 ascii 数字
if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() {
let code: String = chars[i..i + 3].iter().collect();
// 前后边界必须非数字(否则会从端口号 14012 里抠出 401)
let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit();
let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit();
if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) {
return (format!("HTTP {}", code), raw);
}
i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫
continue;
}
i += 1;
}
// 2) 传输层分类reqwest Display 文本特征),不命名 reqwest 类型
let lower = raw.to_lowercase();
let class = if lower.contains("timeout") || lower.contains("超时") {
"timeout"
} else if lower.contains("connect") || lower.contains("dns") || lower.contains("resolve") {
"connect"
} else if lower.contains("timed out") {
"timeout"
} else {
"unknown"
};
(class.to_string(), raw)
}
/// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub(crate) enum DiagKind {
/// `provider.stream()` 直接返回 Err连接/鉴权/HTTP non-2xx 等
Init,
/// 流已建立next() 返回 ErrSSE 传输断/解析错等
MidStream,
}
/// 拼接 AiError 的可读诊断文本(纯函数,便于单测)。
///
/// 格式:`[<provider_name>] <上下文>(<status_or_class>): <raw>`
/// - provider_name`provider.name()`anthropic 协议为 "anthropic-compat"
/// openai 兼容为模型名)。当前 `LlmProvider` trait 未暴露 base_url/provider_type
/// 不改签名的前提下这是唯一可得的 provider 标识。
/// - status_or_class`HTTP 4xx/5xx` 或 `timeout`/`connect`/`unknown` 分类。
/// - rawanyhow 原始错误文本。
pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &str, raw: &str) -> String {
let ctx = match kind {
DiagKind::Init => "AI 调用失败",
DiagKind::MidStream => "流式接收错误",
};
format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw)
}
/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
///
/// 三类异常处理:
/// - idle timeout120s 无 chunk判定连接静默断emit AiError 返回 None
/// - 流尽但从未收到 finished 信号判定异常中断emit AiError 返回 None丢弃残缺不当完整入库
/// - 用户停止stop_flagbreak 返回 Some(已收文本),由调用方入库展示后退出
///
/// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支
/// - `stream.next()`:正常 chunk 处理
/// - `heartbeat.tick()`30s静默期如工具执行后等下一轮首 chunkemit `AiHeartbeat`
/// 前端 watchdog 据此 reset区分「LLM 在跑」与「真断」(避免空气泡误报中断)
/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout
pub(crate) async fn stream_llm(
provider: &dyn LlmProvider,
request: CompletionRequest,
@@ -26,6 +113,8 @@ pub(crate) async fn stream_llm(
) -> Option<(String, HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage)> {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// 心跳间隔静默期向前端报「LLM 仍在跑」reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
match provider.stream(request).await {
Ok(mut stream) => {
@@ -35,56 +124,121 @@ pub(crate) async fn stream_llm(
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
heartbeat.tick().await;
loop {
// 用户主动停止:保留已收文本退出
// B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时,
// 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查——
// 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。
// stop_flag 可能被 stopChat() 在 select! 阻塞期间置位,
// select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
stopped = true;
break;
}
// idle timeout 防"连接存活但中途静默"无限 hang
match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
Ok(None) => break, // 流正常结束
Ok(Some(chunk_result)) => match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
delta: chunk.delta,
// 三分支 select!(B-260615-02 + B-260615-04 合并):
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断仍 emit AiError)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) stop_notify.notified():用户停止即时打断
//
// 注意:stop_flag 是 AtomicBool 无 async 通知能力——
// 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」
// 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会
// 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。
tokio::select! {
// 1) 正常 chunk(idle timeout 包裹)
chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
match chunk_result {
Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
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); }
Ok(None) => break, // 流正常结束
Ok(Some(chunk_result)) => match chunk_result {
Ok(chunk) => {
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
});
}
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); }
}
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
}
// provider 流式错误事件Anthropic SSE `type=="error"` 等):
// 不走 finished 完成路径,发 AiError + 丢弃残缺,与 Err 分支对齐OpenAI 路径一致性)。
if let Some(err_msg) = &chunk.error {
warn!(
provider = %provider.name(),
conv_id = %conv_id,
error = %err_msg,
"[ai] provider 流式错误事件",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
"stream-error",
err_msg,
),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
if chunk.finished {
finished_received = true;
break;
}
}
Err(e) => {
// 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本
let (status_or_class, raw) = extract_error_diag(&e);
warn!(
provider = %provider.name(),
status = %status_or_class,
conv_id = %conv_id,
error = %raw,
"[ai] 流式接收中途错误",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
&status_or_class,
&raw,
),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
}
if chunk.finished {
finished_received = true;
break;
}
}
Err(e) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: e.to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
},
}
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
_ = heartbeat.tick() => {
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()),
});
}
}
}
@@ -93,8 +247,11 @@ pub(crate) async fn stream_llm(
return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()));
}
// 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库
if !finished_received && (!full_text.is_empty() || !tool_calls_acc.is_empty()) {
// 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库
// B-260615-05:一律判异常(不区分内容空否)——空内容无 finished 同属异常:
// 上游 agentic.rs !has_tool_calls 早 break 会按正常路径 emit AiCompleted,
// 用户看空气泡无错误提示,转 P0 卡死入口。此处统一 emit AiError 拦截。
if !finished_received {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
conversation_id: Some(conv_id.to_string()),
@@ -105,11 +262,159 @@ pub(crate) async fn stream_llm(
Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
}
Err(e) => {
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
// 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。
let (status_or_class, raw) = extract_error_diag(&e);
warn!(
provider = %provider.name(),
status = %status_or_class,
conv_id = %conv_id,
error = %raw,
"[ai] LLM 流式调用失败",
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("AI 调用失败: {}", e),
error: fmt_diag(
provider.name(),
DiagKind::Init,
&status_or_class,
&raw,
),
conversation_id: Some(conv_id.to_string()),
});
None
}
}
}
// ============================================================
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---- extract_error_diag业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401鉴权失败/Key 错)
#[test]
fn diag_extracts_401_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 401: Unauthorized");
let (status, raw) = extract_error_diag(&e);
assert_eq!(status, "HTTP 401");
assert!(raw.contains("401"));
}
/// 404 = base_url/endpoint 不对
#[test]
fn diag_extracts_404_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 404: Not Found");
assert_eq!(extract_error_diag(&e).0, "HTTP 404");
}
/// 429 = 限流
#[test]
fn diag_extracts_429_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 429: rate limit");
assert_eq!(extract_error_diag(&e).0, "HTTP 429");
}
/// 5xx = 上游服务端错
#[test]
fn diag_extracts_500_from_provider_bail() {
let e = anyhow::anyhow!("LLM 流式 API 错误 500: Internal Server Error");
assert_eq!(extract_error_diag(&e).0, "HTTP 500");
}
// ---- extract_error_diag传输层reqwest Display 文本,不命名 reqwest----
/// 超时:连接成功但响应慢/静默断
#[test]
fn diag_classifies_timeout() {
let e = anyhow::anyhow!("error sending request for url (https://api.x.com/v1/chat/completions): operation timed out");
let (status, _) = extract_error_diag(&e);
assert_eq!(status, "timeout");
}
/// 中文「超时」关键词
#[test]
fn diag_classifies_timeout_cn() {
let e = anyhow::anyhow!("请求超时");
assert_eq!(extract_error_diag(&e).0, "timeout");
}
/// 连接失败DNS 解析失败 / 端点不通
#[test]
fn diag_classifies_connect() {
let e = anyhow::anyhow!("dns error: failed to lookup address information");
assert_eq!(extract_error_diag(&e).0, "connect");
}
/// resolve 关键词也归 connect
#[test]
fn diag_classifies_connect_resolve() {
let e = anyhow::anyhow!("error connecting: resolve failed");
assert_eq!(extract_error_diag(&e).0, "connect");
}
// ---- extract_error_diag边界 ----
/// 三位数但不在已知 HTTP 状态白名单(如 "200")→ 不当状态码,走分类
#[test]
fn diag_ignores_non_http_status_number() {
let e = anyhow::anyhow!("成功 200 条记录");
// 200 不在白名单,应落到 unknown
assert_eq!(extract_error_diag(&e).0, "unknown");
}
/// 完全无特征文本 → unknown且 raw 原样返回
#[test]
fn diag_unknown_preserves_raw() {
let e = anyhow::anyhow!("奇怪的错误 xyz");
let (status, raw) = extract_error_diag(&e);
assert_eq!(status, "unknown");
assert_eq!(raw, "奇怪的错误 xyz");
}
/// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字)
#[test]
fn diag_does_not_match_status_inside_longer_digits() {
// R-P2-6:边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401)
let e = anyhow::anyhow!("port 14012 used");
assert_eq!(extract_error_diag(&e).0, "unknown");
// 纯长数字串(无白名单码)同样不命中
let e2 = anyhow::anyhow!("port 99999 used");
assert_eq!(extract_error_diag(&e2).0, "unknown");
}
// ---- fmt_diag两个上下文 ----
#[test]
fn fmt_diag_init_branch() {
let s = fmt_diag("anthropic-compat", DiagKind::Init, "HTTP 401", "Unauthorized");
assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized");
}
/// R-P2-6:状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出
/// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全)
#[test]
fn diag_extracts_code_adjacent_to_multibyte_chars() {
let e = anyhow::anyhow!("错误401Unauthorized");
assert_eq!(extract_error_diag(&e).0, "HTTP 401");
// 中文在前:状态码前字符是多字节中文,char 迭代边界判断正确
let e2 = anyhow::anyhow!("请求失败401");
assert_eq!(extract_error_diag(&e2).0, "HTTP 401");
}
#[test]
fn fmt_diag_midstream_branch() {
let s = fmt_diag("gpt-4o", DiagKind::MidStream, "timeout", "operation timed out");
assert_eq!(s, "[gpt-4o] 流式接收错误(timeout): operation timed out");
}
/// provider 名/错误文本含特殊字符仍按模板拼接(无格式注入风险)
#[test]
fn fmt_diag_handles_special_chars() {
let s = fmt_diag("model/x", DiagKind::Init, "unknown", "err: {json} \"quoted\"");
assert_eq!(s, "[model/x] AI 调用失败(unknown): err: {json} \"quoted\"");
}
}

View File

@@ -57,12 +57,17 @@ pub(crate) async fn ensure_conversation_title(
}
// 标题生成是独立一次 LLM 调用,自建 provider便于后台 spawn不借主 loop 的 &dyn LlmProvider
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&provider_config.provider_type,
&provider_config.base_url,
&provider_config.api_key,
&provider_config.default_model,
);
// build_provider_for 含空 key 早失败:Err(如 keyring 读不到)→ 跳过 LLM,extract_title 兜底
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(provider_config) {
Ok(p) => p,
Err(e) => {
tracing::warn!("标题生成跳过(provider 密钥不可用,走 extract_title 兜底): {}", e);
let title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
let _ = conv_repo.update_field(conv_id, "title", &title).await;
let _ = app_handle.emit("ai-conversation-changed", ());
return;
}
};
let title = match generate_title_via_llm(&*provider, &provider_config.default_model, summary_msgs, &llm_concurrency).await {
Some(t) => t,
None => extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()),

View File

@@ -1,9 +1,11 @@
//! AI 工具注册表构建 + 文件路径校验
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
@@ -32,6 +34,26 @@ fn validate_path(path: &str) -> anyhow::Result<()> {
Ok(())
}
/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
fn truncate_output(s: &str, max: usize) -> (String, bool) {
if s.len() <= max {
return (s.to_string(), false);
}
let end = s.len();
let mut start = end.saturating_sub(max);
// 推进到 char 边界,避免从多字节 UTF-8 中间切开
while start < end && !s.is_char_boundary(start) {
start += 1;
}
let tail = &s[start..];
(
format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
true,
)
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -88,17 +110,11 @@ async fn bind_dir_to_project(
if !dir.is_dir() {
anyhow::bail!("目录不存在: {path}");
}
// 防重复:normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)
// 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict
// 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。
let target = df_project::scan::normalize_path(path);
let projects = repo.list_active().await?;
for proj in &projects {
if proj.id != id {
if let Some(pp) = &proj.path {
if df_project::scan::normalize_path(pp) == target {
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
}
}
}
if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? {
anyhow::bail!("目录已被项目「{}」绑定", conflict.name);
}
// stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime)
let stack: Vec<String> = if let Some(s) = stack_opt {
@@ -388,6 +404,55 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
})),
);
registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
};
let result = execute(request).await?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
// ── 文件系统 ──
registry.register(
@@ -409,13 +474,23 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
let mut content = String::new();
file.read_to_string(&mut content).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
if let Err(e) = file.read_to_string(&mut content).await {
if e.kind() == std::io::ErrorKind::InvalidData {
return Ok(serde_json::json!({
"path": path, "content": null, "binary": true,
"size": metadata.len(),
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
}));
}
anyhow::bail!("读取文件失败: {}", e);
}
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
let result = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize;
let limit = args["limit"].as_u64().unwrap_or(200) as usize;
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
} else {
content.clone()
@@ -426,7 +501,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]),
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
@@ -435,8 +510,9 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?;
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
})),
);
@@ -454,13 +530,51 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
if content.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
}
if let Some(parent) = std::path::Path::new(path).parent() {
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
Ok(m) if m.len() > 0 => {
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
Some(m.len())
}
Ok(_) => Some(0), // 空文件(无需备份)
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
tokio::fs::write(path, content).await
.map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
Ok(serde_json::json!({ "path": path, "bytes_written": content.len() }))
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, content).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) {
let bak = format!("{}.bak", path);
let _ = tokio::fs::remove_file(&bak).await;
}
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size {
if old > 0 && (content.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
})),
);
@@ -469,7 +583,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
fn list_dir_recursive<'a>(
path: &'a str,
@@ -488,15 +602,27 @@ fn list_dir_recursive<'a>(
return Ok(true); // 达上限截断
}
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await?;
let is_dir = metadata.is_dir();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
if skip_noise && is_noise_file(&name) {
continue;
}
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink();
let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
result.push(serde_json::json!({
"name": name,
"type": if is_dir { "directory" } else { "file" },
"size": metadata.len(),
"type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
"size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
"depth": depth,
}));
// 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等)
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
@@ -517,3 +643,178 @@ fn is_noise_dir(name: &str) -> bool {
];
NOISE_DIRS.contains(&name)
}
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
///
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
fn is_noise_file(name: &str) -> bool {
const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_dir(".git"));
assert!(is_noise_dir("node_modules"));
assert!(is_noise_dir("target"));
assert!(is_noise_dir("dist"));
assert!(is_noise_dir("build"));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore"));
assert!(!is_noise_dir(".gitattributes"));
assert!(!is_noise_dir(".gitkeep"));
}
/// is_noise_dir 纯函数:普通目录不命中
#[test]
fn test_is_noise_dir_normal_dirs_not_matched() {
assert!(!is_noise_dir("src"));
assert!(!is_noise_dir("tests"));
assert!(!is_noise_dir("docs"));
assert!(!is_noise_dir("main.rs"));
assert!(!is_noise_dir(""));
}
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_dir_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target"));
assert!(!is_noise_dir("DIST"));
assert!(!is_noise_dir("NODE_MODULES"));
}
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
#[test]
fn test_is_noise_file_matches_temp_artifacts() {
// write_file 原子写副产物
assert!(is_noise_file("PROGRESS.md.bak"));
assert!(is_noise_file("PROGRESS.md.tmp-write"));
// 编辑器临时文件
assert!(is_noise_file(".main.rs.swp"));
assert!(is_noise_file("main.rs~"));
// 路径含但非后缀的应不命中(避免误杀)
assert!(!is_noise_file("backup.bak.md"));
assert!(!is_noise_file("main.rs"));
assert!(!is_noise_file(".gitignore"));
assert!(!is_noise_file(""));
}
/// 造一个临时目录树用于 list_dir_recursive 测试
fn build_noise_tree(root: &Path) {
// .git/HEAD (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
// node_modules/x/index.js (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
// target/debug/bin (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("target").join("debug")).unwrap();
fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
// 普通 src/main.rs (应出现)
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
// .gitignore 文件 (文件名不是噪音目录,应出现)
fs::write(root.join(".gitignore"), "/target\n").unwrap();
}
/// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
#[tokio::test]
async fn test_list_dir_recursive_filters_noise_dirs() {
let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
assert!(!truncated);
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 噪音目录本身及其内部文件均不应出现
assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
// 噪音目录内部文件也不应被递归带入
assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");
// 普通目录与 .gitignore 文件应正常返回
assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:skip_noise=false 时噪音目录正常返回
#[tokio::test]
async fn test_list_dir_recursive_keeps_noise_when_disabled() {
let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 关闭过滤后噪音目录应作为 entry 出现
assert!(names.contains(&".git".to_string()));
assert!(names.contains(&"node_modules".to_string()));
assert!(names.contains(&"target".to_string()));
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:max_depth 控制递归深度
#[tokio::test]
async fn test_list_dir_recursive_max_depth() {
// 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
let root = tmp.to_string_lossy().to_string();
// max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"a".to_string()), "depth1 应含 a");
assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");
// max_depth=3: 应能到 depth3 的 main.rs
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");
fs::remove_dir_all(&tmp).ok();
}
}

View File

@@ -12,10 +12,8 @@ pub mod task;
pub mod workflow;
/// 当前时间戳(毫秒字符串)— 与 df-storage 内部的时间格式保持一致
///
/// 转发 df_core::now_millis(DRY:时间获取统一入口在此 crate,避免 commands 层与 df-storage 各写一份 SystemTime 调用)。
pub(crate) fn now_millis() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string()
df_core::now_millis().to_string()
}

View File

@@ -5,7 +5,6 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use tauri::State;
use df_ai::build_provider;
use df_ai::provider::{ChatMessage, CompletionRequest};
use df_core::types::new_id;
use df_project::scan::{collect_sample, detect_stack, extract_description, normalize_path};
@@ -246,17 +245,20 @@ pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result<boo
// ============================================================
/// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。
///
/// 委托 `ProjectRepo::find_path_conflict`(DRY R-PD-11:与 tool_registry::bind_dir_to_project
/// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。
async fn find_binding_conflict(
state: &AppState,
path: &str,
exclude_id: Option<&str>,
) -> Result<Option<ProjectRecord>, String> {
let norm = normalize_path(path);
let projects = state.projects.list_active().await.map_err(|e| e.to_string())?;
Ok(projects.into_iter().find(|p| {
let excluded = exclude_id.is_some_and(|eid| p.id == eid);
!excluded && p.path.as_ref().is_some_and(|pp| normalize_path(pp) == norm)
}))
state
.projects
.find_path_conflict(&norm, exclude_id)
.await
.map_err(|e| e.to_string())
}
/// 探测目录技术栈(前端选目录后实时预览)
@@ -375,7 +377,18 @@ pub async fn scan_project_with_ai(
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;
// 3. 构造 provider + LLM 调用(非流式)
let provider = build_provider(&pc.provider_type, &pc.base_url, &pc.api_key, &pc.default_model);
// build_provider_for 含空 key 早失败:Err → 走纯规则降级(与下方 LLM 失败降级行为一致)
let provider = match crate::commands::ai::secret::build_provider_for(&pc) {
Ok(p) => p,
Err(e) => {
return Ok(AiScanResult {
description: String::new(),
stack: rule_stack,
project_type: None,
raw: Some(format!("LLM 调用失败: provider 密钥不可用: {e}")),
});
}
};
let request = CompletionRequest {
model: pc.default_model.clone(),
messages: build_scan_prompt(&sample, &rule_stack),

View File

@@ -3,7 +3,7 @@
use serde::Deserialize;
use tauri::State;
use df_core::types::new_id;
use df_core::types::{new_id, TaskStatus};
use df_storage::models::TaskRecord;
use crate::state::AppState;
@@ -40,6 +40,20 @@ pub async fn list_tasks(
result.map_err(|e| e.to_string())
}
/// 按 id 查任务,找不到返回 Err(供前端详情页)
#[tauri::command]
pub async fn get_task_by_id(
state: State<'_, AppState>,
id: String,
) -> Result<TaskRecord, String> {
state
.tasks
.get_by_id(&id)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("任务 {} 不存在", id))
}
/// 创建任务,返回完整记录
#[tauri::command]
pub async fn create_task(
@@ -69,7 +83,7 @@ pub async fn create_task(
Ok(record)
}
/// 更新任务单个字段(字段名走 df-storage 白名单校验)
/// 更新任务单个字段(字段名走 df-storage 白名单校验status 值走枚举校验
#[tauri::command]
pub async fn update_task(
state: State<'_, AppState>,
@@ -77,6 +91,15 @@ pub async fn update_task(
field: String,
value: String,
) -> Result<bool, String> {
// 字段名注入由 df-storage 白名单兜底;这里补 status 值校验,
// 拦截拼写错误in-progess / "in progress" / 大小写错等)静默落库。
if field == "status" && !TaskStatus::is_valid(&value) {
return Err(format!(
"非法 status 值 {:?},合法值: {:?}",
value,
TaskStatus::valid_values()
));
}
state
.tasks
.update_field(&id, &field, &value)

View File

@@ -7,7 +7,7 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::broadcast::error::RecvError;
use df_core::events::{WorkflowEvent, HumanApprovalResponse};
use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
use df_core::types::new_id;
use df_storage::crud::WorkflowRepo;
use df_storage::models::WorkflowRecord;
@@ -87,9 +87,23 @@ pub async fn run_workflow(
break;
}
}
// 消费过慢丢失部分事件时继续接收
// R-PD-13: Lagged 表示消费过慢broadcast 滑动窗口丢 n 条最旧事件。
// broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件
// NodeCompleted/NodeFailed/HumanApprovalRequest/WorkflowCompleted/WorkflowFailed
// 风险:若关键终态事件被丢,下面 emit 不到finished 判定不触发,循环不会 break
// 前端永久收不到工作流结束信号(依赖 DB 状态轮询兜底,但实时性差)。
// 最小兜底warn 记录丢失条数 + execution_id便于事后从 tracing 追溯。
// 后续优化方向(不在此 todo 范围):
// 1. 提升广播容量(目前常量值偏小,事件突发易满);
// 2. 关键终态事件经持久化队列/DB 重发receiver 启动时回放未消费事件;
// 3. forward 循环加 watchdog 超时(长期无事件则主动查 DB 终态并退出)。
Err(RecvError::Lagged(n)) => {
tracing::warn!("工作流事件消费滞后,丢失 {} 条", n);
tracing::warn!(
execution_id = %forward_exec_id,
lagged = n,
"工作流事件消费滞后,丢失 {} 条事件broadcast 不暴露被丢事件类型,关键终态事件可能永久丢失,依赖 DB 轮询兜底)",
n
);
}
Err(RecvError::Closed) => break,
}
@@ -176,6 +190,11 @@ pub async fn get_workflow_execution(
}
/// 发送人工审批响应
///
/// F-260615-01: 支持 single/multiple。
/// - single: decision 单值,decisions 为空(向后兼容);
/// - multiple: decisions 数组(≥1 项),decision 留空。
/// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。
#[tauri::command]
pub async fn approve_human_approval(
app: AppHandle,
@@ -183,17 +202,55 @@ pub async fn approve_human_approval(
execution_id: String,
node_id: String,
decision: String,
// F-260615-01: 多选结果数组,缺省空数组(单选调用方不传)
decisions: Option<Vec<String>>,
comment: Option<String>,
// R-PD-5: options 由前端从收到的 HumanApprovalRequest 事件透传IPC 无法访问节点 config
options: Vec<String>,
// F-260615-01: 选择类型,缺省 single
select_type: Option<String>,
) -> Result<(), String> {
// decision 非空校验(FR-S3: 防 "" 透传;HumanNode options 非空时还校验 ∈ options)
if decision.trim().is_empty() {
return Err("审批决策不能为空".to_string());
let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化
let select_type = match select_type.as_deref() {
Some("multiple") => SelectType::Multiple,
_ => SelectType::Single,
};
let decisions = decisions.unwrap_or_default();
// 归一化决策集合(与 HumanNode 下游一致)
let mut picked: Vec<String> = decisions;
if picked.is_empty() && !decision.is_empty() {
picked.push(decision.clone());
}
// 发送审批响应到事件总线
// 数量校验
match select_type {
SelectType::Single if picked.len() != 1 => {
return Err("单选审批只能提交一个决策".to_string());
}
SelectType::Multiple if picked.is_empty() => {
return Err("多选审批至少提交一个决策".to_string());
}
_ => {}
}
// 每项非空 + ∈options(options 非空时)
for d in &picked {
if d.trim().is_empty() {
return Err("审批决策不能为空".to_string());
}
if !options.is_empty() && !options.contains(d) {
return Err(format!("审批决策非法: {}", d));
}
}
// 发送审批响应到事件总线(decision 取首项保留,decisions 透传全量)
let primary = picked.first().cloned().unwrap_or_default();
let response = HumanApprovalResponse {
execution_id: execution_id.clone(),
node_id: node_id.clone(),
decision,
decision: primary,
decisions: picked,
comment,
};
@@ -201,6 +258,7 @@ pub async fn approve_human_approval(
execution_id: response.execution_id,
node_id: response.node_id,
decision: response.decision,
decisions: response.decisions,
comment: response.comment,
};

View File

@@ -23,6 +23,12 @@ pub fn run() {
// 初始化全局状态(打开数据库 + 执行迁移)
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?;
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
) {
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
}
app.manage(app_state);
Ok(())
})
@@ -47,6 +53,7 @@ pub fn run() {
commands::task::create_task,
commands::task::update_task,
commands::task::delete_task,
commands::task::get_task_by_id,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,

View File

@@ -221,12 +221,16 @@ impl AppState {
/// 构建节点注册表 — 注册内置节点
///
/// 注意:不使用 `NodeRegistry::default()`,其 script 工厂为占位实现(会 panic
/// 这里注册 df-nodes 提供的真实 ScriptNode 和 HumanNode。
/// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。
///
/// "script" 节点不注册ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串,
/// 前端可构造任意 DagDef 触达无审批 shellR-PD-2。DevFlow 工作流当前为纯演示
/// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo无真实构建/部署脚本
/// 需求。需要脚本执行能力时新建独立 BuildNode白名单 + 项目目录锚定 + 复用 AI 工具
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
/// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。
fn build_registry() -> NodeRegistry {
let mut registry = NodeRegistry::new();
registry.register("script", |_config| {
Box::new(df_nodes::script_node::ScriptNode)
});
registry.register("human", |_config| {
Box::new(df_nodes::human_node::HumanNode)
});