修复: 协议层与体验缺陷六件

- temperature 按模型适配,仅收 1 的模型不再 400
- 流式 error 帧不再被 choices 必填整帧丢弃
- 只读缓存写后失效
- git diff 中文截断撞 UTF-8 边界 panic
- 非视觉模型纯图片直接拒绝
- 全局快捷键补输入焦点守卫
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent 01d231c39d
commit 7aa0da32f2
28 changed files with 491 additions and 52 deletions
Generated
+10
View File
@@ -869,6 +869,7 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -4422,6 +4423,15 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.7.0" version = "0.7.0"
+12
View File
@@ -138,6 +138,14 @@ pub struct ModelConfig {
#[serde(default = "default_context_window")] #[serde(default = "default_context_window")]
pub context_window: usize, 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)── // ── 探测元数据(只读,由 ModelProbe 填充,Phase 2)──
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub probe_source: Option<ProbeSource>, pub probe_source: Option<ProbeSource>,
@@ -173,6 +181,9 @@ impl ModelConfig {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: default_weight(), weight: default_weight(),
context_window: default_context_window(), context_window: default_context_window(),
// 默认支持 temperature(老格式升级/未知模型保持原样透传,零回归);
// 明确不支持的模型由 presets 表 temperature_supported=false 标注。
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
@@ -339,6 +350,7 @@ mod tests {
intelligence: IntelligenceTier::Plus, intelligence: IntelligenceTier::Plus,
weight: 70, weight: 70,
context_window: 128_000, context_window: 128_000,
temperature_supported: true,
probe_source: Some(ProbeSource::PresetTable), probe_source: Some(ProbeSource::PresetTable),
}; };
let json = serde_json::to_string(&mc).unwrap(); let json = serde_json::to_string(&mc).unwrap();
+1
View File
@@ -472,6 +472,7 @@ mod tests {
usage: None, usage: None,
error: None, error: None,
reasoning_content: Some("thought".to_string()), reasoning_content: Some("thought".to_string()),
truncated_by_max_tokens: false,
}; };
let json = serde_json::to_string(&chunk).unwrap(); let json = serde_json::to_string(&chunk).unwrap();
assert!(json.contains("reasoning_content")); assert!(json.contains("reasoning_content"));
+5
View File
@@ -350,6 +350,11 @@ pub struct StreamChunk {
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
#[serde(skip)] #[serde(skip)]
pub error: Option<String>, pub error: Option<String>,
/// 本流因 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 模式推理内容增量 /// DeepSeek thinking 模式推理内容增量
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>, pub reasoning_content: Option<String>,
+2 -1
View File
@@ -133,7 +133,8 @@
"modalities": ["text", "vision"], "modalities": ["text", "vision"],
"capabilities": ["tool_use"], "capabilities": ["tool_use"],
"weight": 85, "weight": 85,
"context_window": 262144 "context_window": 262144,
"temperature_supported": false
}, },
{ {
"model_id": "deepseek-v4-flash", "model_id": "deepseek-v4-flash",
+27 -1
View File
@@ -262,12 +262,23 @@ impl AnthropicCompatProvider {
.collect() .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 { AnthropicRequest {
model, model,
messages, messages,
max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
system, system,
temperature: req.temperature, temperature,
stream: req.stream, stream: req.stream,
tools, tools,
tool_choice: req.tool_choice, tool_choice: req.tool_choice,
@@ -927,6 +938,21 @@ mod tests {
assert_eq!(a.total_tokens, 15); 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<TokenUsage> = 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<TokenUsage> = 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 /// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13
#[test] #[test]
fn anthropic_message_delta_output_is_cumulative_not_incremental() { fn anthropic_message_delta_output_is_cumulative_not_incremental() {
+16 -10
View File
@@ -137,7 +137,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let v: serde_json::Value = match serde_json::from_str(data) { let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v, Ok(v) => v,
Err(_) => { 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(""); 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<TokenUs
reasoning_tokens: 0, reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens: false }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
@@ -179,9 +179,12 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens); acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
} }
// stop_reason=max_tokens(message_delta 帧携带):输出被 max_tokens 截断。
// 终态 finished 在后续 message_stop 帧,截断标志挂本帧,消费方跨帧 OR 累积。
let truncated_by_max_tokens = v.get("delta").and_then(|d| d.get("stop_reason")).and_then(|s| s.as_str()) == Some("max_tokens");
// 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop // 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。 // 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None, truncated_by_max_tokens }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
@@ -190,16 +193,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。 // 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("thinking_delta") {
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text) }; return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: Some(text), truncated_by_max_tokens: false };
} }
// signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传 // signature_delta:thinking 块签名字段,仅回传校验用。DevFlow 从不把 thinking 回传
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。 // anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("signature_delta") {
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 };
} }
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None, truncated_by_max_tokens: false };
} }
// 工具入参增量 // 工具入参增量
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
@@ -217,10 +220,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
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 }
} }
// 工具块开始:带 id + name // 工具块开始:带 id + name
"content_block_start" => { "content_block_start" => {
@@ -253,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
} }
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 }
} }
// 消息结束:带出累积 usage // 消息结束:带出累积 usage
"message_stop" => StreamChunk { "message_stop" => StreamChunk {
@@ -266,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}, },
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库), // 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。 // 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
"error" => { "error" => {
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); 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 流式错误事件"); 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 // 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 },
} }
} }
+23 -8
View File
@@ -841,12 +841,12 @@ fn phase1_push_only_keeps_clean_no_dirty_flag() {
#[test] #[test]
fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() { 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)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("x")); mgr.push(ChatMessage::user("x"));
mgr.push(ChatMessage::user("y")); mgr.push(ChatMessage::user("y"));
// 模拟 save 成功后推进 // 模拟 save(全量重写)成功后推进
mgr.advance_persisted_count(2); mgr.advance_persisted_count(2, true);
assert_eq!(mgr.persisted_msg_count(), 2); assert_eq!(mgr.persisted_msg_count(), 2);
assert!(!mgr.needs_full_rewrite()); 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() { fn phase1_advance_persisted_count_max_no_regression() {
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写) // 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.advance_persisted_count(5); mgr.advance_persisted_count(5, true);
mgr.advance_persisted_count(3); // 误传更小 mgr.advance_persisted_count(3, true); // 误传更小
assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退"); 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] #[test]
fn phase1_compress_old_messages_sets_dirty() { fn phase1_compress_old_messages_sets_dirty() {
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛 // compress 改既有消息 status(active→compressed) → 必须全量重写收敛
@@ -881,7 +896,7 @@ fn phase1_compress_no_op_does_not_set_dirty() {
mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("a"));
let _ = mgr.compress_old_messages(1); let _ = mgr.compress_old_messages(1);
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty"); assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
mgr.advance_persisted_count(1); // 模拟 save 后清 dirty mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty // 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
let compressed = mgr.compress_old_messages(1); let compressed = mgr.compress_old_messages(1);
assert!(compressed.is_empty()); assert!(compressed.is_empty());
@@ -974,7 +989,7 @@ fn phase1_clear_resets_baseline_and_sets_dirty() {
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB) // clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("a"));
mgr.advance_persisted_count(1); mgr.advance_persisted_count(1, true);
assert_eq!(mgr.persisted_msg_count(), 1); assert_eq!(mgr.persisted_msg_count(), 1);
mgr.clear(); mgr.clear();
assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0"); 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")); // 追加 mgr.push(ChatMessage::user("b")); // 追加
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty"); assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
// 模拟 save(全量重写)成功后推进 + 清 // 模拟 save(全量重写)成功后推进 + 清
mgr.advance_persisted_count(2); mgr.advance_persisted_count(2, true);
assert!(!mgr.needs_full_rewrite()); assert!(!mgr.needs_full_rewrite());
} }
+6 -2
View File
@@ -528,12 +528,16 @@ impl ContextManager {
} }
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。 /// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
/// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。 /// `full_rewrite_applied`=本次写库是否走了全量重写(快照时刻 needs_full_rewrite 快照)。
pub fn advance_persisted_count(&mut self, new_count: usize) { /// 仅全量重写覆盖了既有行才清 needs_full_rewrite;增量/无变化路径写库期间新置的 dirty
/// (如压缩链 mark)与本次 append 写库无关,误清会致改动滞留内存永不落库。供 save_conversation 写库成功后调用。
pub fn advance_persisted_count(&mut self, new_count: usize, full_rewrite_applied: bool) {
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。 // 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
self.persisted_msg_count = self.persisted_msg_count.max(new_count); self.persisted_msg_count = self.persisted_msg_count.max(new_count);
if full_rewrite_applied {
self.needs_full_rewrite = false; self.needs_full_rewrite = false;
} }
}
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。 /// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
pub fn needs_full_rewrite(&self) -> bool { pub fn needs_full_rewrite(&self) -> bool {
+18
View File
@@ -159,6 +159,24 @@ mod tests {
assert!(m.modalities.contains(&Modality::Text)); assert!(m.modalities.contains(&Modality::Text));
// context_window 应为预设的 256K // context_window 应为预设的 256K
assert_eq!(m.context_window, 262144); 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] #[test]
+10
View File
@@ -74,6 +74,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
}; };
} }
@@ -106,6 +107,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
intelligence, intelligence,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: temperature_supported_by_name(&name),
probe_source: None, probe_source: None,
} }
} }
@@ -120,3 +122,11 @@ pub(super) fn has_vision_token(name: &str) -> bool {
|| name.contains("vl") || name.contains("vl")
|| name.split(['-', '.', '_']).any(|seg| seg.ends_with('v')) || 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")
}
+12 -1
View File
@@ -209,10 +209,21 @@ impl OpenAICompatProvider {
.collect() .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 { OpenAiRequest {
model, model,
messages, messages,
temperature: req.temperature, temperature,
max_tokens: req.max_tokens, max_tokens: req.max_tokens,
stream: req.stream, stream: req.stream,
tools, tools,
+36
View File
@@ -274,6 +274,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.take(), usage: usage_accum.take(),
error: None, error: None,
reasoning_content: 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<TokenUsage>)
usage: None, usage: None,
error: Some(msg), error: Some(msg),
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
}; };
} }
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积) // 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
@@ -334,6 +336,9 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
choice.finish_reason.as_deref(), choice.finish_reason.as_deref(),
Some("stop") | Some("tool_calls") | Some("length") | Some("content_filter") 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| { let tool_calls = choice.delta.tool_calls.map(|tcs| {
// 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index // 缺 index 的增量缺省 0(单工具调用语义等价);但同帧多 call 且 index
@@ -379,6 +384,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.clone(), usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
truncated_by_max_tokens,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出 // choices 为空 = usage-only chunk,不输出文本(usage 已累积),usage 一并带出
@@ -389,6 +395,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
usage: usage_accum.clone(), usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: 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<TokenUsage>)
usage: None, usage: None,
error: None, error: None,
reasoning_content: None, reasoning_content: None,
truncated_by_max_tokens: false,
} }
} }
} }
@@ -503,4 +511,32 @@ mod tests {
); );
assert!(!chunk2.finished, "无终态 finish_reason 的普通 delta 不应终结"); 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 不置截断标志");
}
} }
+95 -7
View File
@@ -5,7 +5,7 @@
//! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。 //! 但重试能在不侵入上层的前提下吸收瞬态错误,对低频后台调用零风险。
//! //!
//! 策略: //! 策略:
//! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±20% jitter //! - 最多 3 次尝试(初次 + 2 次重试),指数退避 1s → 2s → 4s + ±50% jitter
//! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误 //! - 总挂钟预算 30s(含 sleep + 各次请求耗时),超预算直接放弃重试返回最后错误
//! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错 //! - 仅对可重试错误重试: timeout / connect / 5xx / 429; 不重试 4xx(非429) / 参数错 / 鉴权错
//! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合 //! - 不引入 reqwest-retry / tower: complete 路径简单,手写 loop 零依赖、可调试、不与 SSE/双层 Semaphore 耦合
@@ -34,6 +34,11 @@ const BASE_BACKOFF_SECS: u64 = 1;
/// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。 /// 与单请求 60s timeout 配合: 最坏 3 次 × 60s = 180s,30s 预算会在此之前主动止损。
const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); 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 的 ±比例)。避免重试风暴对齐。 /// jitter 上限(相对 base 的 ±比例)。避免重试风暴对齐。
/// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。 /// CR-XX: 范围扩到 ±50%(由 gen_range(-0.5..0.5) × JITTER_RATIO=1.0 合成)。
const JITTER_RATIO: f64 = 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) 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 的时长。 /// 指数退避 + jitter: 返回第 `attempt`(1-based)次重试前应 sleep 的时长。
/// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。 /// attempt=1 → ~1s, attempt=2 → ~2s, attempt=3 → ~4s,各 ±50% jitter。
/// ///
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。 /// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
/// 全输入安全: attempt 任意 u32(含 0 / >64)不 panic,结果 ≤ [`MAX_BACKOFF_MS`]。
/// ///
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用 /// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 /// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration { 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 时长的浮动比例 // ±50% jitter,相对 base 时长的浮动比例
let jitter_ratio = rand::thread_rng().gen_range(-0.5..0.5); // [-0.5, 0.5) 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 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) Duration::from_millis(ms)
} }
@@ -160,12 +190,12 @@ mod tests {
#[test] #[test]
fn backoff_is_monotonic_within_jitter() { 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 { for attempt in 1..=3 {
let d = backoff_delay(attempt); let d = backoff_delay(attempt);
let base_ms = (BASE_BACKOFF_SECS * (1u64 << (attempt - 1))) * 1000; let base_ms = base_backoff_ms(attempt);
let lo = (base_ms as f64 * (1.0 - JITTER_RATIO)) as u64; let lo = (base_ms as f64 * (1.0 - JITTER_RATIO / 2.0)) as u64;
let hi = (base_ms as f64 * (1.0 + JITTER_RATIO)) as u64; let hi = (base_ms as f64 * (1.0 + JITTER_RATIO / 2.0)) as u64;
assert!( assert!(
d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi, d.as_millis() as u64 >= lo && d.as_millis() as u64 <= hi,
"attempt={} delay={:?} 应在 [{},{}]ms", "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<u64> = [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] #[test]
fn status_classification() { fn status_classification() {
assert!(is_status_retryable(429)); assert!(is_status_retryable(429));
+1
View File
@@ -163,6 +163,7 @@ mod tests {
intelligence: IntelligenceTier::Standard, intelligence: IntelligenceTier::Standard,
weight: 50, weight: 50,
context_window: 8192, context_window: 8192,
temperature_supported: true,
probe_source: None, probe_source: None,
} }
} }
+3
View File
@@ -39,6 +39,9 @@ async-trait = { workspace = true }
# 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。 # 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。
axum = { version = "0.7" } axum = { version = "0.7" }
# 托盘菜单 i18n:系统 locale 判定(零传递依赖,判定一次缓存)
sys-locale = "0.3"
# 后端 crate # 后端 crate
df-types = { path = "../crates/df-types" } df-types = { path = "../crates/df-types" }
df-storage = { path = "../crates/df-storage" } df-storage = { path = "../crates/df-storage" }
+1 -1
View File
@@ -51,7 +51,7 @@
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, State};
use crate::state::AppState; use crate::state::AppState;
+11
View File
@@ -102,6 +102,9 @@ pub(crate) enum StreamResult {
usage: df_ai::provider::TokenUsage, usage: df_ai::provider::TokenUsage,
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求) /// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>, reasoning_content: Option<String>,
/// 流因 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 /// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
/// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积), /// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积),
@@ -282,6 +285,9 @@ pub(crate) async fn stream_llm(
let mut finished_received = false; let mut finished_received = false;
let mut stopped = false; let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None; let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// max_tokens 截断标志跨帧 OR 累积(openai finish_reason=length / anthropic
// stop_reason=max_tokens 的帧先于终态帧到达),Complete 随旗标带出供上层标记截断。
let mut truncated_by_max_tokens = false;
// DeepSeek thinking 模式推理内容累积(多轮需回传) // DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = None; let mut reasoning_content_acc: Option<String> = None;
// per-conv delta 合批累加器。pending_delta 累积未 flush 的文本, // per-conv delta 合批累加器。pending_delta 累积未 flush 的文本,
@@ -419,6 +425,9 @@ pub(crate) async fn stream_llm(
if let Some(u) = &chunk.usage { if let Some(u) = &chunk.usage {
final_usage = Some(u.clone()); final_usage = Some(u.clone());
} }
if chunk.truncated_by_max_tokens {
truncated_by_max_tokens = true;
}
if let Some(ref rc) = chunk.reasoning_content { if let Some(ref rc) = chunk.reasoning_content {
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); 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, tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(), usage: final_usage.unwrap_or_default(),
reasoning_content: reasoning_content_acc, reasoning_content: reasoning_content_acc,
truncated_by_max_tokens,
}; };
} }
@@ -573,6 +583,7 @@ pub(crate) async fn stream_llm(
tool_calls: tool_calls_acc, tool_calls: tool_calls_acc,
usage: final_usage.unwrap_or_default(), usage: final_usage.unwrap_or_default(),
reasoning_content: reasoning_content_acc, reasoning_content: reasoning_content_acc,
truncated_by_max_tokens,
} }
} }
+6 -2
View File
@@ -156,9 +156,13 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut patch_args = vec!["diff"]; let mut patch_args = vec!["diff"];
if staged { patch_args.push("--cached"); } if staged { patch_args.push("--cached"); }
let patch_raw = exec_git_exit(working_dir, &patch_args).await.0; 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 { 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 { } else {
patch_raw patch_raw
}; };
+2 -1
View File
@@ -313,7 +313,8 @@ pub async fn evaluate_idea(
/// ///
/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。 /// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。
/// - provider 构造仅一次(批量场景减少重复初始化开销)。 /// - provider 构造仅一次(批量场景减少重复初始化开销)。
/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。 // 2026-10-04: 未在 lib.rs 注册 IPC(dead_code),保留作批量评估参考实现;复用下方 evaluate_single 共用内部函数。
#[allow(dead_code)]
#[tauri::command] #[tauri::command]
pub async fn evaluate_ideas_batch( pub async fn evaluate_ideas_batch(
state: State<'_, AppState>, state: State<'_, AppState>,
+50 -6
View File
@@ -4,6 +4,9 @@
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束 //! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除 //! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
//! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime) //! - P1 bg 化:运行态菜单项 + tooltip 随 ConvState 迁移刷新(guard.transition 挂钩 update_tray_runtime)
//! - i18n:文案按系统 locale 取中/英双语静态映射(locale 判定一次缓存),无 Rust i18n 机制的最小实现
use std::sync::OnceLock;
use tauri::{ use tauri::{
AppHandle, Manager, AppHandle, Manager,
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
@@ -12,6 +15,45 @@ use tauri::{
use crate::state::AppState; 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<TrayTexts> = 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 移除托盘图标。 /// managed state 保活,防 GC 移除托盘图标。
/// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip); /// tray 持 TrayIcon 本体(保活为主,update_tray_runtime 借用刷 tooltip);
/// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。 /// status 为运行态纯展示项句柄,供 update_tray_runtime 刷文本。
@@ -24,10 +66,11 @@ pub struct TrayHandle {
/// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。 /// 创建托盘:图标 + 菜单(运行态/显示主窗口/退出)。
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let t = tray_texts();
// 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护 // 运行态纯展示项:enabled=false 置灰不响应点击,文本由 update_tray_runtime 维护
let status = MenuItem::with_id(app, "status", "无运行中会话", false, None::<&str>)?; let status = MenuItem::with_id(app, "status", t.status_idle, false, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", t.show, true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", t.quit, true, None::<&str>)?;
let menu = Menu::with_items(app, &[&status, &show, &quit])?; let menu = Menu::with_items(app, &[&status, &show, &quit])?;
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置 // 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
let icon = app let icon = app
@@ -60,14 +103,15 @@ pub fn update_tray_runtime(app: &AppHandle) {
else { else {
return; return;
}; };
let t = tray_texts();
let n = state.conv_states.active_convs().len(); let n = state.conv_states.active_convs().len();
let text = if n > 0 { let text = if n > 0 {
format!("运行中 {} 个会话", n) t.status_running.replace("{}", &n.to_string())
} else { } else {
"无运行中会话".to_string() t.status_idle.to_string()
}; };
let tooltip = if n > 0 { let tooltip = if n > 0 {
format!("DevFlow (运行中 {} 个会话)", n) t.tooltip_running.replace("{}", &n.to_string())
} else { } else {
"DevFlow (MCP server running)".to_string() "DevFlow (MCP server running)".to_string()
}; };
+4
View File
@@ -157,6 +157,10 @@ function initTheme() {
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰 // UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'i') { 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() e.preventDefault()
aiStore.cyclePanelMode() aiStore.cyclePanelMode()
} }
+31
View File
@@ -15,6 +15,8 @@
/> />
<!-- F-260614-05 Phase 2b: 待发送图片预览 --> <!-- F-260614-05 Phase 2b: 待发送图片预览 -->
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" /> <ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</div>
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="inputText" v-model="inputText"
@@ -304,6 +306,22 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
return textActive ? 'stop' : 'idle' return textActive ? 'stop' : 'idle'
}) })
// ── B1: 非视觉模型贴图前置感知 ──
// 有效模型判定镜像 TopBar 口径(active provider → enabled 池 → override 命中优先,否则权重最高)。
// 池未知(未拉取/空)时保守视为支持(不误伤;后端 model_supports_vision 有 model_probe 兜底,
// 发图照旧温和降级)。发送仍允许(与现状兼容),仅以警示 chip 持续可见防「静默丢图」误判网络问题。
const currentModelSupportsVision = computed<boolean>(() => {
const p = store.state.providers.find(x => x.id === store.state.activeProvider)
|| store.state.providers.find(x => x.is_default)
|| store.state.providers[0]
if (!p?.model_configs?.length) return true
const models = p.model_configs.filter(m => m.enabled)
const chosen = (store.modelOverride.value ? models.find(m => m.model_id === store.modelOverride.value) : undefined) || models[0]
if (!chosen) return true
return (chosen.modalities ?? []).includes('vision')
})
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value)
// ── 技能 `/` 联想 ── // ── 技能 `/` 联想 ──
const skillOpen = ref(false) const skillOpen = ref(false)
const skillIndex = ref(0) const skillIndex = ref(0)
@@ -849,6 +867,12 @@ async function handleSend() {
const skill = pendingSkill.value const skill = pendingSkill.value
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用) // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
if (!text && !skill && pendingImages.value.length === 0) return if (!text && !skill && pendingImages.value.length === 0) return
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) {
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
return
}
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。 // F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的 // 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。 // user 消息渲染多模态图,后端请求仍走 content 文本路径。
@@ -1181,4 +1205,11 @@ defineExpose({
} }
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */ /* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
.ai-img-warn-chip {
font-size: 10.5px;
color: var(--df-warning);
padding: 1px 4px;
}
</style> </style>
+38 -2
View File
@@ -34,7 +34,8 @@
<section class="panel" v-if="connForm.visible"> <section class="panel" v-if="connForm.visible">
<div class="panel-header"> <div class="panel-header">
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2> <h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">{{ $t('common.cancel') }}</button> <!-- L19 对齐: 取消走 closeConnForm,脏表单二次确认后再关(防误弃改动) -->
<button class="btn btn-ghost btn-sm" @click="closeConnForm">{{ $t('common.cancel') }}</button>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<div class="form-field"> <div class="form-field">
@@ -77,7 +78,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted } from 'vue' import { reactive, ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
@@ -120,6 +121,8 @@ const connForm = reactive({
port: DEFAULT_PORTS.mysql, port: DEFAULT_PORTS.mysql,
user: '', user: '',
password: '', password: '',
// L19 对齐: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isConnDirty 比对(同 ProviderPanel 范式)
savedSnapshot: '' as string,
}) })
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮 // 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
@@ -146,6 +149,34 @@ function typeLabel(type: string) {
return map[type] || type return map[type] || type
} }
// L19 对齐: 脏表单二次确认——关闭前与已落库快照比对,有改动先 confirmDialog 防误关丢配置
// (password 计入快照:输入了未保存的密钥同样算脏)
function connBusinessSnapshot(): string {
return JSON.stringify({
name: connForm.name,
type: connForm.type,
host: connForm.host,
port: connForm.port,
user: connForm.user,
password: connForm.password,
})
}
const isConnDirty = computed(() => {
if (!connForm.visible) return false
return connBusinessSnapshot() !== connForm.savedSnapshot
})
/** 关闭表单入口(模板「取消」用):脏表单二次确认后再关,防误弃改动 */
async function closeConnForm() {
if (isConnDirty.value) {
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
if (!ok) return // 用户取消关闭,继续编辑
}
connForm.visible = false
connForm.savedSnapshot = ''
}
function openConnForm(conn?: ConnRecord) { function openConnForm(conn?: ConnRecord) {
if (conn) { if (conn) {
connForm.editId = conn.id connForm.editId = conn.id
@@ -165,6 +196,8 @@ function openConnForm(conn?: ConnRecord) {
connForm.password = '' connForm.password = ''
} }
connForm.visible = true connForm.visible = true
// 记初始快照(visible=true、字段回填完成后),isConnDirty 据此比对
connForm.savedSnapshot = connBusinessSnapshot()
} }
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。 /** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
@@ -202,7 +235,10 @@ function saveConn() {
connections.value.push(record) connections.value.push(record)
} }
persistConnections() persistConnections()
// 成功明确反馈:关表单 + 清快照(刚落库无脏,直接关不走二次确认)+ 绿色「已保存」toast(对齐 ProviderPanel)
connForm.visible = false connForm.visible = false
connForm.savedSnapshot = ''
emit('toast', t('settings.toastSaved'), 'success')
} finally { } finally {
submitting.value = false submitting.value = false
} }
+23 -1
View File
@@ -521,6 +521,12 @@ async function fetchModels() {
apiKey: providerForm.apiKey, apiKey: providerForm.apiKey,
providerId: providerForm.editId || null, providerId: providerForm.editId || null,
}) })
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
if (models.length === 0) {
emit('toast', t('settings.toastFetchEmpty'), 'warning')
return
}
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖 // 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。 // (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重 // enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
@@ -599,6 +605,20 @@ async function saveProvider() {
} else { } else {
providerForm.temperature = null providerForm.temperature = null
} }
// 模型权重钳制 [0,100](同温度范式:原生 min/max 只约束步进按钮,手输越界照存)。
// 越界/小数 → clamp+取整;清空残留非数值(v-model.number 得 '')→ 归一 50(对齐负载池 NaN 兜底)。
// 多行聚合一次 toast,逐行报会刷屏。
let weightClamped = false
for (const m of providerForm.models) {
if (typeof m.weight === 'number' && Number.isFinite(m.weight)) {
const c = Math.min(100, Math.max(0, Math.trunc(m.weight)))
if (c !== m.weight) { m.weight = c; weightClamped = true }
} else {
m.weight = 50
weightClamped = true
}
}
if (weightClamped) emit('toast', t('settings.toastWeightClamped'), 'warning')
providerForm.saving = true providerForm.saving = true
try { try {
await aiApi.saveProvider({ await aiApi.saveProvider({
@@ -672,7 +692,9 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
const prev = p.enabled const prev = p.enabled
p.enabled = enabled p.enabled = enabled
try { try {
await aiApi.updateProviderPool(p.id, enabled, p.weight ?? 50) // 防御钳制:p.weight 理论上已被 onPoolWeightChange 钳过,此处兜底防脏数据直传 IPC
const w = Math.min(100, Math.max(0, p.weight ?? 50))
await aiApi.updateProviderPool(p.id, enabled, w)
emit('toast', t('settings.toastPoolUpdateOk'), 'info') emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) { } catch (e) {
p.enabled = prev // revert p.enabled = prev // revert
+2
View File
@@ -23,6 +23,7 @@ export default {
// Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value) // Out-of-range clamp notices (concurrency 1-64 + per-conv ≤ global; temperature reports the corrected value)
toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)', toastConcurrencyClamped: 'Value out of range, corrected automatically (concurrency 1-{max}; per-conversation capped at global)',
toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}', toastTempClamped: 'Sampling temperature must be within 0-2, corrected to {v}',
toastWeightClamped: 'Model weight must be within 0-100; out-of-range values corrected (cleared inputs default to 50)',
// Prompt caching toggle save feedback (previously silent) // Prompt caching toggle save feedback (previously silent)
toastAnthropicCacheSaved: 'Prompt caching setting saved', toastAnthropicCacheSaved: 'Prompt caching setting saved',
toastAnthropicCacheFail: 'Failed to save the prompt caching setting', toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
@@ -88,6 +89,7 @@ export default {
saveModels: 'Save model config', saveModels: 'Save model config',
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them', modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
toastFetchOk: 'Fetched {count} models', toastFetchOk: 'Fetched {count} models',
toastFetchEmpty: 'Fetched 0 models — existing config kept',
toastFetchFail: 'Fetch failed: {msg}', toastFetchFail: 'Fetch failed: {msg}',
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}', fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}', fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
+2
View File
@@ -23,6 +23,7 @@ export default {
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值) // 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)', toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}', toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
toastWeightClamped: '模型权重须在 0-100 范围内,越界值已修正(清空视为 50)',
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈) // 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存', toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败', toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
@@ -87,6 +88,7 @@ export default {
saveModels: '保存模型配置', saveModels: '保存模型配置',
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃', modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
toastFetchOk: '已拉取 {count} 个模型', toastFetchOk: '已拉取 {count} 个模型',
toastFetchEmpty: '拉取到 0 个模型,已保留现有配置',
toastFetchFail: '拉取模型失败:{msg}', toastFetchFail: '拉取模型失败:{msg}',
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}', fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}', fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}',
+42 -7
View File
@@ -3,6 +3,7 @@
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责: //! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读 //! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写 //! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
//! - 落库失败保留 pending 脏值待下次写入重试,经 lastWriteError 透出(见 set/flush)
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key //! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
//! //!
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 —— //! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
@@ -24,6 +25,11 @@ const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照) /// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
const pendingValues = new Map<string, unknown>() const pendingValues = new Map<string, unknown>()
/// 最近一次落库失败(key + 错误),成功落库同一 key 后清 null。
/// store 层无组件上下文,不直接弹 toast,对齐 knowledge.ts 的 state.error 范式,
/// 上层 watch 此状态做行内红字/toast 通报。
const lastWriteError = ref<{ key: string; error: unknown } | null>(null)
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */ /** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
async function loadAll(): Promise<void> { async function loadAll(): Promise<void> {
const all = await settingsApi.getAll() const all = await settingsApi.getAll()
@@ -41,6 +47,9 @@ function get<T>(key: string, defaultValue: T): T {
/** /**
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。 * 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。 * 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
*
* promise 契约:resolve 仅代表"已调度落库",不代表已落库;本 promise 永不
* reject(调用方 void/await 均安全),落库成败经 lastWriteError 透出。
*/ */
async function set(key: string, value: unknown): Promise<void> { async function set(key: string, value: unknown): Promise<void> {
cache[key] = value cache[key] = value
@@ -51,18 +60,35 @@ async function set(key: string, value: unknown): Promise<void> {
const timer = setTimeout(() => { const timer = setTimeout(() => {
pendingTimers.delete(key) pendingTimers.delete(key)
const latest = pendingValues.get(key) void flush(key)
pendingValues.delete(key)
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
settingsApi
.set(key, JSON.stringify(latest))
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
}, SET_DEBOUNCE_MS) }, SET_DEBOUNCE_MS)
pendingTimers.set(key, timer) pendingTimers.set(key, timer)
} }
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */ /**
* 把 pending 中该 key 的最新值落库。失败时保留 pending 脏值不清除,
* 下次该 key 任何 set 会合并进同一队列自然重试;cache 保持乐观值不回跳
* (避免落库失败把用户已输入的视图打回去),失败经 lastWriteError 与
* console.error 双通道透出,状态不再"假成功"。
*/
async function flush(key: string): Promise<void> {
const latest = pendingValues.get(key)
if (latest === undefined) return
try {
await settingsApi.set(key, JSON.stringify(latest))
// 成功才清 pending;若落库期间又有新写合并进来,保留新值交由新定时器落库
if (Object.is(pendingValues.get(key), latest)) pendingValues.delete(key)
if (lastWriteError.value?.key === key) lastWriteError.value = null
} catch (e) {
console.error(`[appSettings] 写入 ${key} 失败,已标记待重试(下次改动该 key 时重试):`, e)
lastWriteError.value = { key, error: e }
}
}
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除;删除失败恢复内存态后原样上抛 */
async function remove(key: string): Promise<void> { async function remove(key: string): Promise<void> {
const prevCache = cache[key]
const prevPending = pendingValues.get(key)
delete cache[key] delete cache[key]
pendingValues.delete(key) pendingValues.delete(key)
const t = pendingTimers.get(key) const t = pendingTimers.get(key)
@@ -70,7 +96,15 @@ async function remove(key: string): Promise<void> {
clearTimeout(t) clearTimeout(t)
pendingTimers.delete(key) pendingTimers.delete(key)
} }
try {
await settingsApi.delete(key) await settingsApi.delete(key)
} catch (e) {
// 删除失败:磁盘仍有该值,恢复内存态保持两端一致,避免重启后该 key 幽灵复活而视图已丢
if (prevCache !== undefined) cache[key] = prevCache
if (prevPending !== undefined) pendingValues.set(key, prevPending)
lastWriteError.value = { key, error: e }
throw e
}
} }
/** /**
@@ -149,6 +183,7 @@ function parse(raw: string): unknown {
export function useAppSettingsStore() { export function useAppSettingsStore() {
return { return {
cache, cache,
lastWriteError,
loadAll, loadAll,
get, get,
set, set,