修复: 协议层与体验缺陷六件
- temperature 按模型适配,仅收 1 的模型不再 400 - 流式 error 帧不再被 choices 必填整帧丢弃 - 只读缓存写后失效 - git diff 中文截断撞 UTF-8 边界 panic - 非视觉模型纯图片直接拒绝 - 全局快捷键补输入焦点守卫
This commit is contained in:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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<ProbeSource>,
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -350,6 +350,11 @@ pub struct StreamChunk {
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
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 模式推理内容增量
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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
|
||||
#[test]
|
||||
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
|
||||
|
||||
@@ -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) {
|
||||
Ok(v) => 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<TokenUs
|
||||
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
|
||||
"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.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,对称 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" => {
|
||||
@@ -190,16 +193,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
// 不混入正文 delta。本侧请求未启用 thinking,此分支只为不丢端点侧自行产出的思考。
|
||||
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();
|
||||
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 回传
|
||||
// anthropic 历史(丢弃 signature 后伪造回传必 400),签名无处消费,安静忽略。
|
||||
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") {
|
||||
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") {
|
||||
@@ -217,10 +220,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: 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
|
||||
"content_block_start" => {
|
||||
@@ -253,10 +257,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: None,
|
||||
error: 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
|
||||
"message_stop" => StreamChunk {
|
||||
@@ -266,15 +271,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
reasoning_content: None,
|
||||
truncated_by_max_tokens: false,
|
||||
},
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -528,12 +528,16 @@ 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);
|
||||
if full_rewrite_applied {
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
|
||||
pub fn needs_full_rewrite(&self) -> bool {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -274,6 +274,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
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<TokenUsage>)
|
||||
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<TokenUsage>)
|
||||
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<TokenUsage>)
|
||||
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<TokenUsage>)
|
||||
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<TokenUsage>)
|
||||
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 不置截断标志");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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]
|
||||
fn status_classification() {
|
||||
assert!(is_status_retryable(429));
|
||||
|
||||
@@ -163,6 +163,7 @@ mod tests {
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
weight: 50,
|
||||
context_window: 8192,
|
||||
temperature_supported: true,
|
||||
probe_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ pub(crate) enum StreamResult {
|
||||
usage: df_ai::provider::TokenUsage,
|
||||
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
|
||||
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
|
||||
/// 但流尽未收到 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<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 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+50
-6
@@ -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<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 移除托盘图标。
|
||||
/// 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()
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/>
|
||||
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
|
||||
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
|
||||
<!-- B1: 当前模型不看图时贴图警示(发送仍允许,后端温和降级丢图;chip 持续可见防误判网络问题) -->
|
||||
<div v-if="showImageIgnoreWarn" class="ai-img-warn-chip">{{ t('aiChat.imageIgnoredWarn') }}</div>
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="inputText"
|
||||
@@ -304,6 +306,22 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
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 skillIndex = ref(0)
|
||||
@@ -849,6 +867,12 @@ async function handleSend() {
|
||||
const skill = pendingSkill.value
|
||||
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
|
||||
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...])。
|
||||
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
|
||||
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
|
||||
@@ -1181,4 +1205,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||
|
||||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||||
.ai-img-warn-chip {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-warning);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
<section class="panel" v-if="connForm.visible">
|
||||
<div class="panel-header">
|
||||
<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 class="form-grid">
|
||||
<div class="form-field">
|
||||
@@ -77,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
|
||||
@@ -120,6 +121,8 @@ const connForm = reactive({
|
||||
port: DEFAULT_PORTS.mysql,
|
||||
user: '',
|
||||
password: '',
|
||||
// L19 对齐: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isConnDirty 比对(同 ProviderPanel 范式)
|
||||
savedSnapshot: '' as string,
|
||||
})
|
||||
|
||||
// 异步操作禁用态(防双击重复提交):saveConn 保存连接期间禁用按钮
|
||||
@@ -146,6 +149,34 @@ function typeLabel(type: string) {
|
||||
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) {
|
||||
if (conn) {
|
||||
connForm.editId = conn.id
|
||||
@@ -165,6 +196,8 @@ function openConnForm(conn?: ConnRecord) {
|
||||
connForm.password = ''
|
||||
}
|
||||
connForm.visible = true
|
||||
// 记初始快照(visible=true、字段回填完成后),isConnDirty 据此比对
|
||||
connForm.savedSnapshot = connBusinessSnapshot()
|
||||
}
|
||||
|
||||
/** 用户手动切换连接类型时,端口自动重置为该类型默认端口。
|
||||
@@ -202,7 +235,10 @@ function saveConn() {
|
||||
connections.value.push(record)
|
||||
}
|
||||
persistConnections()
|
||||
// 成功明确反馈:关表单 + 清快照(刚落库无脏,直接关不走二次确认)+ 绿色「已保存」toast(对齐 ProviderPanel)
|
||||
connForm.visible = false
|
||||
connForm.savedSnapshot = ''
|
||||
emit('toast', t('settings.toastSaved'), 'success')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -521,6 +521,12 @@ async function fetchModels() {
|
||||
apiKey: providerForm.apiKey,
|
||||
providerId: providerForm.editId || null,
|
||||
})
|
||||
// 空列表守卫:拉到 0 个模型时保留现有模型表格(不覆盖),仅提示——
|
||||
// 与后端 ai_fetch_models 空守卫同语义(空结果不清用户配置),防误清后顺手保存落库
|
||||
if (models.length === 0) {
|
||||
emit('toast', t('settings.toastFetchEmpty'), 'warning')
|
||||
return
|
||||
}
|
||||
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
|
||||
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
|
||||
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
|
||||
@@ -599,6 +605,20 @@ async function saveProvider() {
|
||||
} else {
|
||||
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
|
||||
try {
|
||||
await aiApi.saveProvider({
|
||||
@@ -672,7 +692,9 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
|
||||
const prev = p.enabled
|
||||
p.enabled = enabled
|
||||
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')
|
||||
} catch (e) {
|
||||
p.enabled = prev // revert
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
// 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)',
|
||||
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)
|
||||
toastAnthropicCacheSaved: 'Prompt caching setting saved',
|
||||
toastAnthropicCacheFail: 'Failed to save the prompt caching setting',
|
||||
@@ -88,6 +89,7 @@ export default {
|
||||
saveModels: 'Save model config',
|
||||
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
|
||||
toastFetchOk: 'Fetched {count} models',
|
||||
toastFetchEmpty: 'Fetched 0 models — existing config kept',
|
||||
toastFetchFail: 'Fetch failed: {msg}',
|
||||
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
|
||||
fetchFailedAuth: 'Authentication failed, check your API Key: {msg}',
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
// 走查 P3-14 越界钳制提示(并发 1-64 + 单对话不超全局;采样温度单独提示修正值)
|
||||
toastConcurrencyClamped: '输入超出有效范围,已自动修正(并发 1-{max},单对话不超过全局)',
|
||||
toastTempClamped: '采样温度须在 0-2 范围内,已修正为 {v}',
|
||||
toastWeightClamped: '模型权重须在 0-100 范围内,越界值已修正(清空视为 50)',
|
||||
// 走查 P3-11:Prompt 缓存开关保存反馈(原切换零反馈)
|
||||
toastAnthropicCacheSaved: 'Prompt 缓存设置已保存',
|
||||
toastAnthropicCacheFail: 'Prompt 缓存设置保存失败',
|
||||
@@ -87,6 +88,7 @@ export default {
|
||||
saveModels: '保存模型配置',
|
||||
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
|
||||
toastFetchOk: '已拉取 {count} 个模型',
|
||||
toastFetchEmpty: '拉取到 0 个模型,已保留现有配置',
|
||||
toastFetchFail: '拉取模型失败:{msg}',
|
||||
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
|
||||
fetchFailedAuth: '鉴权失败,请检查 API Key:{msg}',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
|
||||
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
|
||||
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
||||
//! - 落库失败保留 pending 脏值待下次写入重试,经 lastWriteError 透出(见 set/flush)
|
||||
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
||||
//!
|
||||
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
|
||||
@@ -24,6 +25,11 @@ const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
|
||||
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 调用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
const all = await settingsApi.getAll()
|
||||
@@ -41,6 +47,9 @@ function get<T>(key: string, defaultValue: T): T {
|
||||
/**
|
||||
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
|
||||
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
|
||||
*
|
||||
* promise 契约:resolve 仅代表"已调度落库",不代表已落库;本 promise 永不
|
||||
* reject(调用方 void/await 均安全),落库成败经 lastWriteError 透出。
|
||||
*/
|
||||
async function set(key: string, value: unknown): Promise<void> {
|
||||
cache[key] = value
|
||||
@@ -51,18 +60,35 @@ async function set(key: string, value: unknown): Promise<void> {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(key)
|
||||
const latest = pendingValues.get(key)
|
||||
pendingValues.delete(key)
|
||||
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
|
||||
settingsApi
|
||||
.set(key, JSON.stringify(latest))
|
||||
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
|
||||
void flush(key)
|
||||
}, SET_DEBOUNCE_MS)
|
||||
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> {
|
||||
const prevCache = cache[key]
|
||||
const prevPending = pendingValues.get(key)
|
||||
delete cache[key]
|
||||
pendingValues.delete(key)
|
||||
const t = pendingTimers.get(key)
|
||||
@@ -70,7 +96,15 @@ async function remove(key: string): Promise<void> {
|
||||
clearTimeout(t)
|
||||
pendingTimers.delete(key)
|
||||
}
|
||||
try {
|
||||
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() {
|
||||
return {
|
||||
cache,
|
||||
lastWriteError,
|
||||
loadAll,
|
||||
get,
|
||||
set,
|
||||
|
||||
Reference in New Issue
Block a user