新增: 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

@@ -0,0 +1,115 @@
//! 流式接收 LLM 响应
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use futures::StreamExt;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ToolCallDraft};
/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
///
/// 三类异常处理:
/// - idle timeout120s 无 chunk判定连接静默断emit AiError 返回 None
/// - 流尽但从未收到 finished 信号判定异常中断emit AiError 返回 None丢弃残缺不当完整入库
/// - 用户停止stop_flagbreak 返回 Some(已收文本),由调用方入库展示后退出
pub(crate) async fn stream_llm(
provider: &dyn LlmProvider,
request: CompletionRequest,
app_handle: &AppHandle,
stop_flag: &AtomicBool,
conv_id: &str,
) -> Option<(String, HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage)> {
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
match provider.stream(request).await {
Ok(mut stream) => {
let mut full_text = String::new();
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
let mut finished_received = false;
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
loop {
// 用户主动停止:保留已收文本退出
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,
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());
}
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;
}
},
}
}
// 用户停止:已生成文本(可能残缺)交调用方入库展示
if stopped {
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()) {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
conversation_id: Some(conv_id.to_string()),
});
return None;
}
Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
}
Err(e) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("AI 调用失败: {}", e),
conversation_id: Some(conv_id.to_string()),
});
None
}
}
}