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

- 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
+1 -1
View File
@@ -51,7 +51,7 @@
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, State};
use crate::state::AppState;
+11
View File
@@ -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,
}
}
+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"];
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
};
+2 -1
View File
@@ -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
View File
@@ -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()
};