优化: 前端体验批次(Knowledge持久化+流式缓存失效 + dfnodes节点注册 + delta 50ms合批 + MCP schema必填校验+bind_directory对齐)

This commit is contained in:
lxy
2026-08-08 20:19:19 +08:00
parent 97525a3143
commit 4092a8d5bb
9 changed files with 277 additions and 90 deletions
+12 -1
View File
@@ -176,7 +176,18 @@ async fn resolve_and_inject(
return String::new();
}
let loc = sanitize::locality_of(provider);
let (augs, _errors) = state.resolvers.resolve_all(&refs, loc).await;
let (augs, errors) = state.resolvers.resolve_all(&refs, loc).await;
// 技能/提及注入失败透出:单条 resolve 失败不阻断整批注入(既有语义保留),
// 但失败不能静默——warn 日志让技能注入失败可见(不改前端行为)。
if !errors.is_empty() {
tracing::warn!(
skill = ?skill,
fail_count = errors.len(),
"[ai] 技能/提及注入失败 {} 条(已跳过,不阻断注入): {:?}",
errors.len(),
errors
);
}
build_augmentation_segment(&augs, lang)
}
+69 -12
View File
@@ -148,6 +148,27 @@ fn accumulate_tool_calls(
}
}
/// 双写 emit: Tauri 事件 + ai_event_bus 总线(跨端透传)。消费 ev。
fn emit_ai_chat_event(app_handle: &AppHandle, ev: AiChatEvent) {
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
/// AR-8: flush 合批的 AiTextDelta(双写 emit + event_bus)。空 buffer 不 emit。
fn flush_delta(app_handle: &AppHandle, conv_id: &str, pending_delta: &mut String) {
if pending_delta.is_empty() {
return;
}
let delta = std::mem::take(pending_delta);
emit_ai_chat_event(
app_handle,
AiChatEvent::AiTextDelta {
delta,
conversation_id: Some(conv_id.to_string()),
},
);
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
@@ -184,6 +205,10 @@ pub(crate) async fn stream_llm(
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// AR-8: 后端 delta 合批窗口。50ms 内的多次 AiTextDelta 合并为一条再 emit,
/// 降低 IPC 事件风暴(前端 rAF 只节流渲染不节流 IPC);合并后前端 currentText
/// 逐条累加,最终一致。
const DELTA_FLUSH_INTERVAL: Duration = Duration::from_millis(50);
// ================================================================
// BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的
@@ -264,6 +289,10 @@ pub(crate) async fn stream_llm(
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None;
// AR-8: per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
// next_flush_at 记录下次 flush 的绝对时刻(首个入 buffer 时置 now+50ms)。
let mut pending_delta = String::new();
let mut next_flush_at: Option<tokio::time::Instant> = None;
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
@@ -290,6 +319,7 @@ pub(crate) async fn stream_llm(
// BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。
if tokio::time::Instant::now() >= idle_deadline {
if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: true,
error: format!(
@@ -304,6 +334,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -312,10 +343,24 @@ pub(crate) async fn stream_llm(
};
}
// 三分支 select!:
// AR-8: 到点 flush 合批的 delta(单条 AiTextDelta 双写 emit + event_bus)。
if let Some(deadline) = next_flush_at {
if tokio::time::Instant::now() >= deadline {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
// 多分支 select!:
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) notify.notified():用户停止即时打断
// 4) flush 定时器(sleep_until next_flush_at):尾部 delta 不因流静默而延迟(AR-8)。
let flush_deadline = next_flush_at
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
let flush_fut = tokio::time::sleep_until(flush_deadline);
tokio::pin!(flush_fut);
tokio::select! {
chunk_result = tokio::time::timeout(
Duration::from_secs(15),
@@ -325,6 +370,7 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => {
// 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。
if !first_chunk_done {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: true,
error: format!(
@@ -339,6 +385,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -355,13 +402,11 @@ pub(crate) async fn stream_llm(
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
if !chunk.delta.is_empty() {
full_text.push_str(&chunk.delta);
// L3 emit 双写
let ev = AiChatEvent::AiTextDelta {
delta: chunk.delta,
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// AR-8: 合批入 buffer,首个入 buffer 时置 flush 时刻(到点统一 emit 单条)。
pending_delta.push_str(&chunk.delta);
if next_flush_at.is_none() {
next_flush_at = Some(tokio::time::Instant::now() + DELTA_FLUSH_INTERVAL);
}
}
if let Some(tc_deltas) = &chunk.tool_calls {
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
@@ -395,6 +440,7 @@ pub(crate) async fn stream_llm(
"[ai] provider 流式错误事件",
);
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable,
error: fmt_diag(
@@ -405,6 +451,7 @@ pub(crate) async fn stream_llm(
),
};
}
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -429,6 +476,7 @@ pub(crate) async fn stream_llm(
"[ai] 流式接收中途错误(from 专用线程)",
);
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag(
@@ -439,6 +487,7 @@ pub(crate) async fn stream_llm(
),
};
}
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -450,11 +499,9 @@ pub(crate) async fn stream_llm(
}
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
_ = heartbeat.tick() => {
let ev = AiChatEvent::AiHeartbeat {
emit_ai_chat_event(app_handle, AiChatEvent::AiHeartbeat {
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
});
}
// 3) 即时停止唤醒(B-260615-14)
_ = notify.notified() => {
@@ -463,11 +510,19 @@ pub(crate) async fn stream_llm(
break;
}
}
// 4) AR-8: delta 合批到点 flush(流静默/工具执行等待期也能按时发出尾部文本)
_ = &mut flush_fut => {
if next_flush_at.is_some() {
next_flush_at = None;
flush_delta(app_handle, conv_id, &mut pending_delta);
}
}
}
}
// 用户停止
if stopped {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
@@ -490,6 +545,7 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(),
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
);
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::Partial {
text: full_text,
tool_calls: tool_calls_acc,
@@ -499,6 +555,7 @@ pub(crate) async fn stream_llm(
}
// 正常完成
flush_delta(app_handle, conv_id, &mut pending_delta);
StreamResult::Complete {
text: full_text,
tool_calls: tool_calls_acc,
+9
View File
@@ -618,6 +618,15 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
});
// 补注册 5 个实现完整的孤儿节点(git/docker/http/notify/subflow):
// 均为无参单元结构体(impl Node),与 human 节点同风格注册(工厂闭包直接 Box 结构体,
// 无需捕获 db)。此前未接线,前端 DAG 用对应节点类型会报「未注册」。
// "script" 节点保持禁用(ScriptNode 走 cmd/sh 无审批,见上方 R-PD-2 说明)。
registry.register("git", |_config| Box::new(df_nodes::git_node::GitNode));
registry.register("docker", |_config| Box::new(df_nodes::docker_node::DockerNode));
registry.register("http", |_config| Box::new(df_nodes::http_node::HttpNode));
registry.register("notify", |_config| Box::new(df_nodes::notify_node::NotifyNode));
registry.register("subflow", |_config| Box::new(df_nodes::subflow_node::SubflowNode));
registry
}