Merge branch 'main' into feat/gemini-proxy-integration

Resolve conflict in claudeProviderPresets.ts: keep both Gemini Native
and Shengsuanyun presets.
This commit is contained in:
YoVinchen
2026-04-11 15:50:48 +08:00
52 changed files with 4557 additions and 334 deletions
+1
View File
@@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[cfg(target_os = "macos")]
+1
View File
@@ -948,6 +948,7 @@ exec bash --norc --noprofile
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
+37 -28
View File
@@ -1072,8 +1072,15 @@ impl Database {
/// v6 -> v7: Skills 更新检测支持(content_hash + updated_at
fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
Self::add_column_if_missing(conn, "skills", "updated_at", "INTEGER NOT NULL DEFAULT 0")?;
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
Self::add_column_if_missing(
conn,
"skills",
"updated_at",
"INTEGER NOT NULL DEFAULT 0",
)?;
}
log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
Ok(())
}
@@ -1103,32 +1110,34 @@ impl Database {
.map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
// 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD
let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
("deepseek-v3", "0.28", "1.11", "0.028", "0"),
("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
("minimax-m2", "0.27", "0.95", "0.03", "0"),
("glm-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "0.28", "1.11", "0.03", "0"),
("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
];
for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
conn.execute(
"UPDATE model_pricing SET
input_cost_per_million = ?2,
output_cost_per_million = ?3,
cache_read_cost_per_million = ?4,
cache_creation_cost_per_million = ?5
WHERE model_id = ?1",
rusqlite::params![model_id, input, output, cache_read, cache_creation],
)
.map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
if Self::table_exists(conn, "model_pricing")? {
let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
("deepseek-v3", "0.28", "1.11", "0.028", "0"),
("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
("minimax-m2", "0.27", "0.95", "0.03", "0"),
("glm-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "0.28", "1.11", "0.03", "0"),
("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
];
for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
conn.execute(
"UPDATE model_pricing SET
input_cost_per_million = ?2,
output_cost_per_million = ?3,
cache_read_cost_per_million = ?4,
cache_creation_cost_per_million = ?5
WHERE model_id = ?1",
rusqlite::params![model_id, input, output, cache_read, cache_creation],
)
.map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
}
}
log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
+5 -5
View File
@@ -35,7 +35,7 @@ pub fn supports_reasoning_effort(model: &str) -> bool {
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
/// (supported by mainstream GPT models). Unknown values are ignored.
/// 2. Fallback: `thinking.type` + `budget_tokens`:
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
/// - `adaptive` → `xhigh` (adaptive = maximum reasoning effort)
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 00015 999) / `high` (≥16 000)
/// - `enabled` without budget → `high` (conservative default)
/// - `disabled` / absent → `None`
@@ -57,7 +57,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
// --- Priority 2: thinking.type + budget_tokens fallback ---
let thinking = body.get("thinking")?;
match thinking.get("type").and_then(|t| t.as_str()) {
Some("adaptive") => Some("high"),
Some("adaptive") => Some("xhigh"),
Some("enabled") => {
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
match budget {
@@ -1019,9 +1019,9 @@ mod tests {
}
#[test]
fn test_thinking_adaptive_maps_high() {
fn test_thinking_adaptive_maps_xhigh() {
let body = json!({"thinking": {"type": "adaptive"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
}
#[test]
@@ -1100,7 +1100,7 @@ mod tests {
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "high");
assert_eq!(result["reasoning_effort"], "xhigh");
}
#[test]
@@ -1047,7 +1047,7 @@ mod tests {
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_high() {
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
@@ -1056,7 +1056,7 @@ mod tests {
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
@@ -12,6 +12,7 @@ use super::utils::{
};
const PROVIDER_ID: &str = "claude";
const TITLE_MAX_CHARS: usize = 80;
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_claude_config_dir().join("projects");
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut first_user_message: Option<String> = None;
// Extract metadata from head lines
// Extract metadata and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -151,11 +153,41 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
// Extract first real user message as title candidate
// Skip system-injected caveats and slash commands (e.g. /clear, /compact)
if first_user_message.is_none() {
let is_user = value.get("type").and_then(Value::as_str) == Some("user")
|| value
.get("message")
.and_then(|m| m.get("role"))
.and_then(Value::as_str)
== Some("user");
if is_user {
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
let trimmed = text.trim();
if !trimmed.is_empty()
&& !trimmed.contains("<local-command-caveat>")
&& !trimmed.starts_with("<command-name>")
{
first_user_message = Some(trimmed.to_string());
}
}
}
}
if session_id.is_some()
&& project_dir.is_some()
&& created_at.is_some()
&& first_user_message.is_some()
{
break;
}
}
// Extract last_active_at and summary from tail lines (reverse order)
// Extract last_active_at, summary, and custom-title from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
let mut custom_title: Option<String> = None;
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
@@ -165,6 +197,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
// Look for custom-title entry (take the last one, i.e. first in reverse)
if custom_title.is_none()
&& value.get("type").and_then(Value::as_str) == Some("custom-title")
{
custom_title = value
.get("customTitle")
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
if summary.is_none() {
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
@@ -176,7 +218,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
}
}
}
if last_active_at.is_some() && summary.is_some() {
if last_active_at.is_some() && summary.is_some() && custom_title.is_some() {
break;
}
}
@@ -184,10 +226,16 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = project_dir
.as_deref()
.and_then(path_basename)
.map(|value| value.to_string());
// Title priority: custom-title > first user message > directory basename
let title = custom_title
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
.or_else(|| {
project_dir
.as_deref()
.and_then(path_basename)
.map(|v| v.to_string())
});
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -336,4 +384,117 @@ mod tests {
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].content.contains("Please continue"));
}
#[test]
fn parse_session_uses_first_user_message_as_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-abc.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"sessionId\":\"session-abc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
}
#[test]
fn parse_session_custom_title_overrides_first_message() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-def.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-def\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"sessionId\":\"session-def\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n",
"{\"type\":\"custom-title\",\"customTitle\":\"fix-login-bug\",\"sessionId\":\"session-def\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("fix-login-bug"));
}
#[test]
fn parse_session_falls_back_to_dir_basename() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-ghi.jsonl");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-ghi\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
// No user message and no custom-title → falls back to dir basename
assert_eq!(meta.title.as_deref(), Some("my-project"));
}
#[test]
fn parse_session_truncates_long_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-trunc.jsonl");
let long_msg = "a".repeat(200);
std::fs::write(
&path,
format!(
"{{\"sessionId\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"sessionId\":\"session-trunc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
let title = meta.title.unwrap();
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
assert!(title.ends_with("..."));
}
#[test]
fn parse_session_new_format_with_snapshot() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-new.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"请帮我重构这个函数\"},\"sessionId\":\"session-new\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"OK\"},\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("请帮我重构这个函数"));
}
#[test]
fn parse_session_skips_command_caveat_and_slash_commands() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-clear.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<local-command-caveat>Caveat: The messages below were generated by the user while running local commands.</local-command-caveat>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<command-name>/clear</command-name>\\n<command-message>clear</command-message>\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:01Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:00:02Z\",\"cwd\":\"/tmp/project\"}\n",
"{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"帮我看看工作区的改动\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("帮我看看工作区的改动"));
}
}
+77 -17
View File
@@ -20,6 +20,7 @@ pub fn launch_terminal(
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
@@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let full_command = build_shell_command(command, None);
let mut args = vec!["-na", "WezTerm", "--args", "start"];
if let Some(dir) = cwd {
args.push("--cwd");
args.push(dir);
}
// Invoke shell to run the command string (to handle pipes, etc)
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
args.push("--");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
let status = Command::new("open")
.args(&args)
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
@@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
}
}
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
let args = build_wezterm_compatible_args("Kaku", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kaku.".to_string())
}
}
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
}
fn build_wezterm_compatible_args_with_shell(
app_name: &str,
command: &str,
cwd: Option<&str>,
shell: &str,
) -> Vec<String> {
let full_command = build_shell_command(command, None);
let mut args = vec![
"-na".to_string(),
app_name.to_string(),
"--args".to_string(),
"start".to_string(),
];
if let Some(dir) = cwd {
args.push("--cwd".to_string());
args.push(dir.to_string());
}
// Invoke shell to run the command string (to handle pipes, etc)
args.push("--".to_string());
args.push(shell.to_string());
args.push("-c".to_string());
args.push(full_command);
args
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
@@ -305,4 +339,30 @@ mod tests {
"raw:echo foo\\\\\\\\bar\\npwd\\n"
);
}
#[test]
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
let args = build_wezterm_compatible_args_with_shell(
"Kaku",
"claude --resume abc-123",
Some("/tmp/project dir"),
"/bin/zsh",
);
assert_eq!(
args,
vec![
"-na".to_string(),
"Kaku".to_string(),
"--args".to_string(),
"start".to_string(),
"--cwd".to_string(),
"/tmp/project dir".to_string(),
"--".to_string(),
"/bin/zsh".to_string(),
"-c".to_string(),
"claude --resume abc-123".to_string(),
]
);
}
}
+1 -1
View File
@@ -273,7 +273,7 @@ pub struct AppSettings {
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]