diff --git a/Cargo.lock b/Cargo.lock index a785f9a..366017a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sys-locale", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4422,6 +4423,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/crates/df-ai-core/src/model.rs b/crates/df-ai-core/src/model.rs index 795749e..8c0cc2e 100644 --- a/crates/df-ai-core/src/model.rs +++ b/crates/df-ai-core/src/model.rs @@ -138,6 +138,14 @@ pub struct ModelConfig { #[serde(default = "default_context_window")] pub context_window: usize, + /// 是否支持 temperature 参数(默认 true)。 + /// + /// 部分模型仅接受固定温度值(如商汤 sensenova 系列仅允许 1.0),透传其它温度(标题 0.3/ + /// 压缩 0.2 等)→ 上游 HTTP 400,实测 9 次。false 时协议转换层省略 temperature 字段 + /// (交由上游默认),避免无效往返;true 保持原样透传(零回归)。 + #[serde(default = "default_true")] + pub temperature_supported: bool, + // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)── #[serde(default, skip_serializing_if = "Option::is_none")] pub probe_source: Option, @@ -173,6 +181,9 @@ impl ModelConfig { intelligence: IntelligenceTier::Standard, weight: default_weight(), context_window: default_context_window(), + // 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归); + // 明确不支持的模型由 presets 表 temperature_supported=false 标注。 + temperature_supported: true, probe_source: None, } } @@ -339,6 +350,7 @@ mod tests { intelligence: IntelligenceTier::Plus, weight: 70, context_window: 128_000, + temperature_supported: true, probe_source: Some(ProbeSource::PresetTable), }; let json = serde_json::to_string(&mc).unwrap(); diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index f953a9b..5d92f6e 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -472,6 +472,7 @@ mod tests { usage: None, error: None, reasoning_content: Some("thought".to_string()), + truncated_by_max_tokens: false, }; let json = serde_json::to_string(&chunk).unwrap(); assert!(json.contains("reasoning_content")); diff --git a/crates/df-ai-core/src/types.rs b/crates/df-ai-core/src/types.rs index f7b6e4f..99f96c9 100644 --- a/crates/df-ai-core/src/types.rs +++ b/crates/df-ai-core/src/types.rs @@ -350,6 +350,11 @@ pub struct StreamChunk { /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 #[serde(skip)] pub error: Option, + /// 本流因 provider 输出上限截断收尾(openai finish_reason=length / + /// anthropic stop_reason=max_tokens)。finished 仍为 true(正常终止非断连),但文本/ + /// tool_calls 可能不完整;消费方跨帧 OR 累积该标志,轮产出据此标记截断,不冒充正常完成。 + #[serde(default)] + pub truncated_by_max_tokens: bool, /// DeepSeek thinking 模式推理内容增量 #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_content: Option, diff --git a/crates/df-ai/presets/models.json b/crates/df-ai/presets/models.json index 771f17e..ef308e0 100644 --- a/crates/df-ai/presets/models.json +++ b/crates/df-ai/presets/models.json @@ -133,7 +133,8 @@ "modalities": ["text", "vision"], "capabilities": ["tool_use"], "weight": 85, - "context_window": 262144 + "context_window": 262144, + "temperature_supported": false }, { "model_id": "deepseek-v4-flash", diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 4c56148..9d23bb2 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -262,12 +262,23 @@ impl AnthropicCompatProvider { .collect() }); + // 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。 + // 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。 + let temperature = if req.temperature.is_some() + && !crate::model_probe::probe(&model).temperature_supported + { + tracing::debug!(model = %model, "模型不支持 temperature,已省略该参数"); + None + } else { + req.temperature + }; + AnthropicRequest { model, messages, max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), system, - temperature: req.temperature, + temperature, stream: req.stream, tools, tool_choice: req.tool_choice, @@ -927,6 +938,21 @@ mod tests { assert_eq!(a.total_tokens, 15); } + /// stop_reason=max_tokens(message_delta 帧携带)必须置截断标志;end_turn 不置。 + /// 终态 finished 在后续 message_stop 帧,消费方跨帧 OR 累积标志。 + #[test] + fn anthropic_max_tokens_stop_reason_sets_truncation_flag() { + let mut acc: Option = None; + let data = r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":9}}"#; + let c = apply_anthropic_event(data, &mut acc); + assert!(!c.finished, "message_delta 非终态帧"); + assert!(c.truncated_by_max_tokens, "max_tokens 必须置截断标志"); + + let mut acc2: Option = None; + let c2 = apply_anthropic_event(&message_delta(5), &mut acc2); + assert!(!c2.truncated_by_max_tokens, "end_turn 不置截断标志"); + } + /// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13 #[test] fn anthropic_message_delta_output_is_cumulative_not_incremental() { diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index c3fe395..0e6ca03 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -137,7 +137,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option v, Err(_) => { - return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } + return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false } } }; let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -169,7 +169,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { @@ -179,9 +179,12 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { @@ -190,16 +193,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { @@ -253,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option StreamChunk { @@ -266,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { 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: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None } + StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None, truncated_by_max_tokens: false } } // content_block_stop / ping 等不产出 chunk - _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }, + _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false }, } } diff --git a/crates/df-ai/src/context/manager_tests.rs b/crates/df-ai/src/context/manager_tests.rs index 2e0ffce..4ba62fe 100644 --- a/crates/df-ai/src/context/manager_tests.rs +++ b/crates/df-ai/src/context/manager_tests.rs @@ -841,12 +841,12 @@ fn phase1_push_only_keeps_clean_no_dirty_flag() { #[test] fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() { - // advance_persisted_count(new_count):推进基线 + 清 needs_full_rewrite(DB 已与内存一致) + // advance_persisted_count(new_count, full):推进基线;全量重写才清 needs_full_rewrite let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("x")); mgr.push(ChatMessage::user("y")); - // 模拟 save 成功后推进 - mgr.advance_persisted_count(2); + // 模拟 save(全量重写)成功后推进 + mgr.advance_persisted_count(2, true); assert_eq!(mgr.persisted_msg_count(), 2); assert!(!mgr.needs_full_rewrite()); } @@ -855,11 +855,26 @@ fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() { fn phase1_advance_persisted_count_max_no_regression() { // 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写) let mut mgr = ContextManager::new(cfg(100_000)); - mgr.advance_persisted_count(5); - mgr.advance_persisted_count(3); // 误传更小 + mgr.advance_persisted_count(5, true); + mgr.advance_persisted_count(3, true); // 误传更小 assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退"); } +#[test] +fn phase1_advance_incremental_keeps_dirty() { + // 增量/无变化路径(full=false)不得清 dirty:写库期间新置的 dirty(如压缩链 mark) + // 与本次 append 写库无关,误清会致改动滞留内存永不落库 + let mut mgr = ContextManager::new(cfg(100_000)); + mgr.push(ChatMessage::user("a")); + mgr.advance_persisted_count(1, false); // 增量 save 成功 + assert!(!mgr.needs_full_rewrite()); + mgr.mark_needs_full_rewrite(); // 模拟写库期间压缩 mark + mgr.advance_persisted_count(1, false); // 又一次增量 save + assert!(mgr.needs_full_rewrite(), "增量路径 advance 不得清 dirty"); + mgr.advance_persisted_count(1, true); // 直到全量重写才清 + assert!(!mgr.needs_full_rewrite()); +} + #[test] fn phase1_compress_old_messages_sets_dirty() { // compress 改既有消息 status(active→compressed) → 必须全量重写收敛 @@ -881,7 +896,7 @@ fn phase1_compress_no_op_does_not_set_dirty() { mgr.push(ChatMessage::user("a")); let _ = mgr.compress_old_messages(1); assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty"); - mgr.advance_persisted_count(1); // 模拟 save 后清 dirty + mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty // 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty let compressed = mgr.compress_old_messages(1); assert!(compressed.is_empty()); @@ -974,7 +989,7 @@ fn phase1_clear_resets_baseline_and_sets_dirty() { // clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); - mgr.advance_persisted_count(1); + mgr.advance_persisted_count(1, true); assert_eq!(mgr.persisted_msg_count(), 1); mgr.clear(); assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0"); @@ -1021,7 +1036,7 @@ fn phase1_append_after_dirty_stays_dirty_until_save() { mgr.push(ChatMessage::user("b")); // 追加 assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty"); // 模拟 save(全量重写)成功后推进 + 清 - mgr.advance_persisted_count(2); + mgr.advance_persisted_count(2, true); assert!(!mgr.needs_full_rewrite()); } diff --git a/crates/df-ai/src/context/mod.rs b/crates/df-ai/src/context/mod.rs index 2f33431..7e1bab9 100644 --- a/crates/df-ai/src/context/mod.rs +++ b/crates/df-ai/src/context/mod.rs @@ -528,11 +528,15 @@ impl ContextManager { } /// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。 - /// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。 - pub fn advance_persisted_count(&mut self, new_count: usize) { + /// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。 + /// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty + /// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。 + pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) { // 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。 self.persisted_msg_count = self.persisted_msg_count.max(new_count); - self.needs_full_rewrite = false; + if full_rewrite_applied { + self.needs_full_rewrite = false; + } } /// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。 diff --git a/crates/df-ai/src/model_probe.rs b/crates/df-ai/src/model_probe.rs index 92722cc..6eaf7cc 100644 --- a/crates/df-ai/src/model_probe.rs +++ b/crates/df-ai/src/model_probe.rs @@ -159,6 +159,24 @@ mod tests { assert!(m.modalities.contains(&Modality::Text)); // context_window 应为预设的 256K assert_eq!(m.context_window, 262144); + // P1 温度:商汤 sensenova 仅接受 temperature=1.0,透传其它值(标题 0.3/压缩 0.2) + // → 上游 HTTP 400(实测 9 次)。预设表须标 temperature_supported=false, + // 协议转换层据此省略该字段。 + assert!( + !m.temperature_supported, + "sensenova 应标 temperature_supported=false(仅接受 1.0)" + ); + } + + /// 温度支持判定:仅 sensenova 系列为 false,其余模型默认 true(零回归)。 + #[test] + fn probe_temperature_supported_defaults_true_except_sensenova() { + assert!(!probe("sensenova-6.7-flash-lite").temperature_supported); + // 启发式路径(非预设表):sensenova 前缀仍判 false + assert!(!probe("sensenova-unknown-model").temperature_supported); + // 常规模型保持 true(原样透传) + assert!(probe("deepseek-v4-flash").temperature_supported); + assert!(probe("glm-4v").temperature_supported); } #[test] diff --git a/crates/df-ai/src/model_probe_helpers.rs b/crates/df-ai/src/model_probe_helpers.rs index ab38ee0..158e0f3 100644 --- a/crates/df-ai/src/model_probe_helpers.rs +++ b/crates/df-ai/src/model_probe_helpers.rs @@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig { intelligence, weight: 50, context_window: 8192, + temperature_supported: temperature_supported_by_name(&name), probe_source: None, }; } @@ -106,6 +107,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig { intelligence, weight: 50, context_window: 8192, + temperature_supported: temperature_supported_by_name(&name), probe_source: None, } } @@ -120,3 +122,11 @@ pub(super) fn has_vision_token(name: &str) -> bool { || name.contains("vl") || name.split(['-', '.', '_']).any(|seg| seg.ends_with('v')) } + +/// 温度参数支持判定(命名约定式):商汤 sensenova 系列仅接受 temperature=1.0, +/// 透传其它温度值(标题 0.3/压缩 0.2 等)→ 上游 HTTP 400(实测 9 次)。 +/// 返回 false 时协议转换层省略 temperature 字段(交上游默认),避免无效往返; +/// 其余模型默认 true(原样透传,零回归)。 +pub(super) fn temperature_supported_by_name(name: &str) -> bool { + !name.starts_with("sensenova") +} diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index e723065..93fcdf5 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -209,10 +209,21 @@ impl OpenAICompatProvider { .collect() }); + // 模型不支持 temperature(如商汤 sensenova 仅接受 1.0)→ 省略该字段,避免上游 HTTP 400。 + // 判定走 model_probe(命中 presets 表;未命中默认支持,保持原样透传零回归)。 + let temperature = if req.temperature.is_some() + && !crate::model_probe::probe(&model).temperature_supported + { + tracing::debug!(model = %model, "模型不支持 temperature,已省略该参数"); + None + } else { + req.temperature + }; + OpenAiRequest { model, messages, - temperature: req.temperature, + temperature, max_tokens: req.max_tokens, stream: req.stream, tools, diff --git a/crates/df-ai/src/openai_helpers.rs b/crates/df-ai/src/openai_helpers.rs index b77a19b..2cb14a6 100644 --- a/crates/df-ai/src/openai_helpers.rs +++ b/crates/df-ai/src/openai_helpers.rs @@ -274,6 +274,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) usage: usage_accum.take(), error: None, reasoning_content: None, + truncated_by_max_tokens: false, }; } @@ -296,6 +297,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) usage: None, error: Some(msg), reasoning_content: None, + truncated_by_max_tokens: false, }; } // 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积) @@ -334,6 +336,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) choice.finish_reason.as_deref(), Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter") ); + // length = 输出被 max_tokens 截断:仍按正常终止收敛(finished=true), + // 但截断标志随 chunk 带出,消费方据此把该轮产出标记截断,不冒充完整。 + let truncated_by_max_tokens = choice.finish_reason.as_deref() == Some("length"); let tool_calls = choice.delta.tool_calls.map(|tcs| { // 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index @@ -379,6 +384,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) usage: usage_accum.clone(), error: None, reasoning_content: choice.delta.reasoning_content, + truncated_by_max_tokens, } } else { // choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出 @@ -389,6 +395,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) usage: usage_accum.clone(), error: None, reasoning_content: None, + truncated_by_max_tokens: false, } } } @@ -401,6 +408,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) usage: None, error: None, reasoning_content: None, + truncated_by_max_tokens: false, } } } @@ -503,4 +511,32 @@ mod tests { ); assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结"); } + + /// finish_reason=length:max_tokens 截断仍按正常终止收敛(finished=true), + /// 但截断标志必须随 chunk 带出(零信号会让腰斩报告冒充完整完成); + /// stop 等正常终态不置标志。 + #[test] + fn finish_reason_length_carries_truncation_flag() { + let mut accum = None; + let chunk = apply_openai_sse( + r#"{"choices":[{"delta":{"content":"报告写到一半"},"finish_reason":"length"}]}"#, + &mut accum, + ); + assert!(chunk.finished, "length 属正常终止"); + assert!(chunk.truncated_by_max_tokens, "length 必须带截断标志"); + + let mut accum2 = None; + let chunk2 = apply_openai_sse( + r#"{"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}"#, + &mut accum2, + ); + assert!(chunk2.finished && !chunk2.truncated_by_max_tokens, "stop 不置截断标志"); + + let mut accum3 = None; + let chunk3 = apply_openai_sse( + r#"{"choices":[{"delta":{"content":"x"},"finish_reason":null}]}"#, + &mut accum3, + ); + assert!(!chunk3.truncated_by_max_tokens, "普通 delta 不置截断标志"); + } } diff --git a/crates/df-ai/src/retry.rs b/crates/df-ai/src/retry.rs index c502359..762773f 100644 --- a/crates/df-ai/src/retry.rs +++ b/crates/df-ai/src/retry.rs @@ -5,7 +5,7 @@ //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! //! 策略: -//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter +//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 @@ -34,6 +34,11 @@ const BASE_BACKOFF_SECS: u64 = 1; /// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。 const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); +/// 单次退避基准上限(毫秒),与 [`MAX_TOTAL_BUDGET`] 同口径: 超预算的退避没有意义, +/// attempt 走深时不再指数膨胀。与总预算的一致性由测试 `backoff_cap_matches_budget` 守住。 +/// (同构先例: df-tunnel 的 backoff_delay 也以 RECONNECT_MAX 封顶。) +const MAX_BACKOFF_MS: u64 = 30_000; + /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。 /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。 const JITTER_RATIO: f64 = 1.0; @@ -65,20 +70,45 @@ pub fn is_status_retryable(status: u16) -> bool { status == 429 || (500..600).contains(&status) } +/// 退避倍率 `2^(attempt-1)`,位移溢出安全。 +/// +/// 替代裸 `1u64 << (attempt - 1)`: attempt > 64 时位移越界(debug panic / release 环绕)、 +/// attempt = 0 时下溢 panic。现 attempt = 0 视作 1(倍率 1),attempt - 1 ≥ 64 时 +/// checked_shl 返回 None,倍率封顶 u64::MAX,最终时长由 [`MAX_BACKOFF_MS`] 兜底。 +/// 全输入无 panic,序列单调不减。 +fn backoff_multiplier(attempt: u32) -> u64 { + match 1u64.checked_shl(attempt.saturating_sub(1)) { + Some(m) => m, + None => u64::MAX, + } +} + +/// 退避基准毫秒(未加 jitter),`BASE_BACKOFF_SECS * 2^(attempt-1) * 1000`,封顶 [`MAX_BACKOFF_MS`]。 +/// +/// 乘法全程 saturating: 倍率封顶 u64::MAX 后 `* 1000` 也会二次溢出,不能裸乘。 +/// 单一事实源——backoff_delay 与单测的 base 断言都走这里,不再各写一份公式。 +fn base_backoff_ms(attempt: u32) -> u64 { + BASE_BACKOFF_SECS + .saturating_mul(backoff_multiplier(attempt)) + .saturating_mul(1000) + .min(MAX_BACKOFF_MS) +} + /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。 /// /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 +/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。 /// /// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用 /// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 pub fn backoff_delay(attempt: u32) -> Duration { - let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; + let base_ms = base_backoff_ms(attempt) as f64; // ±50% jitter,相对 base 时长的浮动比例 let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5) let factor = 1.0 + jitter_ratio * JITTER_RATIO; - let ms = (base_ms as f64 * factor).max(0.0) as u64; + let ms = (base_ms * factor).clamp(0.0, MAX_BACKOFF_MS as f64) as u64; Duration::from_millis(ms) } @@ -160,12 +190,12 @@ mod tests { #[test] fn backoff_is_monotonic_within_jitter() { - // base 序列: 1, 2, 4 秒; jitter ±20% 后仍在 [0.8,4.8] 范围 + // base 序列: 1, 2, 4 秒; jitter ±50%(JITTER_RATIO=1.0) 后仍在 [0.5x, 1.5x] 范围 for attempt in 1..=3 { let d = backoff_delay(attempt); - let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000; - let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64; - let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64; + let base_ms = base_backoff_ms(attempt); + let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64; + let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64; assert!( d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi, "attempt={} delay={:?} 应在 [{},{}]ms", @@ -177,6 +207,64 @@ mod tests { } } + /// 倍率溢出安全: attempt=0 不下溢,≥65 封顶 u64::MAX,序列单调不减 + #[test] + fn backoff_multiplier_extremes_safe() { + assert_eq!(backoff_multiplier(0), 1, "attempt=0 视作 1,不下溢 panic"); + assert_eq!(backoff_multiplier(1), 1); + assert_eq!(backoff_multiplier(2), 2); + assert_eq!(backoff_multiplier(3), 4); + assert_eq!(backoff_multiplier(63), 1u64 << 62); + assert_eq!(backoff_multiplier(64), 1u64 << 63, "u64 可表达的最大位移"); + assert_eq!(backoff_multiplier(65), u64::MAX, "checked_shl 越界封顶"); + assert_eq!(backoff_multiplier(100), u64::MAX); + assert_eq!(backoff_multiplier(u32::MAX), u64::MAX); + let seq: Vec = [0u32, 1, 2, 3, 10, 63, 64, 65, 100, u32::MAX] + .iter() + .map(|&a| backoff_multiplier(a)) + .collect(); + assert!(seq.windows(2).all(|w| w[0] <= w[1]), "倍率应单调不减: {:?}", seq); + } + + /// 基准毫秒单调不减、封顶值正确(2^5×1s=32s 已超 30s 预算,从 attempt=6 起封顶) + #[test] + fn base_backoff_ms_monotonic_and_capped() { + let mut prev = 0u64; + for attempt in [0u32, 1, 2, 3, 4, 5, 6, 10, 63, 64, 65, 100, u32::MAX] { + let b = base_backoff_ms(attempt); + assert!(b >= prev, "attempt={} base_ms={} 应 ≥ 前值 {}", attempt, b, prev); + assert!(b <= MAX_BACKOFF_MS, "attempt={} base_ms={} 应 ≤ {}", attempt, b, MAX_BACKOFF_MS); + prev = b; + } + assert_eq!(base_backoff_ms(1), 1000); + assert_eq!(base_backoff_ms(2), 2000); + assert_eq!(base_backoff_ms(3), 4000); + assert_eq!(base_backoff_ms(5), 16_000, "未触及封顶"); + assert_eq!(base_backoff_ms(6), MAX_BACKOFF_MS, "32s 超预算封顶 30s"); + assert_eq!(base_backoff_ms(u32::MAX), MAX_BACKOFF_MS); + } + + /// 极端 attempt 下 backoff_delay 不 panic(修复: 1u64<<(attempt-1) 曾 debug panic/release 环绕) + #[test] + fn backoff_delay_extreme_attempts_no_panic_and_capped() { + for attempt in [0u32, 1, 10, 63, 64, 65, 100, u32::MAX] { + let ms = backoff_delay(attempt).as_millis() as u64; + assert!( + ms <= MAX_BACKOFF_MS, + "attempt={} 实际 {}ms 应 ≤ {}ms(含 jitter 后仍不超)", + attempt, + ms, + MAX_BACKOFF_MS + ); + } + } + + /// 封顶常量与总预算同口径(改预算时两处同步) + #[test] + fn backoff_cap_matches_budget() { + assert_eq!(MAX_BACKOFF_MS, MAX_TOTAL_BUDGET.as_millis() as u64); + } + #[test] fn status_classification() { assert!(is_status_retryable(429)); diff --git a/crates/df-ai/src/router.rs b/crates/df-ai/src/router.rs index c3d9ae8..df03dcc 100644 --- a/crates/df-ai/src/router.rs +++ b/crates/df-ai/src/router.rs @@ -163,6 +163,7 @@ mod tests { intelligence: IntelligenceTier::Standard, weight: 50, context_window: 8192, + temperature_supported: true, probe_source: None, } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a55483a..b13cbab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,9 @@ async-trait = { workspace = true } # 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。 axum = { version = "0.7" } +# 托盘菜单 i18n:系统 locale 判定(零传递依赖,判定一次缓存) +sys-locale = "0.3" + # 后端 crate df-types = { path = "../crates/df-types" } df-storage = { path = "../crates/df-storage" } diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index e7854d6..a20215c 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -51,7 +51,7 @@ use serde::Deserialize; use serde_json::Value; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use crate::state::AppState; diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index bb7f340..b5e4546 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -102,6 +102,9 @@ pub(crate) enum StreamResult { usage: df_ai::provider::TokenUsage, /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) reasoning_content: Option, + /// 流因 provider max_tokens 上限截断(finish_reason=length / stop_reason=max_tokens)。 + /// true=文本/tool_calls 可能不完整,调用方据此标记该轮产出截断,不冒充正常完成。 + truncated_by_max_tokens: bool, }, /// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text /// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积), @@ -282,6 +285,9 @@ pub(crate) async fn stream_llm( let mut finished_received = false; let mut stopped = false; let mut final_usage: Option = None; + // max_tokens 截断标志跨帧 OR 累积(openai finish_reason=length / anthropic + // stop_reason=max_tokens 的帧先于终态帧到达),Complete 随旗标带出供上层标记截断。 + let mut truncated_by_max_tokens = false; // DeepSeek thinking 模式推理内容累积(多轮需回传) let mut reasoning_content_acc: Option = None; // per-conv delta 合批累加器。pending_delta 累积未 flush 的文本, @@ -419,6 +425,9 @@ pub(crate) async fn stream_llm( if let Some(u) = &chunk.usage { final_usage = Some(u.clone()); } + if chunk.truncated_by_max_tokens { + truncated_by_max_tokens = true; + } if let Some(ref rc) = chunk.reasoning_content { reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); } @@ -540,6 +549,7 @@ pub(crate) async fn stream_llm( tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), reasoning_content: reasoning_content_acc, + truncated_by_max_tokens, }; } @@ -573,6 +583,7 @@ pub(crate) async fn stream_llm( tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), reasoning_content: reasoning_content_acc, + truncated_by_max_tokens, } } diff --git a/src-tauri/src/commands/ai/tools/git.rs b/src-tauri/src/commands/ai/tools/git.rs index 05e9e3d..e9784bc 100644 --- a/src-tauri/src/commands/ai/tools/git.rs +++ b/src-tauri/src/commands/ai/tools/git.rs @@ -156,9 +156,13 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { let mut patch_args = vec!["diff"]; if staged { patch_args.push("--cached"); } let patch_raw = exec_git_exit(working_dir, &patch_args).await.0; - // 截断到 8000 字符(防大体量 diff) + // 截断到 8000 字符(防大体量 diff);中文字节切片撞 UTF-8 边界会 panic,回退到字符边界 let patch_truncated = if patch_raw.len() > 8000 { - format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) + let mut end = 8000; + while !patch_raw.is_char_boundary(end) { + end -= 1; + } + format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..end], patch_raw.len()) } else { patch_raw }; diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index 49cd5e9..ade336a 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -313,7 +313,8 @@ pub async fn evaluate_idea( /// /// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。 /// - provider 构造仅一次(批量场景减少重复初始化开销)。 -/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。 +// 2026-10-04: 未在 lib.rs 注册 IPC(dead_code),保留作批量评估参考实现;复用下方 evaluate_single 共用内部函数。 +#[allow(dead_code)] #[tauri::command] pub async fn evaluate_ideas_batch( state: State<'_, AppState>, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 96c745a..bdbe334 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -4,6 +4,9 @@ //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 //! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime) +//! - i18n:文案按系统 locale 取中/英双语静态映射(locale 判定一次缓存),无 Rust i18n 机制的最小实现 +use std::sync::OnceLock; + use tauri::{ AppHandle, Manager, menu::{Menu, MenuItem}, @@ -12,6 +15,45 @@ use tauri::{ use crate::state::AppState; +/// 托盘文案双语映射(系统 locale 判定一次,进程内缓存;判定失败回退中文——应用主语言)。 +/// 菜单项与运行态文本分两处消费,映射同源防两处漂移。 +struct TrayTexts { + status_idle: &'static str, + status_running: &'static str, + show: &'static str, + quit: &'static str, + tooltip_running: &'static str, +} + +/// 按系统 locale 取托盘文案(判定一次缓存)。zh* → 中文,其余(含判定失败)→ 中文/英文 +/// 各取其一:locale 明确非 zh 用英文,判定失败(None)回退中文(应用主语言)。 +fn tray_texts() -> &'static TrayTexts { + static TEXTS: OnceLock = OnceLock::new(); + TEXTS.get_or_init(|| { + let zh = sys_locale::get_locale() + .map(|l| l.to_ascii_lowercase().starts_with("zh")) + // None = 系统locale不可得,回退中文(应用默认语言),与首次安装体验一致 + .unwrap_or(true); + if zh { + TrayTexts { + status_idle: "无运行中会话", + status_running: "运行中 {} 个会话", + show: "显示主窗口", + quit: "退出", + tooltip_running: "DevFlow (运行中 {} 个会话)", + } + } else { + TrayTexts { + status_idle: "No running sessions", + status_running: "{} session(s) running", + show: "Show Main Window", + quit: "Quit", + tooltip_running: "DevFlow ({} session(s) running)", + } + } + }) +} + /// managed state 保活,防 GC 移除托盘图标。 /// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip); /// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。 @@ -24,10 +66,11 @@ pub struct TrayHandle { /// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。 pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { + let t = tray_texts(); // 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护 - let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?; - let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let status = MenuItem::with_id(app, "status", t.status_idle, false, None::<&str>)?; + let show = MenuItem::with_id(app, "show", t.show, true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", t.quit, true, None::<&str>)?; let menu = Menu::with_items(app, &[&status, &show, &quit])?; // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 let icon = app @@ -60,14 +103,15 @@ pub fn update_tray_runtime(app: &AppHandle) { else { return; }; + let t = tray_texts(); let n = state.conv_states.active_convs().len(); let text = if n > 0 { - format!("运行中 {} 个会话", n) + t.status_running.replace("{}", &n.to_string()) } else { - "无运行中会话".to_string() + t.status_idle.to_string() }; let tooltip = if n > 0 { - format!("DevFlow (运行中 {} 个会话)", n) + t.tooltip_running.replace("{}", &n.to_string()) } else { "DevFlow (MCP server running)".to_string() }; diff --git a/src/App.vue b/src/App.vue index dcbdc81..d6ca16b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -157,6 +157,10 @@ function initTheme() { // UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰 function handleKeydown(e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key === 'i') { + // 输入焦点守卫(2026-10-04 回归修复):输入控件内 Ctrl+I 让路(打字/组合键不切面板), + // 对齐 AiChat onGlobalKeydown Esc 分支的 closest target 守卫写法 + const el = e.target as HTMLElement | null + if (el?.closest?.('input, textarea, [contenteditable]')) return e.preventDefault() aiStore.cyclePanelMode() } diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index bc5dcf5..f2d20f7 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -15,6 +15,8 @@ /> + +
{{ t('aiChat.imageIgnoredWarn') }}