重构: 后端 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:
30
Cargo.lock
generated
30
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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=false(R-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() {
|
||||
|
||||
@@ -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_call,unresolved 入 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! 想法晋升 — 将想法转为项目
|
||||
//!
|
||||
//! 历史的 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套
|
||||
//! 是空壳 TODO(do_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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 晚于 send,Response 会在 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 send(broadcast 不回放)
|
||||
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_machine,human 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 应含单值");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>>,每层内的节点可以并行执行。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 待审批的工具调用
|
||||
|
||||
228
src-tauri/src/commands/ai/secret.rs
Normal file
228
src-tauri/src/commands/ai/secret.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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() 返回 Err:SSE 传输断/解析错等
|
||||
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` 分类。
|
||||
/// - raw:anyhow 原始错误文本。
|
||||
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 timeout(120s 无 chunk):判定连接静默断,emit AiError 返回 None
|
||||
/// - 流尽但从未收到 finished 信号:判定异常中断,emit AiError 返回 None(丢弃残缺,不当完整入库)
|
||||
/// - 用户停止(stop_flag):break 返回 Some(已收文本),由调用方入库展示后退出
|
||||
///
|
||||
/// 心跳与停止响应(B-260615-02 / B-260615-04):单 `tokio::select!` 三分支
|
||||
/// - `stream.next()`:正常 chunk 处理
|
||||
/// - `heartbeat.tick()`(30s):静默期(如工具执行后等下一轮首 chunk)emit `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:业务层 bail(provider 串已含状态码)----
|
||||
|
||||
/// 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\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 触达无审批 shell(R-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)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user