From 5ac6fb5315e74f689fd881e84f208efeba07d9da Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 23:18:06 +0800 Subject: [PATCH 01/28] feat(session-manager): extract meaningful titles for Claude sessions Instead of showing directory basenames for all Claude sessions, extract titles from JSONL content with a priority chain: 1. custom-title metadata (set via /rename in Claude Code) 2. First real user message (skipping /clear, /compact caveats) 3. Directory basename (fallback) --- .../src/session_manager/providers/claude.rs | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 865b9efa9..226613d13 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -12,6 +12,7 @@ use super::utils::{ }; const PROVIDER_ID: &str = "claude"; +const TITLE_MAX_CHARS: usize = 80; pub fn scan_sessions() -> Vec { let root = get_claude_config_dir().join("projects"); @@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option { let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; + let mut first_user_message: Option = 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 { 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("") + && !trimmed.starts_with("") + { + 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 = None; let mut summary: Option = None; + let mut custom_title: Option = 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 { 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 { } } } - 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 { 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\":\"Caveat: The messages below were generated by the user while running local commands.\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"/clear\\nclear\"},\"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("帮我看看工作区的改动")); + } } From 308314baacfff769f90d12ff4d8281bf75ae2b1f Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 00:14:50 +0800 Subject: [PATCH 02/28] fix(session-manager): improve session search accuracy and Chinese support - Pre-filter sessions by provider before indexing to prevent result truncation when FlexSearch limit cuts across providers - Switch tokenizer from "forward" to "full" for Chinese substring matching - Preserve FlexSearch relevance ranking when search query is present --- src/hooks/useSessionSearch.ts | 49 ++++++++++++----------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts index 2f1b8c920..5d9710233 100644 --- a/src/hooks/useSessionSearch.ts +++ b/src/hooks/useSessionSearch.ts @@ -19,14 +19,18 @@ export function useSessionSearch({ sessions, providerFilter, }: UseSessionSearchOptions): UseSessionSearchResult { + const filteredByProvider = useMemo(() => { + if (providerFilter === "all") return sessions; + return sessions.filter((s) => s.providerId === providerFilter); + }, [sessions, providerFilter]); + const index = useMemo(() => { - // 使用 forward tokenizer 支持中文前缀搜索 const nextIndex = new FlexSearch.Index({ - tokenize: "forward", + tokenize: "full", resolution: 9, }); - sessions.forEach((session, idx) => { + filteredByProvider.forEach((session, idx) => { const metaContent = [ session.sessionId, session.title, @@ -41,49 +45,28 @@ export function useSessionSearch({ }); return nextIndex; - }, [sessions]); + }, [filteredByProvider]); - // 搜索函数 const search = useCallback( (query: string): SessionMeta[] => { - const needle = query.trim().toLowerCase(); + const needle = query.trim(); - // 先按 provider 过滤 - let filtered = sessions; - if (providerFilter !== "all") { - filtered = sessions.filter((s) => s.providerId === providerFilter); - } - - // 如果没有搜索词,返回按时间排序的结果 if (!needle) { - return [...filtered].sort((a, b) => { + return [...filteredByProvider].sort((a, b) => { const aTs = a.lastActiveAt ?? a.createdAt ?? 0; const bTs = b.lastActiveAt ?? b.createdAt ?? 0; return bTs - aTs; }); } - // 使用 FlexSearch 搜索 - const results = index.search(needle, { limit: 100 }) as number[]; + const results = index.search(needle, { + limit: filteredByProvider.length, + }) as number[]; - // 转换为 session 并过滤 - const matchedSessions = results - .map((idx) => sessions[idx]) - .filter( - (session) => - session && - (providerFilter === "all" || session.providerId === providerFilter), - ); - - // 按时间排序 - return matchedSessions.sort((a, b) => { - const aTs = a.lastActiveAt ?? a.createdAt ?? 0; - const bTs = b.lastActiveAt ?? b.createdAt ?? 0; - return bTs - aTs; - }); + return results.map((idx) => filteredByProvider[idx]); }, - [index, providerFilter, sessions], + [index, filteredByProvider], ); - return useMemo(() => ({ search }), [search]); + return { search }; } From f9fb9085acb3d1f6df6213246aa3a43200609251 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:30:37 +0800 Subject: [PATCH 03/28] feat(session-manager): highlight search keywords in session titles and messages --- src/components/sessions/SessionItem.tsx | 7 +++++- .../sessions/SessionManagerPage.tsx | 2 ++ .../sessions/SessionMessageItem.tsx | 13 +++++++++-- src/components/sessions/utils.ts | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index dc2841c33..1cacc1405 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -15,6 +15,7 @@ import { getProviderIconName, getProviderLabel, getSessionKey, + highlightText, } from "./utils"; interface SessionItemProps { @@ -23,6 +24,7 @@ interface SessionItemProps { selectionMode: boolean; isChecked: boolean; isCheckDisabled?: boolean; + searchQuery?: string; onSelect: (key: string) => void; onToggleChecked: (checked: boolean) => void; } @@ -33,6 +35,7 @@ export function SessionItem({ selectionMode, isChecked, isCheckDisabled = false, + searchQuery, onSelect, onToggleChecked, }: SessionItemProps) { @@ -82,7 +85,9 @@ export function SessionItem({ {getProviderLabel(session.providerId, t)} - {title} + + {searchQuery ? highlightText(title, searchQuery) : title} + { if (el) messageRefs.current.set(index, el); }} diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx index 2293241bf..1dd4d2b62 100644 --- a/src/components/sessions/SessionMessageItem.tsx +++ b/src/components/sessions/SessionMessageItem.tsx @@ -9,12 +9,18 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { SessionMessage } from "@/types"; -import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils"; +import { + formatTimestamp, + getRoleLabel, + getRoleTone, + highlightText, +} from "./utils"; interface SessionMessageItemProps { message: SessionMessage; index: number; isActive: boolean; + searchQuery?: string; setRef: (el: HTMLDivElement | null) => void; onCopy: (content: string) => void; } @@ -22,6 +28,7 @@ interface SessionMessageItemProps { export function SessionMessageItem({ message, isActive, + searchQuery, setRef, onCopy, }: SessionMessageItemProps) { @@ -68,7 +75,9 @@ export function SessionMessageItem({ )}
- {message.content} + {searchQuery + ? highlightText(message.content, searchQuery) + : message.content}
); diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 633d7136d..1c2c677a9 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; +import { createElement } from "react"; import { SessionMeta } from "@/types"; export const getSessionKey = (session: SessionMeta) => @@ -78,3 +80,23 @@ export const formatSessionTitle = (session: SessionMeta) => { session.sessionId.slice(0, 8) ); }; + +export const highlightText = (text: string, query: string): ReactNode => { + if (!query) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + if (parts.length === 1) return text; + return parts.map((part, i) => + i % 2 === 1 + ? createElement( + "mark", + { + key: i, + className: + "bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5", + }, + part, + ) + : part, + ); +}; From cc1530f997f19649abc076a9bfcaf5668e8716e6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:33:13 +0800 Subject: [PATCH 04/28] docs: add working directory feature implementation plan Design document for per-directory state switching (providers, MCP, skills, prompts). Not yet implemented - saved for future reference. --- docs/working-directory-plan.md | 597 +++++++++++++++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/working-directory-plan.md diff --git a/docs/working-directory-plan.md b/docs/working-directory-plan.md new file mode 100644 index 000000000..ae2426146 --- /dev/null +++ b/docs/working-directory-plan.md @@ -0,0 +1,597 @@ +# CC-Switch "工作目录" 功能 — 实施方案 + +## Context + +CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。 + +本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。 + +--- + +## 一、需要按目录区分的实体(完整清单) + +| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 | +|------|-------------|---------|-----------|------| +| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 | +| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 | +| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 | +| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills | +| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 | +| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 | +| Settings | key-value | flat table | NO | 全局用户偏好 | +| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 | +| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 | +| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 | + +> 原计划遗漏了 **Failover Queue** 和 **Provider Health 清除**。 + +--- + +## 二、数据库变更(Schema v8 → v9) + +### 新增 5 张表 + +```sql +-- 1. 工作目录注册表 +CREATE TABLE IF NOT EXISTS working_directories ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + name TEXT, + is_current BOOLEAN NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0 +); + +-- 2. Provider 状态快照 (is_current + in_failover_queue) +-- 每个目录保存所有 provider 的两个状态标志 +CREATE TABLE IF NOT EXISTS dir_provider_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + is_current BOOLEAN NOT NULL DEFAULT 0, + in_failover_queue BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, app_type, provider_id) +); + +-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开) +CREATE TABLE IF NOT EXISTS dir_mcp_state ( + dir_id TEXT NOT NULL, + mcp_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, mcp_id) +); + +-- 4. Skill 启用状态快照 (直接镜像 4 列) +CREATE TABLE IF NOT EXISTS dir_skill_state ( + dir_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, skill_id) +); + +-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id) +CREATE TABLE IF NOT EXISTS dir_prompt_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + prompt_id TEXT NOT NULL, + PRIMARY KEY (dir_id, app_type) +); +``` + +### 设计决策说明 + +**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**: +- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列 +- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行) +- 未来增加新 app 时,两边同步加列即可 + +**Prompt 只存 `(dir_id, app_type, prompt_id)`**: +- 每个 app_type 最多一个 enabled prompt,不需要存 boolean +- 无记录 = 该 app 无激活 prompt + +**Provider 合并 `is_current` + `in_failover_queue`**: +- 两个标志都是 per `(app_type, provider_id)` 的状态 +- 存在同一表中避免多表 JOIN + +### 迁移脚本 + +在 `schema.rs` 中: +- `create_tables_on_conn()` 添加 5 个 CREATE TABLE +- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__` 行 +- `SCHEMA_VERSION` 升至 9 +- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }` + +```rust +fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> { + // 创建 5 张表(使用 IF NOT EXISTS,幂等) + // ... + // 插入 __default__ 虚拟目录,代表"全局默认"状态 + conn.execute( + "INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \ + VALUES ('__default__', '__default__', NULL, 0, ?1)", + [crate::database::get_unix_timestamp()?], + )?; + Ok(()) +} +``` + +--- + +## 三、后端实现 + +### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs` + +所有方法都是 `impl Database` 块,遵循现有 DAO 模式。 + +**关键方法签名**(需要 `_on_conn` 变体支持事务): + +```rust +// ═══ 工作目录 CRUD ═══ +pub fn list_working_directories(&self) -> Result, AppError> +pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError> +pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError> +pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError> +pub fn get_current_working_directory(&self) -> Result, AppError> + +// 使用 _on_conn 变体,在 Service 层的事务中调用 +fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError> + +// ═══ 快照写入 ═══ (都有 _on_conn 变体) +fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> + +// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照) +fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +``` + +**snapshot_providers 实现思路**: +```sql +-- 先清除旧快照 +DELETE FROM dir_provider_state WHERE dir_id = ?1; +-- 从主表复制当前状态 +INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue) +SELECT ?1, app_type, id, is_current, in_failover_queue +FROM providers +WHERE is_current = 1 OR in_failover_queue = 1; +``` + +**apply_provider_snapshot 实现思路**: +```sql +-- 检查是否有快照 +SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false + +-- 在事务中:先清除主表所有 is_current 和 in_failover_queue +UPDATE providers SET is_current = 0; +UPDATE providers SET in_failover_queue = 0; + +-- 从快照恢复 +UPDATE providers SET is_current = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1); + +UPDATE providers SET in_failover_queue = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1); +``` + +**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列): +```sql +DELETE FROM dir_mcp_state WHERE dir_id = ?1; +INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode) +SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode +FROM mcp_servers; +``` + +**apply_mcp_snapshot 实现思路**: +```sql +-- 先全部禁用 +UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0; + +-- 从快照恢复 +UPDATE mcp_servers SET + enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id) +WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1); +``` + +### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs` + +```rust +use crate::store::AppState; +use crate::error::AppError; +use crate::database::lock_conn; +use crate::app_config::AppType; +use crate::services::{McpService, ProviderService, SkillService}; +use crate::config::write_text_file; +use crate::prompt_files::prompt_file_path; + +pub struct WorkingDirService; + +impl WorkingDirService { + /// 核心切换逻辑 + pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> { + // ═══ 前置检查 ═══ + // 1. 检查代理接管状态,若活跃则拒绝切换 + // 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active + // (因为 ProxyService::is_running() 是 async,而此函数是 sync) + Self::check_proxy_not_active(state)?; + + // ═══ Phase 1: 回填 Prompt ═══ + // 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt + // 这样即使用户手动编辑了 live 文件,内容也不会丢失 + Self::backfill_prompt_content(state)?; + + // ═══ Phase 2: 数据库操作(事务) ═══ + { + let conn = lock_conn!(state.db.conn); + conn.execute("BEGIN IMMEDIATE", [])?; + + let result = (|| -> Result<(), AppError> { + // 获取当前工作目录 + let current = Self::get_current_dir_id_on_conn(&conn)?; + + // 保存当前状态到旧目录 + if let Some(old_id) = ¤t { + Database::snapshot_providers_on_conn(&conn, old_id)?; + Database::snapshot_mcp_on_conn(&conn, old_id)?; + Database::snapshot_skills_on_conn(&conn, old_id)?; + Database::snapshot_prompts_on_conn(&conn, old_id)?; + } else { + // 无当前目录 = 全局模式,保存到 __default__ + Database::snapshot_providers_on_conn(&conn, "__default__")?; + Database::snapshot_mcp_on_conn(&conn, "__default__")?; + Database::snapshot_skills_on_conn(&conn, "__default__")?; + Database::snapshot_prompts_on_conn(&conn, "__default__")?; + } + + // 加载目标目录快照(如果有的话) + // 如果无快照(首次进入),保持主表不变 + Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?; + + // 更新 is_current 标记 + Database::set_current_working_directory_on_conn(&conn, target_dir_id)?; + + Ok(()) + })(); + + match result { + Ok(()) => conn.execute("COMMIT", [])?, + Err(e) => { + let _ = conn.execute("ROLLBACK", []); + return Err(e); + } + }; + } + // conn 锁在此处释放 + + // ═══ Phase 3: 同步 live 配置文件 ═══ + Self::sync_all_live(state)?; + + // ═══ Phase 4: 清除 Provider Health ═══ + state.db.clear_all_provider_health()?; + + Ok(()) + } + + /// 回填 live prompt 文件内容到 DB(切换前调用) + fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + if !path.exists() { continue; } + let live_content = std::fs::read_to_string(&path).unwrap_or_default(); + if live_content.trim().is_empty() { continue; } + + let mut prompts = state.db.get_prompts(app.as_str())?; + if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) { + prompt.content = live_content; + prompt.updated_at = Some(get_unix_timestamp()?); + state.db.save_prompt(app.as_str(), prompt)?; + } + } + Ok(()) + } + + /// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用) + /// 注意:不做回填!只写入。区别于 PromptService::enable_prompt() + fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + let prompts = state.db.get_prompts(app.as_str())?; + if let Some(prompt) = prompts.values().find(|p| p.enabled) { + write_text_file(&path, &prompt.content)?; + } + // 无 enabled prompt 时不清空文件(保留现状) + } + Ok(()) + } + + /// 同步所有 live 配置(Provider + MCP + Skill + Prompt) + fn sync_all_live(state: &AppState) -> Result<(), AppError> { + // 1. Provider → live files + ProviderService::sync_current_to_live(state)?; + // sync_current_to_live 内部已调用 McpService::sync_all_enabled() + + // 2. Skills → app dirs (循环每个 app) + for app in AppType::all() { + let _ = SkillService::sync_to_app(&state.db, &app); + } + + // 3. Prompts → live files + Self::write_prompts_to_live(state)?; + + Ok(()) + } + + /// 检查代理是否活跃(同步检查数据库标志) + fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> { + // 检查 proxy_config 表中 live_takeover_active 列 + // 如果有任何 app 的 live_takeover_active = 1,拒绝切换 + let conn = lock_conn!(state.db.conn); + let active: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)", + [], |r| r.get(0) + ).unwrap_or(false); + + if active { + return Err(AppError::Message( + "代理接管模式运行中,请先停止代理再切换工作目录".into() + )); + } + Ok(()) + } +} +``` + +### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs` + +遵循现有模式:`State<'_, AppState>` + `Result` + `.map_err(|e| e.to_string())`。 + +```rust +#[tauri::command] +pub fn list_working_directories(state: State<'_, AppState>) -> Result, String> + +#[tauri::command] +pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option) -> Result + +#[tauri::command] +pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> + +#[tauri::command] +pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String> + +#[tauri::command] +pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> +// 调用 WorkingDirService::switch() + +#[tauri::command] +pub fn get_current_working_directory(state: State<'_, AppState>) -> Result, String> +``` + +### 3.4 需修改的现有文件 + +| 文件 | 修改内容 | +|------|---------| +| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` | +| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao | +| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` | +| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` | +| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` | +| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 | + +### 3.5 可能需要新增的 DAO 辅助方法 + +`src-tauri/src/database/dao/failover.rs`: +```rust +/// 清除所有 provider_health 记录(切换目录时调用) +pub fn clear_all_provider_health(&self) -> Result<(), AppError> +``` + +--- + +## 四、前端实现 + +### 4.1 API — `src/lib/api/workingDir.ts` + +```typescript +import { invoke } from "@tauri-apps/api/core"; + +export interface WorkingDirectory { + id: string; + path: string; + name?: string; + isCurrent: boolean; + createdAt: number; +} + +export const workingDirApi = { + list: () => invoke("list_working_directories"), + add: (path: string, name?: string) => + invoke("add_working_directory", { path, name }), + delete: (id: string) => invoke("delete_working_directory", { id }), + rename: (id: string, name: string) => + invoke("rename_working_directory", { id, name }), + switch: (id: string) => invoke("switch_working_directory", { id }), + getCurrent: () => + invoke("get_current_working_directory"), +}; +``` + +### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx` + +**位置**:Header toolbar,靠近 AppSwitcher。 + +**功能**: +- 下拉菜单显示已注册目录列表 +- 当前目录高亮 +- "浏览…" 按钮调用 Tauri 文件夹选择对话框 +- 右键菜单:重命名、删除 +- "__default__(全局)" 选项恢复到全局状态 +- 切换后 invalidate 所有相关 React Query + +**切换后的 Query Invalidation**: +```typescript +// 需要验证实际的 queryKey 名称 +queryClient.invalidateQueries({ queryKey: ["providers"] }); +queryClient.invalidateQueries({ queryKey: ["mcp-servers"] }); +queryClient.invalidateQueries({ queryKey: ["installed-skills"] }); +queryClient.invalidateQueries({ queryKey: ["prompts"] }); +queryClient.invalidateQueries({ queryKey: ["workingDirectories"] }); +``` + +### 4.3 i18n + +三个文件都需更新: +- `src/i18n/locales/zh.json` +- `src/i18n/locales/en.json` +- `src/i18n/locales/ja.json` + +--- + +## 五、切换流程时序 + +``` +用户选择目录 B + │ + ├── 1. check_proxy_not_active() + │ → 如果代理接管中,返回错误,终止 + │ + ├── 2. backfill_prompt_content() + │ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content + │ → 保护用户手动编辑的 prompt 不丢失 + │ + ├── 3. BEGIN TRANSACTION + │ ├── snapshot(old_dir / __default__) + │ │ ├── providers → dir_provider_state (is_current + in_failover_queue) + │ │ ├── mcp_servers → dir_mcp_state (4 列直接复制) + │ │ ├── skills → dir_skill_state (4 列直接复制) + │ │ └── prompts → dir_prompt_state (enabled prompt_id) + │ │ + │ ├── apply(target_dir) + │ │ ├── dir_provider_state → providers + │ │ ├── dir_mcp_state → mcp_servers + │ │ ├── dir_skill_state → skills + │ │ └── dir_prompt_state → prompts + │ │ + │ └── set_current_working_directory(target_dir) + │ + ├── COMMIT + │ + ├── 4. sync_all_live() + │ ├── ProviderService::sync_current_to_live(state) + │ │ └── 内部已调用 McpService::sync_all_enabled() + │ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) } + │ └── write_prompts_to_live() ← 无回填,直接写 + │ + └── 5. clear_all_provider_health() + → 清除运行时熔断器状态 +``` + +--- + +## 六、边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 | +| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 | +| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 | +| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 | +| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 | +| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 | +| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 | +| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 | +| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 | + +--- + +## 七、实施顺序 + +### Phase 1: 数据库 +1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()` +2. `database/mod.rs` — `SCHEMA_VERSION = 9` + 迁移分支 +3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体) +4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()` +5. `database/dao/mod.rs` — 注册模块 + +### Phase 2: 服务 + 命令 +6. `services/working_dir.rs` — `WorkingDirService::switch()` 等 +7. `commands/working_dir.rs` — 6 个 Tauri 命令 +8. `services/mod.rs` — 注册模块 +9. `commands/mod.rs` — 注册模块 +10. `lib.rs` — invoke_handler 注册 + +### Phase 3: 前端 +11. `src/lib/api/workingDir.ts` — API 封装 +12. `src/types.ts` — WorkingDirectory 类型 +13. `src/components/WorkingDirSwitcher.tsx` — UI 组件 +14. `src/App.tsx` — 集成到 header toolbar +15. `src/i18n/locales/{zh,en,ja}.json` — 国际化 + +### Phase 4: 优化(可选) +16. 启动恢复检查(DB 状态 vs live 文件一致性) +17. 托盘菜单显示当前工作目录 + +--- + +## 八、关键文件索引 + +### 新增文件(5 个) +- `src-tauri/src/database/dao/working_dir.rs` +- `src-tauri/src/services/working_dir.rs` +- `src-tauri/src/commands/working_dir.rs` +- `src/lib/api/workingDir.ts` +- `src/components/WorkingDirSwitcher.tsx` + +### 必须修改的文件(7 个) +- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移 +- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环 +- `src-tauri/src/database/dao/mod.rs` — 模块注册 +- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health +- `src-tauri/src/services/mod.rs` — 模块注册 +- `src-tauri/src/commands/mod.rs` — 模块注册 +- `src-tauri/src/lib.rs` — invoke_handler + +### 必须修改的前端文件(4 个) +- `src/App.tsx` — 集成 WorkingDirSwitcher +- `src/types.ts` — WorkingDirectory 接口 +- `src/i18n/locales/zh.json` — 中文 +- `src/i18n/locales/en.json` — 英文 +- `src/i18n/locales/ja.json` — 日文 + +### 参考文件(理解现有模式) +- `src-tauri/src/services/mcp.rs` — `sync_all_enabled()` (line 165) +- `src-tauri/src/services/skill.rs` — `sync_to_app()` (line 1707) +- `src-tauri/src/services/provider/mod.rs` — `sync_current_to_live()` (line 1552) +- `src-tauri/src/services/prompt.rs` — `enable_prompt()` (line 73) — 理解回填逻辑 +- `src-tauri/src/prompt_files.rs` — prompt 文件路径 +- `src-tauri/src/config.rs` — `write_text_file()` (line 176) + +--- + +## 九、验证计划 + +### 后端验证 +1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`) + - 快照/恢复往返一致性 + - 新增/删除实体后的 apply 行为 + - `__default__` 全局状态保护 + - 事务回滚测试 +2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化 + +### 前端验证 +1. `pnpm typecheck` — TypeScript 类型检查 +2. `pnpm lint` — ESLint 检查 +3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新 From c4458cf28086cf37b5b61770a3a189faab2d6afc Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:08:09 +0800 Subject: [PATCH 05/28] fix: update tests for InstalledSkill new fields and missing hook mocks - Add content_hash and updated_at fields to 4 InstalledSkill literals in skill_sync.rs - Add useCheckSkillUpdates and useUpdateSkill to UnifiedSkillsPanel test mock - Suppress unused import warning in auto_launch.rs test module --- src-tauri/src/auto_launch.rs | 1 + src-tauri/tests/skill_sync.rs | 8 ++++++++ tests/components/UnifiedSkillsPanel.test.tsx | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/src-tauri/src/auto_launch.rs b/src-tauri/src/auto_launch.rs index c69ae46ad..2f44e8971 100644 --- a/src-tauri/src/auto_launch.rs +++ b/src-tauri/src/auto_launch.rs @@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result { #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[cfg(target_os = "macos")] diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index 3d4cf6814..b1e0c3a61 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -110,6 +110,8 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { opencode: false, }, installed_at: 0, + content_hash: None, + updated_at: 0, }) .expect("save disabled skill"); @@ -154,6 +156,8 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { opencode: false, }, installed_at: 123, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -222,6 +226,8 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() { opencode: false, }, installed_at: 456, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -303,6 +309,8 @@ fn delete_skill_backup_removes_backup_directory() { opencode: false, }, installed_at: 789, + content_hash: None, + updated_at: 0, }) .expect("save skill"); diff --git a/tests/components/UnifiedSkillsPanel.test.tsx b/tests/components/UnifiedSkillsPanel.test.tsx index 9fb641c49..38518ad9b 100644 --- a/tests/components/UnifiedSkillsPanel.test.tsx +++ b/tests/components/UnifiedSkillsPanel.test.tsx @@ -64,6 +64,15 @@ vi.mock("@/hooks/useSkills", () => ({ useInstallSkillsFromZip: () => ({ mutateAsync: installFromZipMock, }), + useCheckSkillUpdates: () => ({ + data: [], + refetch: vi.fn(), + isFetching: false, + }), + useUpdateSkill: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })); describe("UnifiedSkillsPanel", () => { From b85e44994923471308c72d8d0e606ea0b220df5b Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:20:47 +0800 Subject: [PATCH 06/28] fix: guard migrations against missing tables and fix highlighted text assertion - Make migrate_v6_to_v7 check skills table existence before ALTER - Make migrate_v7_to_v8 check model_pricing table existence before UPDATE - Fix SessionManagerPage test: use getByRole heading instead of getAllByText which breaks when highlightText splits text across elements --- src-tauri/src/database/schema.rs | 67 ++++++++++++-------- tests/components/SessionManagerPage.test.tsx | 4 +- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 2a9e1ded7..95abaa8e0 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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,36 @@ 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 个模型定价"); diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index a6e99115d..3b095ffd7 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -184,7 +184,9 @@ describe("SessionManagerPage", () => { }); await waitFor(() => - expect(screen.getAllByText("Alpha Session")).toHaveLength(2), + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), ); fireEvent.click(screen.getByRole("button", { name: /删除会话/i })); From 489c7c75eab5f7402303df733120bdaa84513109 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:23:08 +0800 Subject: [PATCH 07/28] style: apply cargo fmt to schema migration code --- src-tauri/src/database/schema.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 95abaa8e0..d755116fd 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1136,9 +1136,7 @@ impl Database { WHERE model_id = ?1", rusqlite::params![model_id, input, output, cache_read, cache_creation], ) - .map_err(|e| { - AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) - })?; + .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?; } } From 68df60bd4a61833fe022c932af618f66cbc78601 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:47:18 +0800 Subject: [PATCH 08/28] feat(provider): add TheRouter presets for OpenCode and OpenClaw (#1892) Co-authored-by: max --- src/config/openclawProviderPresets.ts | 66 +++++++++++++++++++ src/config/opencodeProviderPresets.ts | 31 +++++++++ .../therouterOpenCodeOpenClawPresets.test.ts | 50 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/config/therouterOpenCodeOpenClawPresets.test.ts diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index cf9ca37c5..965c4a6a3 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -719,6 +719,72 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + baseUrl: "https://api.therouter.ai/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }, + { + id: "openai/gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextWindow: 400000, + cost: { input: 5, output: 40, cacheRead: 0.5 }, + }, + { + id: "openai/gpt-5.2", + name: "GPT-5.2", + contextWindow: 400000, + cost: { input: 1.75, output: 14, cacheRead: 0.175 }, + }, + { + id: "google/gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + contextWindow: 1000000, + cost: { input: 0.5, output: 3, cacheRead: 0.05 }, + }, + { + id: "qwen/qwen3-coder-480b", + name: "Qwen3 Coder 480B", + contextWindow: 262144, + cost: { input: 0.6, output: 2.35 }, + }, + ], + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }, + modelCatalog: { + "therouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" }, + "therouter/openai/gpt-5.2": { alias: "GPT-5.2" }, + "therouter/google/gemini-3-flash-preview": { alias: "Gemini Flash" }, + "therouter/openai/gpt-5.3-codex": { alias: "Codex" }, + "therouter/qwen/qwen3-coder-480b": { alias: "Qwen Coder" }, + }, + }, + }, { name: "ModelScope", websiteUrl: "https://modelscope.cn", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 18a830fca..9696ad086 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -879,6 +879,37 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "TheRouter", + options: { + baseURL: "https://api.therouter.ai/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, + "openai/gpt-5.3-codex": { name: "GPT-5.3 Codex" }, + "openai/gpt-5.2": { name: "GPT-5.2" }, + "google/gemini-3-flash-preview": { + name: "Gemini 3 Flash Preview", + }, + "qwen/qwen3-coder-480b": { name: "Qwen3 Coder 480B" }, + }, + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/tests/config/therouterOpenCodeOpenClawPresets.test.ts b/tests/config/therouterOpenCodeOpenClawPresets.test.ts new file mode 100644 index 000000000..29279f9d7 --- /dev/null +++ b/tests/config/therouterOpenCodeOpenClawPresets.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { opencodeProviderPresets } from "@/config/opencodeProviderPresets"; +import { openclawProviderPresets } from "@/config/openclawProviderPresets"; + +describe("TheRouter OpenCode and OpenClaw presets", () => { + it("uses OpenAI-compatible config for OpenCode", () => { + const preset = opencodeProviderPresets.find((item) => item.name === "TheRouter"); + const models = preset?.settingsConfig.models ?? {}; + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.npm).toBe("@ai-sdk/openai-compatible"); + expect(preset?.settingsConfig.options?.baseURL).toBe( + "https://api.therouter.ai/v1", + ); + expect(preset?.settingsConfig.options?.setCacheKey).toBe(true); + expect(models).toHaveProperty("openai/gpt-5.3-codex"); + expect(models).toHaveProperty("anthropic/claude-sonnet-4.6"); + expect(models).toHaveProperty("google/gemini-3-flash-preview"); + }); + + it("uses OpenAI completions config for OpenClaw", () => { + const preset = openclawProviderPresets.find((item) => item.name === "TheRouter"); + const modelIds = (preset?.settingsConfig.models ?? []).map((model) => model.id); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.baseUrl).toBe("https://api.therouter.ai/v1"); + expect(preset?.settingsConfig.api).toBe("openai-completions"); + expect(modelIds).toEqual( + expect.arrayContaining([ + "anthropic/claude-sonnet-4.6", + "openai/gpt-5.3-codex", + "openai/gpt-5.2", + "google/gemini-3-flash-preview", + ]), + ); + expect(preset?.suggestedDefaults?.model).toEqual({ + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }); + }); +}); From 7f1963ab493d02c71ec936b2ed72c05a31434809 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:48:14 +0800 Subject: [PATCH 09/28] feat(provider): add TheRouter presets for Claude, Codex, and Gemini (#1891) * feat(provider): add TheRouter presets for Claude and Codex * feat(provider): add TheRouter Gemini preset --------- Co-authored-by: max --- src/config/claudeProviderPresets.ts | 18 +++++ src/config/codexProviderPresets.ts | 13 ++++ src/config/geminiProviderPresets.ts | 16 +++++ tests/config/therouterProviderPresets.test.ts | 66 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 tests/config/therouterProviderPresets.test.ts diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 94f9f60e7..96353e626 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -671,6 +671,24 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.therouter.ai", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6", + }, + }, + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index dd0a687fe..97a0375bc 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -365,4 +365,17 @@ requires_openai_auth = true`, icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "therouter", + "https://api.therouter.ai/v1", + "openai/gpt-5.3-codex", + ), + endpointCandidates: ["https://api.therouter.ai/v1"], + category: "aggregator", + }, ]; diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index aaff2e257..4b7339b5e 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -241,6 +241,22 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://api.therouter.ai", + GEMINI_MODEL: "gemini-2.5-pro", + }, + }, + baseURL: "https://api.therouter.ai", + model: "gemini-2.5-pro", + description: "TheRouter", + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "自定义", websiteUrl: "", diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts new file mode 100644 index 000000000..97b77a24c --- /dev/null +++ b/tests/config/therouterProviderPresets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { providerPresets } from "@/config/claudeProviderPresets"; +import { codexProviderPresets } from "@/config/codexProviderPresets"; +import { geminiProviderPresets } from "@/config/geminiProviderPresets"; + +describe("TheRouter provider presets", () => { + it("uses the Anthropic-compatible root endpoint for Claude", () => { + const preset = providerPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(""); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-4.6"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe( + "anthropic/claude-haiku-4.5", + ); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe( + "anthropic/claude-sonnet-4.6", + ); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( + "anthropic/claude-opus-4.6", + ); + }); + + it("uses the OpenAI-compatible v1 endpoint for Codex", () => { + const preset = codexProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual([ + "https://api.therouter.ai/v1", + ]); + expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" }); + expect(preset?.config).toContain('model_provider = "therouter"'); + expect(preset?.config).toContain('model = "openai/gpt-5.3-codex"'); + expect(preset?.config).toContain( + 'base_url = "https://api.therouter.ai/v1"', + ); + expect(preset?.config).toContain('wire_api = "responses"'); + }); + + it("uses the Gemini-native root endpoint for Gemini", () => { + const preset = geminiProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + expect(preset?.baseURL).toBe("https://api.therouter.ai"); + expect(preset?.model).toBe("gemini-2.5-pro"); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.GEMINI_MODEL).toBe("gemini-2.5-pro"); + }); +}); From afdda27bd5ca9585945f780e47749450e998032e Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:24:25 +0800 Subject: [PATCH 10/28] Restore auth tab localization in settings (#1985) The settings page already routes the auth tab label through the shared i18n key, but the locale bundles never defined that key. Adding the missing entries fixes the label with the same simple pattern used by the other tabs. Constraint: Keep the fix aligned with the existing settings-tab i18n flow Rejected: Add component-level bilingual rendering | unnecessary for a missing translation key Confidence: high Scope-risk: narrow Reversibility: clean Directive: When adding settings tabs, define locale keys in every bundled language before relying on fallback text Tested: pnpm format:check; pnpm typecheck Not-tested: Manual verification in the desktop UI --- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 3 files changed, 3 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5a0f16b42..b03a54c65 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -243,6 +243,7 @@ "title": "Settings", "general": "General", "tabGeneral": "General", + "tabAuth": "Auth", "tabAdvanced": "Advanced", "tabProxy": "Proxy", "authCenter": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bb0c8bf43..90409338d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -243,6 +243,7 @@ "title": "設定", "general": "一般", "tabGeneral": "一般", + "tabAuth": "認証", "tabAdvanced": "詳細", "tabProxy": "プロキシ", "authCenter": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8696647dd..66128f02d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -243,6 +243,7 @@ "title": "设置", "general": "通用", "tabGeneral": "通用", + "tabAuth": "认证", "tabAdvanced": "高级", "tabProxy": "代理", "authCenter": { From 8c32610a7de81fd8e9b8cc0e6df971e3cf887349 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:34:18 +0800 Subject: [PATCH 11/28] Align Thinking fallback with main-model-only Claude mappings (#1984) The Claude provider form reopened with an empty Thinking model after users saved only a main model. This updates model-state hydration to mirror the existing Haiku-style fallback semantics: read ANTHROPIC_REASONING_MODEL when present, otherwise display ANTHROPIC_MODEL, without writing a synthetic reasoning field back into config. Constraint: Existing Haiku, Sonnet, and Opus selectors already rely on read-time fallback behavior Rejected: Persist ANTHROPIC_REASONING_MODEL from ANTHROPIC_MODEL automatically | would diverge from Haiku behavior and silently rewrite saved config Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Thinking fallback read-only unless all model-mapping fields are intentionally migrated to write-through semantics Tested: pnpm typecheck Tested: pnpm test:unit (1 unrelated pre-existing failure in tests/components/UnifiedSkillsPanel.test.tsx mock setup) Not-tested: Manual add-provider reopen flow in the desktop UI --- .../providers/forms/hooks/useModelState.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 46a1c6d7e..01278c4b5 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -14,10 +14,11 @@ function parseModelsFromConfig(settingsConfig: string) { const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -92,10 +93,11 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -148,16 +150,17 @@ export function useModelState({ ? JSON.parse(settingsConfig) : { env: {} }; if (!currentConfig.env) currentConfig.env = {}; + const env = currentConfig.env as Record; // 新键仅写入;旧键不再写入 const trimmed = value.trim(); if (trimmed) { - currentConfig.env[field] = trimmed; + env[field] = trimmed; } else { - delete currentConfig.env[field]; + delete env[field]; } // 删除旧键 - delete currentConfig.env["ANTHROPIC_SMALL_FAST_MODEL"]; + delete env["ANTHROPIC_SMALL_FAST_MODEL"]; onConfigChange(JSON.stringify(currentConfig, null, 2)); } catch (err) { From e4b58c72065dfb3d509091763dc9e525ba37cc4d Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:35:16 +0800 Subject: [PATCH 12/28] Let Kaku users launch sessions from their chosen terminal (#1954) (#1983) Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows. Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests Not-tested: End-to-end launch against a locally installed Kaku.app Related: #1954 --- src-tauri/src/commands/misc.rs | 1 + src-tauri/src/session_manager/terminal/mod.rs | 94 +++++++++++++++---- src-tauri/src/settings.rs | 2 +- src/components/settings/TerminalSettings.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/types.ts | 2 +- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 5001e1f37..623166e5a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -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 }; diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index ab13466ba..3e35df30e 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -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 { + 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 { + 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(), + ] + ); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 22e415a4c..c9b4a4b41 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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")] diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index c1d62a158..4cf43d418 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -16,6 +16,7 @@ const MACOS_TERMINALS = [ { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, + { value: "kaku", labelKey: "settings.terminal.options.macos.kaku" }, ] as const; const WINDOWS_TERMINALS = [ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b03a54c65..8e3040bcf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "Command Prompt", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 90409338d..e8bb0238d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "コマンドプロンプト", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 66128f02d..00990ace8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "命令提示符", diff --git a/src/types.ts b/src/types.ts index c1b674418..90cb06a22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -313,7 +313,7 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string; From 3aef5217cbe4796f4e03e8f2c65857b0d4460c26 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:36:32 +0800 Subject: [PATCH 13/28] Restore first-class OMO Slim council support (#1981) (#1982) cc-switch could already persist arbitrary OMO Slim agent keys and top-level fields, but the built-in metadata and UI copy still reflected the pre-council agent set. This made the upstream council feature look unsupported and pushed users toward manual JSON-only setup. Promote council to a built-in OMO Slim agent, add copy that points top-level plugin settings at Other Fields, and lock the behavior with regression tests. Constraint: oh-my-opencode-slim exposes council through both agents.council and top-level council config Rejected: Add a dedicated council editor UI now | too much surface area for issue #1981 Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep OMO Slim built-in agent metadata aligned with upstream agent additions before shipping UI support Tested: pnpm exec vitest run tests/utils/omoConfig.test.ts tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts Tested: pnpm typecheck Not-tested: End-to-end validation against a live oh-my-opencode-slim installation Related: farion1231/cc-switch#1981 --- .../providers/forms/OmoFormFields.tsx | 22 ++++++++--- src/i18n/locales/en.json | 7 +++- src/i18n/locales/ja.json | 7 +++- src/i18n/locales/zh.json | 7 +++- src/types/omo.ts | 9 +++++ tests/utils/omoConfig.test.ts | 38 +++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 52cced9cb..c1a316515 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -1264,12 +1264,22 @@ export function OmoFormFields({ ) : undefined, maxHeightClass: "max-h-[500px]", children: ( -