重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes
This commit is contained in:
@@ -9,6 +9,9 @@ use tokio::sync::Mutex;
|
||||
use df_ai::ai_tools::AiToolRegistry;
|
||||
use df_ai::context::TokenEstimator;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||
use df_ai::retry;
|
||||
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
@@ -18,7 +21,7 @@ use crate::state::{AppState, LlmConcurrency};
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::maybe_spawn_extraction;
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::stream_recv::stream_llm;
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
use super::audit::process_tool_calls;
|
||||
|
||||
@@ -32,6 +35,14 @@ use super::{AiChatEvent, AiSession, ErrorType};
|
||||
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
|
||||
/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1)
|
||||
///
|
||||
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
|
||||
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
|
||||
/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream:已输出
|
||||
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
|
||||
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||
|
||||
// ============================================================
|
||||
// B-260615-09: generating 状态 RAII guard
|
||||
// ============================================================
|
||||
@@ -99,6 +110,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
knowledge_config: crate::state::KnowledgeConfig,
|
||||
llm_concurrency: LlmConcurrency,
|
||||
max_iterations: usize,
|
||||
max_retries: usize,
|
||||
) {
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone());
|
||||
@@ -170,7 +182,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
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()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,34 +220,187 @@ pub(crate) async fn run_agentic_loop(
|
||||
};
|
||||
|
||||
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
|
||||
// 注:F-260616-07 重试循环内每次重建 retry_request(因 provider.stream 消费 body),
|
||||
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
|
||||
let estimated_prompt: u32 = {
|
||||
let est = TokenEstimator::default();
|
||||
messages.iter().map(|m| est.estimate_message(m)).sum()
|
||||
};
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: provider_config.default_model.clone(),
|
||||
messages,
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(8192),
|
||||
stream: true,
|
||||
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
// LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身;
|
||||
// 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽
|
||||
//
|
||||
// CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream
|
||||
// Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) +
|
||||
// retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像,
|
||||
// 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 permit 不释放(防新请求挤占)。
|
||||
let _global_permit = llm_concurrency.acquire_global().await;
|
||||
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
|
||||
// 流式接收(内部处理 idle timeout / 断连检测 / 停止信号)
|
||||
let (full_text, tool_calls_acc, round_usage) = match stream_llm(&*provider, request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
// 错误已在 stream_llm 中 emit,直接结束
|
||||
guard.reset().await;
|
||||
return;
|
||||
|
||||
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
|
||||
// 超预算直接放弃重试交最终错误/保文路径。
|
||||
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
|
||||
let (full_text, tool_calls_acc, round_usage, incomplete) = {
|
||||
// outcome 累积最后一次成功/保文结果(Complete 或 Partial)。InitFailed 不写入,
|
||||
// 走重试或耗尽 return。
|
||||
let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool)> = None;
|
||||
|
||||
for retry_attempt in 0..=max_retries {
|
||||
// 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
|
||||
let retry_request = CompletionRequest {
|
||||
model: provider_config.default_model.clone(),
|
||||
messages: {
|
||||
let session = session_arc.lock().await;
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
|
||||
let mut msgs = vec![ChatMessage::system(&system_prompt)];
|
||||
msgs.extend(history_msgs);
|
||||
msgs
|
||||
},
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(8192),
|
||||
stream: true,
|
||||
tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
match stream_llm(&*provider, retry_request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
|
||||
StreamResult::Complete { text, tool_calls, usage } => {
|
||||
// 正常完成(流尽 + finished,或用户主动停止)。incomplete=false。
|
||||
outcome = Some((text, tool_calls, usage, false));
|
||||
break;
|
||||
}
|
||||
StreamResult::Partial { text, tool_calls, usage } => {
|
||||
// CR-30-2 / UX-2025-04 / 决策 a1: 流中途失败保文,**不重试**。
|
||||
// 已 emit AiTextDelta(前端 currentText 已累积),此处保文入库 +
|
||||
// AiCompleted(incomplete=true) + 系统提示。currentText 不混乱(未重试无追加)。
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
text_len = text.len(),
|
||||
"[ai] 流中途失败,保文不重试(incomplete=true),入库 + AiCompleted + 系统提示",
|
||||
);
|
||||
outcome = Some((text, tool_calls, usage, true));
|
||||
break;
|
||||
}
|
||||
StreamResult::InitFailed { retryable } => {
|
||||
// stream_llm 已 emit AiError。决定是否重试(仅 Init 失败可重试,决策 a1)。
|
||||
let is_last = retry_attempt >= max_retries;
|
||||
let now = tokio::time::Instant::now();
|
||||
let budget_exhausted = now >= retry_deadline;
|
||||
|
||||
// Fatal(4xx 非429/鉴权/参数错)立即放弃,不浪费预算重试
|
||||
if !retryable {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = retry_attempt + 1,
|
||||
"[ai] 流前失败 Fatal(4xx/鉴权),立即放弃不重试",
|
||||
);
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if is_last || budget_exhausted {
|
||||
// 重试耗尽或预算耗尽:错误已 emit,结束
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = retry_attempt + 1,
|
||||
budget_exhausted = budget_exhausted,
|
||||
"[ai] 流前失败重试{}({}次),放弃",
|
||||
if budget_exhausted { "预算耗尽" } else { "耗尽" },
|
||||
max_retries + 1,
|
||||
);
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
|
||||
// CR-30-1: 退避复用 retry::backoff_delay(attempt+1)(1s→2s→4s + ±20% jitter),
|
||||
// 不再使用纯指数 `1u64 << retry_attempt`。attempt 传 retry_attempt+1
|
||||
// (retry::backoff_delay 是 1-based,第 1 次重试 ~1s,第 2 次 ~2s,第 3 次 ~4s)。
|
||||
let delay = retry::backoff_delay((retry_attempt + 1) as u32)
|
||||
.min(retry_deadline.saturating_duration_since(now));
|
||||
let total_retry = retry_attempt + 1; // 1-based 当前是第几次尝试
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = total_retry,
|
||||
max_attempts = max_retries + 1,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
"[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})",
|
||||
delay.as_millis(), total_retry, max_retries + 1
|
||||
);
|
||||
|
||||
// F-260616-07(d): emit 重试提示事件,前端在错误气泡内显示「重试 n/m」
|
||||
// (对齐决策"错误气泡内更新")。CR-30-2: useAiEvents.ts 补 case 处理。
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
|
||||
attempt: total_retry as u32,
|
||||
max_attempts: (max_retries + 1) as u32,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
});
|
||||
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// 不应到达:InitFailed 非 Fatal/未耗尽即重试,耗尽/Fatal 已 return;
|
||||
// Complete/Partial 写入 outcome 后 break。防御兜底。
|
||||
guard.reset().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
|
||||
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
|
||||
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
|
||||
if incomplete {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
|
||||
completion_tokens: round_usage.completion_tokens,
|
||||
total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
|
||||
};
|
||||
tokens.add(usage.prompt_tokens, usage.completion_tokens);
|
||||
|
||||
// 追加 partial assistant 消息(若无 tool_calls 且有文本)
|
||||
{
|
||||
let mut 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] MidStream 保文后对话已切换,丢弃本轮 push(B-260615-11)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !full_text.is_empty() {
|
||||
let mut msg = ChatMessage::assistant(&full_text);
|
||||
msg.model = Some(provider_config.default_model.clone());
|
||||
session.messages.push(msg);
|
||||
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
|
||||
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
|
||||
notice.model = Some(provider_config.default_model.clone());
|
||||
session.messages.push(notice);
|
||||
}
|
||||
}
|
||||
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
|
||||
// 标题生成后台化(失败有 extract_title 兜底)
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
guard.reset().await;
|
||||
// generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整
|
||||
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()),
|
||||
incomplete: Some(true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本)
|
||||
drop(_global_permit);
|
||||
drop(_per_conv_permit);
|
||||
@@ -289,7 +454,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
|
||||
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()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -377,7 +542,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
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()) });
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
@@ -434,6 +599,7 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
}
|
||||
@@ -485,6 +651,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
||||
@@ -494,6 +662,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,9 +101,11 @@ pub async fn ai_regenerate(
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
});
|
||||
|
||||
Ok("ok".to_string())
|
||||
@@ -195,9 +197,11 @@ pub async fn ai_chat_send(
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
});
|
||||
|
||||
Ok("ok".to_string())
|
||||
@@ -213,10 +217,20 @@ pub async fn ai_approve(
|
||||
) -> Result<String, String> {
|
||||
let mut session = state.ai_session.lock().await;
|
||||
|
||||
let approval = session
|
||||
.pending_approvals
|
||||
.remove(&tool_call_id)
|
||||
.ok_or_else(|| format!("未找到挂起的审批: {}", tool_call_id))?;
|
||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
||||
if let Some(rec) = state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" {
|
||||
return Ok(format!("已处理({})", rec.status));
|
||||
}
|
||||
}
|
||||
return Err(format!("未找到挂起的审批: {}", tool_call_id));
|
||||
}
|
||||
};
|
||||
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
||||
let _recovered = approval.recovered;
|
||||
|
||||
@@ -424,6 +438,7 @@ pub async fn ai_chat_edit(
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(
|
||||
@@ -437,6 +452,7 @@ pub async fn ai_chat_edit(
|
||||
knowledge_config,
|
||||
llm_concurrency,
|
||||
max_iterations,
|
||||
max_retries,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -472,6 +488,7 @@ pub async fn ai_chat_force_send(
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(cid.clone()),
|
||||
});
|
||||
}
|
||||
@@ -499,7 +516,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
|
||||
session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
let conv_id = session.active_conversation_id.clone();
|
||||
drop(session);
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: conv_id });
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: conv_id });
|
||||
return Ok(());
|
||||
}
|
||||
// 流式生成中:置位让 loop 自行收尾
|
||||
@@ -529,6 +546,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
);
|
||||
@@ -601,6 +619,7 @@ pub async fn ai_stop_loop(
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id),
|
||||
});
|
||||
Ok("ok".to_string())
|
||||
@@ -807,6 +826,7 @@ pub async fn ai_conversation_create(
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(old_conv),
|
||||
});
|
||||
}
|
||||
@@ -1082,3 +1102,20 @@ pub async fn ai_set_agent_max_iterations(
|
||||
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置流式对话失败自动重试次数(F-260616-07 / 决策 a1:运行时调整,下次发消息生效)
|
||||
///
|
||||
/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream Partial)保文不重试。
|
||||
/// 退避复用 retry::backoff_delay(1s→2s→4s+jitter) + is_status_retryable Fatal 分类 +
|
||||
/// 30s 总预算(详见 agentic.rs 流前重试循环)。
|
||||
/// 范围 clamp 0-10:0 表示不重试(直接报错),上限 10 防过度重试烧 token/拖慢体验。
|
||||
/// 默认 3(复用 retry.rs backoff_delay + 错误分类,详见 agentic.rs 重试循环)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_set_agent_max_retries(
|
||||
state: State<'_, AppState>,
|
||||
value: u32,
|
||||
) -> Result<(), String> {
|
||||
let clamped = value.clamp(0, 10) as usize;
|
||||
state.agent_max_retries.store(clamped, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -100,7 +100,18 @@ pub enum AiChatEvent {
|
||||
/// 审批结果
|
||||
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
|
||||
/// AI 响应完成
|
||||
AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> },
|
||||
///
|
||||
/// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1——
|
||||
/// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化
|
||||
/// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。
|
||||
AiCompleted {
|
||||
total_tokens: u32,
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
/// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
|
||||
incomplete: Option<bool>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 错误
|
||||
///
|
||||
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
|
||||
@@ -120,6 +131,11 @@ pub enum AiChatEvent {
|
||||
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
|
||||
/// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false)
|
||||
AiMaxRoundsReached { conversation_id: Option<String> },
|
||||
/// 流式调用自动重试提示(F-260616-07:流前失败重试中)
|
||||
///
|
||||
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
||||
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
||||
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -92,12 +92,42 @@ pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &st
|
||||
format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw)
|
||||
}
|
||||
|
||||
/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
|
||||
/// 流式接收结果。显式区分三类出口,避免调用方对 None 的歧义推断
|
||||
/// (UX-2025-04 / CR-30-1 / CR-30-2 / 决策 F-260616-07 a1)。
|
||||
pub(crate) enum StreamResult {
|
||||
/// 正常完成(流尽 + finished 信号,或用户主动停止)。incomplete=false。
|
||||
/// 调用方按正常流程入库 + emit AiCompleted。
|
||||
Complete {
|
||||
text: String,
|
||||
tool_calls: HashMap<u32, ToolCallDraft>,
|
||||
usage: df_ai::provider::TokenUsage,
|
||||
},
|
||||
/// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
|
||||
/// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积),
|
||||
/// **不 emit AiError**——调用方保文入库 + emit AiCompleted(incomplete=true) + 系统提示。
|
||||
/// 调用方**不重试**(决策 a1)。
|
||||
Partial {
|
||||
text: String,
|
||||
tool_calls: HashMap<u32, ToolCallDraft>,
|
||||
usage: df_ai::provider::TokenUsage,
|
||||
},
|
||||
/// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx)。已 emit AiError。
|
||||
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像):true=5xx/429/timeout/connect
|
||||
/// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
|
||||
InitFailed { retryable: bool },
|
||||
}
|
||||
|
||||
/// 流式接收 LLM 响应。
|
||||
///
|
||||
/// 三类异常处理:
|
||||
/// - idle timeout(120s 无 chunk):判定连接静默断,emit AiError 返回 None
|
||||
/// - 流尽但从未收到 finished 信号:判定异常中断,emit AiError 返回 None(丢弃残缺,不当完整入库)
|
||||
/// - 用户停止(stop_flag):break 返回 Some(已收文本),由调用方入库展示后退出
|
||||
/// 三类异常处理(返回 StreamResult 显式区分出口):
|
||||
/// - Init 失败(provider.stream() Err):emit AiError + 返回 InitFailed{retryable}。
|
||||
/// retryable 据状态码分类(retry::is_status_retryable 镜像):5xx/429/timeout/connect=true,
|
||||
/// 4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次,
|
||||
/// 复用 retry::backoff_delay 退避 + 30s 总预算)。
|
||||
/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished):
|
||||
/// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
|
||||
/// + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
|
||||
/// - 用户停止(stop_flag):返回 Complete(incomplete 语义非异常中断)。
|
||||
///
|
||||
/// 心跳与停止响应(B-260615-02 / B-260615-04):单 `tokio::select!` 三分支
|
||||
/// - `stream.next()`:正常 chunk 处理
|
||||
@@ -111,7 +141,7 @@ pub(crate) async fn stream_llm(
|
||||
stop_flag: &AtomicBool,
|
||||
notify: &tokio::sync::Notify,
|
||||
conv_id: &str,
|
||||
) -> Option<(String, HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage)> {
|
||||
) -> StreamResult {
|
||||
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
|
||||
@@ -157,13 +187,28 @@ pub(crate) async fn stream_llm(
|
||||
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(),
|
||||
// 120s 无 chunk = idle timeout,归 Timeout
|
||||
error_type: Some(ErrorType::Timeout),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return None;
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
|
||||
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
|
||||
// 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(),
|
||||
error_type: Some(ErrorType::Timeout),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return StreamResult::InitFailed { retryable: true };
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
Ok(None) => break, // 流正常结束
|
||||
Ok(Some(chunk_result)) => match chunk_result {
|
||||
@@ -187,27 +232,35 @@ pub(crate) async fn stream_llm(
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
// provider 流式错误事件(Anthropic SSE `type=="error"` 等):
|
||||
// 不走 finished 完成路径,发 AiError + 丢弃残缺,与 Err 分支对齐(OpenAI 路径一致性)。
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
|
||||
// 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true,
|
||||
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
|
||||
if let Some(err_msg) = &chunk.error {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
error = %err_msg,
|
||||
text_len = full_text.len(),
|
||||
"[ai] provider 流式错误事件",
|
||||
);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
// provider SSE error 事件体可为 401/overloaded 等多种,
|
||||
// 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断)
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return None;
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
error_type: Some(ErrorType::Unknown),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return StreamResult::InitFailed { retryable: true };
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
if chunk.finished {
|
||||
finished_received = true;
|
||||
@@ -222,22 +275,33 @@ pub(crate) async fn stream_llm(
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流式接收中途错误",
|
||||
);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
// 流已建立后 next() 返 Err = SSE 传输断/解析错,归 Network
|
||||
// (extract_error_diag 已抠 status_or_class,但混合源难统一归 auth/timeout,
|
||||
// 主流为传输断,前端可据 error 文本二次判断 HTTP 4xx 等)
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return None;
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
|
||||
// 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError +
|
||||
// InitFailed{retryable=classify_status_or_class(status_or_class)}
|
||||
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,26 +326,47 @@ pub(crate) async fn stream_llm(
|
||||
}
|
||||
}
|
||||
|
||||
// 用户停止:已生成文本(可能残缺)交调用方入库展示
|
||||
// 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)——
|
||||
// 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
|
||||
if stopped {
|
||||
return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()));
|
||||
return StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
|
||||
// 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库。
|
||||
// B-260615-05:一律判异常(不区分内容空否)——空内容无 finished 同属异常:
|
||||
// 上游 agentic.rs !has_tool_calls 早 break 会按正常路径 emit AiCompleted,
|
||||
// 用户看空气泡无错误提示,转 P0 卡死入口。此处统一 emit AiError 拦截。
|
||||
// 断连检测:流尽但从未收到 finished 信号 = 异常中断。
|
||||
// B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
|
||||
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
|
||||
if !finished_received {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
// 流尽但未收到 finished = 连接异常中断,归 Network
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return None;
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
error_type: Some(ErrorType::Network),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return StreamResult::InitFailed { retryable: true };
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
};
|
||||
}
|
||||
|
||||
Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
|
||||
// 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
|
||||
StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
|
||||
@@ -301,18 +386,53 @@ pub(crate) async fn stream_llm(
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
// 建连失败混合源:401(auth)/404(provider_config)/connect(network)/timeout/unknown,
|
||||
// extract_error_diag 已抠 status_or_class 供前端 error 文本展示,但运行时文本分类
|
||||
// 归一 error_type 不可靠(同 raw 跨多类型),按任务约定难精确分类的旧点填 None,
|
||||
// 前端可据 error 文本中的 "HTTP 401" 等自行二次判断。
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
None
|
||||
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
|
||||
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
|
||||
StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 据 extract_error_diag 产出的 status_or_class 字符串镜像 retry::is_status_retryable 分类。
|
||||
///
|
||||
/// status_or_class 取值: "HTTP 5xx"/"HTTP 4xx"(白名单码) / "timeout" / "connect" / "unknown"。
|
||||
/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
|
||||
/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
|
||||
/// - timeout / connect → retryable=true(瞬态,可重试)
|
||||
/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
|
||||
///
|
||||
/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
|
||||
/// 避免 stream_recv 与 agentic 各自重写状态码解析。
|
||||
fn classify_status_or_class(status_or_class: &str) -> bool {
|
||||
use df_ai::retry::is_status_retryable;
|
||||
// 抠 status_or_class 内三位数字状态码("HTTP 401" → 401)
|
||||
let chars: Vec<char> = status_or_class.chars().collect();
|
||||
let mut i = 0;
|
||||
let n = chars.len();
|
||||
while i + 3 <= n {
|
||||
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();
|
||||
if let Ok(status) = code.parse::<u16>() {
|
||||
return is_status_retryable(status);
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守可重试)
|
||||
let lower = status_or_class.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("connect") || true // unknown 保守可重试
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle)
|
||||
// ============================================================
|
||||
@@ -320,6 +440,43 @@ pub(crate) async fn stream_llm(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- classify_status_or_class:CR-30-1 retryable 分类(镜像 retry::is_status_retryable) ----
|
||||
|
||||
/// 5xx → retryable=true
|
||||
#[test]
|
||||
fn classify_5xx_retryable() {
|
||||
assert!(classify_status_or_class("HTTP 500"));
|
||||
assert!(classify_status_or_class("HTTP 503"));
|
||||
}
|
||||
|
||||
/// 429 → retryable=true(限流可重试)
|
||||
#[test]
|
||||
fn classify_429_retryable() {
|
||||
assert!(classify_status_or_class("HTTP 429"));
|
||||
}
|
||||
|
||||
/// 4xx(非429) → retryable=false Fatal(鉴权/参数错立即放弃)
|
||||
#[test]
|
||||
fn classify_4xx_fatal() {
|
||||
assert!(!classify_status_or_class("HTTP 401"));
|
||||
assert!(!classify_status_or_class("HTTP 403"));
|
||||
assert!(!classify_status_or_class("HTTP 404"));
|
||||
assert!(!classify_status_or_class("HTTP 422"));
|
||||
}
|
||||
|
||||
/// timeout / connect → retryable=true(瞬态)
|
||||
#[test]
|
||||
fn classify_transient_retryable() {
|
||||
assert!(classify_status_or_class("timeout"));
|
||||
assert!(classify_status_or_class("connect"));
|
||||
}
|
||||
|
||||
/// unknown → 保守 retryable=true(不误判 Fatal 错杀)
|
||||
#[test]
|
||||
fn classify_unknown_retryable() {
|
||||
assert!(classify_status_or_class("unknown"));
|
||||
}
|
||||
|
||||
// ---- extract_error_diag:业务层 bail(provider 串已含状态码)----
|
||||
|
||||
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
|
||||
|
||||
@@ -205,51 +205,57 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
|
||||
// ── 只读 (Low) ──
|
||||
registry.register(
|
||||
"list_projects", "列出所有项目,返回项目列表(ID、名称、状态、描述)。最多返回 50 条",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||||
let items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||||
let total = items.len();
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条",
|
||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
||||
"list_tasks", "列出任务,可按 project_id 筛选,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
};
|
||||
let total = tasks.len();
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
tasks.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_ideas", "列出所有灵感。最多返回 50 条",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
"list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let mut items = repo.list_all().await?;
|
||||
let items = repo.list_all().await?;
|
||||
let total = items.len();
|
||||
let truncated = total > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
@@ -456,16 +462,19 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"list_trash", "列出回收站已删除项目(最多返回 50 条)",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
"list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let mut items = repo.list_deleted().await?;
|
||||
let truncated = items.len() > MAX_LIST_RESULTS;
|
||||
items.truncate(MAX_LIST_RESULTS);
|
||||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||||
let items = repo.list_deleted().await?;
|
||||
let total = items.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
@@ -568,23 +577,27 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
}
|
||||
anyhow::bail!("读取文件失败: {}", e);
|
||||
}
|
||||
// search 模式: 按行枚举收集含 search 子串的行,限 50 条
|
||||
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
|
||||
if let Some(search) = args["search"].as_str() {
|
||||
let search_max = 50usize;
|
||||
let mut matches_vec: Vec<serde_json::Value> = Vec::with_capacity(search_max);
|
||||
const SEARCH_MAX: usize = 50;
|
||||
let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
|
||||
// 先收集全部匹配行用于 total 计数,再做 skip/take 分页
|
||||
let mut all_matches: Vec<serde_json::Value> = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
if line.contains(search) {
|
||||
matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
|
||||
if matches_vec.len() >= search_max { break; }
|
||||
all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
|
||||
}
|
||||
}
|
||||
let total = content.lines().filter(|l| l.contains(search)).count();
|
||||
let total = all_matches.len();
|
||||
let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
|
||||
let has_more = (search_offset + page_matches.len()) < total;
|
||||
return Ok(serde_json::json!({
|
||||
"path": path, "size": metadata.len(),
|
||||
"search": search,
|
||||
"matches": matches_vec,
|
||||
"matches": page_matches,
|
||||
"total": total,
|
||||
"has_more": total > search_max,
|
||||
"has_more": has_more,
|
||||
}));
|
||||
}
|
||||
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||||
@@ -1043,8 +1056,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
|
||||
// ── 文件搜索 (Low risk) ──
|
||||
registry.register(
|
||||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
|
||||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
@@ -1055,14 +1068,49 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
const MAX_RESULTS: usize = 50;
|
||||
let mut results = Vec::new();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
|
||||
// 先收集全部结果用于 total 计数,再做 skip/take 分页
|
||||
let mut all_results = Vec::new();
|
||||
let mut total = 0u64;
|
||||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
|
||||
let has_more = total as usize > MAX_RESULTS;
|
||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
|
||||
// 用较大上限收集全量(分页由内存 skip/take 控制)
|
||||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
||||
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_results.len()) < total as usize;
|
||||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 探总量工具 (Low risk, F-260616-08) ──
|
||||
registry.register(
|
||||
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
|
||||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let items = repo.list_active().await?;
|
||||
Ok(serde_json::json!({ "total": items.len() }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
|
||||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "total": tasks.len() }))
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ pub async fn knowledge_search(
|
||||
state: State<'_, AppState>,
|
||||
input: KnowledgeSearchInput,
|
||||
) -> Result<Vec<KnowledgeRecord>, String> {
|
||||
let limit = input.limit.unwrap_or(3).min(3);
|
||||
let limit = input.limit.unwrap_or(3).min(20);
|
||||
state
|
||||
.knowledge
|
||||
.search(&input.query, input.kind.as_deref(), limit)
|
||||
|
||||
@@ -35,17 +35,20 @@ struct WorkflowEventPayload {
|
||||
/// 映射表(失败退一步):
|
||||
/// - testing → in_review
|
||||
/// - in_review → in_progress
|
||||
/// - in_progress → todo
|
||||
/// - in_progress → None(状态机禁止→todo,留 in_progress 等人介入)
|
||||
/// - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调)
|
||||
///
|
||||
/// 设计选择:in_progress → todo(而非 None)。理由:in_progress 是执行态,失败退回 todo
|
||||
/// 符合"执行未达预期 → 回起点重排"的直觉语义;若选 None 会丢失"执行失败"信号。
|
||||
/// 设计选择:in_progress → None(而非 todo)。理由:状态机禁止 in_progress→todo
|
||||
/// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被
|
||||
/// advance_task_atomic 的 InvalidState 拦截,任务原地保留 in_progress 且无信号;
|
||||
/// 改为 None 则回调跳过推进,留 in_progress 等人介入。
|
||||
/// 起点状态 todo 无可退态 → None。
|
||||
fn regression_target(target: &str) -> Option<&str> {
|
||||
match target {
|
||||
"testing" => Some("in_review"),
|
||||
"in_review" => Some("in_progress"),
|
||||
"in_progress" => Some("todo"),
|
||||
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
|
||||
"in_progress" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub fn run() {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
);
|
||||
@@ -131,6 +132,7 @@ pub fn run() {
|
||||
commands::ai::ai_list_skills,
|
||||
commands::ai::ai_set_concurrency_config,
|
||||
commands::ai::ai_set_agent_max_iterations,
|
||||
commands::ai::ai_set_agent_max_retries,
|
||||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::list_tool_executions,
|
||||
// 知识库
|
||||
|
||||
@@ -180,6 +180,11 @@ pub struct AppState {
|
||||
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
|
||||
/// 默认 10,与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
|
||||
pub agent_max_iterations: Arc<AtomicUsize>,
|
||||
// ── 流式对话失败自动重试 ──
|
||||
/// 流式对话失败自动重试次数(F-260616-07 / 决策 a1:只重试流前失败 Init Err——未输出
|
||||
/// 任何 token;流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
|
||||
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3)。
|
||||
pub agent_max_retries: Arc<AtomicUsize>,
|
||||
// ── 工作流执行状态 ──
|
||||
/// 工作流执行 → 节点状态机注册表
|
||||
///
|
||||
@@ -217,6 +222,9 @@ impl AppState {
|
||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
||||
)),
|
||||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||||
)),
|
||||
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
|
||||
db,
|
||||
event_bus: EventBus::new(),
|
||||
|
||||
Reference in New Issue
Block a user