新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -8,7 +8,7 @@
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt};
use futures::StreamExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
@@ -79,6 +79,126 @@ struct AnthropicUsage {
output_tokens: u32,
}
// ============================================================
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
// ============================================================
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk并按需更新 usage 累加器。
///
/// 按 `type` 字段分发:
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器output 置 0
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
/// - `message_stop` → 返回 `finished=true` 终态 chunk`usage` 取自累加器(`take()`)。
/// - `error` → 返回 `finished=true` 终态空 chunk。
/// - 其它content_block_stop / ping 等)→ 空 chunk。
///
/// 等价性content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
/// usage 透传message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
// 解析 data 中的 JSON按 type 字段决定如何转 StreamChunk
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 }
}
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 消息开始:取 input_tokens 初始化累积器output 此时未知,置 0
"message_start" => {
if let Some(inp) = v
.get("message")
.and_then(|m| m.get("usage"))
.and_then(|u| u.get("input_tokens"))
.and_then(|t| t.as_u64())
{
*usage_accum = Some(TokenUsage {
prompt_tokens: inp as u32,
completion_tokens: 0,
total_tokens: inp as u32,
});
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 消息增量output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => {
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
let acc = usage_accum
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
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 }
}
// 文本增量
"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 };
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
usage: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 工具块开始:带 id + name
"content_block_start" => {
if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
return StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
usage: None,
};
}
}
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
}
// 消息结束:带出累积 usage
"message_stop" => StreamChunk {
delta: String::new(),
finished: true,
tool_calls: None,
usage: usage_accum.take(),
},
// 错误事件
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
error!(%msg, "Anthropic 流式错误事件");
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
}
// content_block_stop / ping 等不产出 chunk
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
}
}
// ============================================================
// Provider 实现
// ============================================================
@@ -332,77 +452,15 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 流式解析eventsource 逐事件处理,按 type 字段分发转 StreamChunk
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
// usage 累积message_start 给 input_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
let mut usage_accum: Option<TokenUsage> = None;
let stream = resp
.bytes_stream()
.eventsource()
.map(move |event| match event {
Ok(ev) => {
// 解析 data 中的 JSON按 type 字段决定如何转 StreamChunk
let v: serde_json::Value = match serde_json::from_str(&ev.data) {
Ok(v) => v,
Err(_) => return Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
};
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match ty {
// 文本增量
"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 Ok(StreamChunk { delta: text, finished: false, tool_calls: None });
}
// 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
return Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id: None,
function_name: None,
function_arguments: Some(partial),
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 工具块开始:带 id + name
"content_block_start" => {
if let Some(cb) = v.get("content_block") {
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
return Ok(StreamChunk {
delta: String::new(),
finished: false,
tool_calls: Some(vec![ToolCallDelta {
index: idx,
id,
function_name: name,
function_arguments: None,
}]),
});
}
}
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
}
// 消息结束
"message_stop" => Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None }),
// 错误事件
"error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
error!(%msg, "Anthropic 流式错误事件");
Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None })
}
// message_start / content_block_stop / message_delta 等不产出 chunk
_ => Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
}
}
Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)),
Err(e) => {
error!(error = %e, "Anthropic SSE 事件流错误");
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
@@ -424,3 +482,184 @@ impl LlmProvider for AnthropicCompatProvider {
}
}
}
// ============================================================
// 单测(不发真实 HTTP喂构造的 SSE data 字符串序列)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 辅助:构造 message_start 事件
fn message_start(input_tokens: u32) -> String {
format!(
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"output_tokens":0}}}}}}"#,
input_tokens
)
}
/// 辅助:构造 message_delta 事件output_tokens 为累计值)
fn message_delta(output_tokens: u32) -> String {
format!(
r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn"}},"usage":{{"output_tokens":{}}}}}"#,
output_tokens
)
}
/// 辅助:构造文本增量 content_block_delta
fn text_delta(text: &str) -> String {
format!(
r#"{{"type":"content_block_delta","index":0,"delta":{{"type":"text_delta","text":"{}"}}}}"#,
text
)
}
/// 辅助:构造 message_stop 事件
fn message_stop() -> &'static str {
r#"{"type":"message_stop"}"#
}
/// 完整流message_start 初始化 input + 多次 message_delta 累计覆盖 output + message_stop 带出
#[test]
fn anthropic_full_stream_accumulates_usage() {
let mut acc: Option<TokenUsage> = None;
// 1) message_startinput=42output=0
let c = apply_anthropic_event(&message_start(42), &mut acc);
assert!(!c.finished);
assert!(c.usage.is_none());
let a = acc.as_ref().expect("message_start 应初始化累加器");
assert_eq!(a.prompt_tokens, 42);
assert_eq!(a.completion_tokens, 0);
assert_eq!(a.total_tokens, 42);
// 2) 文本增量不影响 usage
let c = apply_anthropic_event(&text_delta("Hello"), &mut acc);
assert_eq!(c.delta, "Hello");
assert!(!c.finished);
assert_eq!(acc.as_ref().unwrap().completion_tokens, 0, "文本增量不应改 output");
// 3) message_deltaoutput_tokens=10累计值覆盖
let c = apply_anthropic_event(&message_delta(10), &mut acc);
assert!(!c.finished);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 42, "input 保持");
assert_eq!(a.completion_tokens, 10, "output 被覆盖为累计值");
assert_eq!(a.total_tokens, 52, "total 重算 = input+output");
// 4) 再次 message_deltaoutput_tokens=30更大累计值再覆盖
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.completion_tokens, 30, "后续累计值覆盖前值");
assert_eq!(a.total_tokens, 72);
// 5) message_stop带出累积 usagefinished=true累加器清空
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
let u = c.usage.expect("message_stop 应带出累积 usage");
assert_eq!(u.prompt_tokens, 42);
assert_eq!(u.completion_tokens, 30);
assert_eq!(u.total_tokens, 72);
assert!(acc.is_none(), "take() 后累加器应清空");
}
/// message_delta 在没有 message_start 时也能补全累加器get_or_insert 兜底)
#[test]
fn anthropic_message_delta_without_start_uses_default_input() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&message_delta(15), &mut acc);
let a = acc.as_ref().unwrap();
assert_eq!(a.prompt_tokens, 0, "无 message_start 时 input 兜底为 0");
assert_eq!(a.completion_tokens, 15);
assert_eq!(a.total_tokens, 15);
}
/// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8结果应是 8 不是 13
#[test]
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
let mut acc: Option<TokenUsage> = None;
apply_anthropic_event(&message_start(100), &mut acc);
apply_anthropic_event(&message_delta(5), &mut acc);
apply_anthropic_event(&message_delta(8), &mut acc);
let c = apply_anthropic_event(message_stop(), &mut acc);
let u = c.usage.unwrap();
assert_eq!(u.completion_tokens, 8, "output_tokens 是累计值,覆盖而非累加");
assert_eq!(u.total_tokens, 108);
}
/// 无 usage 字段的流message_stop 时 usage 为 None
#[test]
fn anthropic_message_stop_without_any_usage() {
let mut acc: Option<TokenUsage> = None;
let _ = apply_anthropic_event(&text_delta("hi"), &mut acc);
assert!(acc.is_none(), "文本增量不初始化累加器");
let c = apply_anthropic_event(message_stop(), &mut acc);
assert!(c.finished);
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
}
/// content_block_start (tool_use) 带 id+name
#[test]
fn anthropic_content_block_start_tool_use() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather"}}"#;
let c = apply_anthropic_event(data, &mut acc);
assert!(acc.is_none(), "content_block_start 不动 usage");
let tcs = c.tool_calls.expect("应有 tool_calls");
assert_eq!(tcs.len(), 1);
assert_eq!(tcs[0].index, 1);
assert_eq!(tcs[0].id.as_deref(), Some("tool_1"));
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
assert!(tcs[0].function_arguments.is_none());
assert!(!c.finished);
}
/// content_block_delta (input_json_delta) → 工具入参增量
#[test]
fn anthropic_content_block_delta_input_json() {
let mut acc: Option<TokenUsage> = None;
let data = r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#;
let c = apply_anthropic_event(data, &mut acc);
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
assert_eq!(tcs[0].index, 2);
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
assert!(tcs[0].id.is_none());
assert_eq!(c.delta, "");
assert!(!c.finished);
}
/// error 事件 → finished=true 终态空 chunk
#[test]
fn anthropic_error_event_finishes_stream() {
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.usage.is_none(), "error 不带出 usage");
assert!(acc.is_some(), "error 不应清空已累积的 usage与原实现一致");
}
/// ping / content_block_stop 等事件 → 空且不 finished
#[test]
fn anthropic_ping_and_block_stop_yield_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event(r#"{"type":"ping"}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
let c = apply_anthropic_event(r#"{"type":"content_block_stop","index":0}"#, &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
}
/// 非法 JSON → 空 chunk不 panic
#[test]
fn anthropic_malformed_json_yields_empty_chunk() {
let mut acc: Option<TokenUsage> = None;
let c = apply_anthropic_event("not json", &mut acc);
assert!(!c.finished);
assert_eq!(c.delta, "");
assert!(acc.is_none());
}
}