feat(sessions): group local conversations by project and enable auto-repair by default

This commit is contained in:
lzt404 committed 2026-08-01 00:04:25 +08:00
1 parent e01aef44f5
commit 6a5eedbb56
9 files changed
+763 -94

No files matched your search

+7 -8
View File
@@ -58,7 +58,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
throw "Build finished, but executable not found: $AppBin"
}
& bun $PreparePortable --binary $AppBin --output $BinDir --download true
& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
if ($LASTEXITCODE -ne 0) {
throw "Portable preparation failed with exit code $LASTEXITCODE."
}
@@ -68,15 +68,14 @@ if (-not (Test-Path -LiteralPath $BinOut -PathType Leaf)) {
}
$CoreOut = Join-Path $BinDir 'cpa-core'
$CoreEntries = @(Get-ChildItem -LiteralPath $CoreOut -Force)
if (
$CoreEntries.Count -ne 1 -or
$CoreEntries[0].PSIsContainer -or
$CoreEntries[0].Name -notmatch '^CLIProxyAPI_.+_windows_.+\.zip$'
) {
$CoreArchives = @(
Get-ChildItem -LiteralPath $CoreOut -File |
Where-Object { $_.Name -match '^CLIProxyAPI_.+_windows_.+\.zip$' }
)
if ($CoreArchives.Count -ne 1) {
throw "Portable core output must contain exactly one Windows core archive: $CoreOut"
}
Write-Host "Built: $AppBin"
Write-Host "Copied: $BinOut"
Write-Host "Bundled core archive: $($CoreEntries[0].FullName)"
Write-Host "Bundled core archive: $($CoreArchives[0].FullName)"
+1 -1
View File
@@ -1 +1 @@
7.2.109
7.2.112
+16 -7
View File
@@ -14,6 +14,7 @@ const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target',
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch];
const shouldDownload = args.get('--download') === 'true';
const preserveRuntimeConfig = args.get('--preserve-runtime-config') === 'true';
if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`);
if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`);
@@ -75,17 +76,25 @@ await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({
}, null, 2)}\n`);
const coreOutput = join(output, 'cpa-core');
await rm(coreOutput, { recursive: true, force: true });
if (preserveRuntimeConfig) {
await mkdir(coreOutput, { recursive: true });
const entries = await readdir(coreOutput, { withFileTypes: true });
await Promise.all(entries
.filter((entry) => entry.name !== 'config.yaml')
.map((entry) => rm(join(coreOutput, entry.name), { recursive: true, force: true })));
} else {
await rm(coreOutput, { recursive: true, force: true });
}
await mkdir(coreOutput, { recursive: true });
await copyFile(sourceArchive, join(coreOutput, assetName));
const coreEntries = await readdir(coreOutput, { withFileTypes: true });
if (
coreEntries.length !== 1
|| !coreEntries[0].isFile()
|| coreEntries[0].name !== assetName
) {
const bundledArchive = coreEntries.find((entry) => entry.isFile() && entry.name === assetName);
if (!bundledArchive) {
throw new Error(`Core output must contain ${assetName}`);
}
if (!preserveRuntimeConfig && coreEntries.length !== 1) {
throw new Error(`Core output must contain only ${assetName}`);
}
console.log(`Prepared portable directory: ${output}`);
console.log(`Bundled core: ${basename(sourceArchive)} (archive only)`);
console.log(`Bundled core: ${basename(sourceArchive)}${preserveRuntimeConfig ? ' (preserved cpa-core/config.yaml)' : ' (archive only)'}`);
+59 -8
View File
@@ -99,11 +99,14 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
.find(|value| !value.is_empty())
.unwrap_or_default()
};
if slug.is_empty() || !seen.insert(normalize_id(slug)) {
if slug.is_empty() {
continue;
}
let display_name = optional_string(value, &["display_name", "displayName", "alias"]);
let display_name = optional_string(value, &["display_name", "displayName"]);
let model_alias =
optional_string(value, &["alias"]).filter(|alias| !alias.eq_ignore_ascii_case(slug));
let keep_original = value.get("fork").and_then(Value::as_bool).unwrap_or(false);
let description = optional_string(value, &["description"]);
let context_window = positive_u64_field(
value,
@@ -125,17 +128,28 @@ pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeMo
let hidden = optional_string(value, &["visibility"])
.is_some_and(|value| value.eq_ignore_ascii_case("hide"));
models.push(CodexRuntimeModel {
let make_runtime_model = |slug: &str, display_name: Option<String>| CodexRuntimeModel {
slug: slug.to_string(),
display_name,
description,
description: description.clone(),
context_window,
max_context_window,
input_modalities,
supported_reasoning_levels,
default_reasoning_level,
input_modalities: input_modalities.clone(),
supported_reasoning_levels: supported_reasoning_levels.clone(),
default_reasoning_level: default_reasoning_level.clone(),
hidden,
});
};
if let Some(model_alias) = model_alias {
if keep_original && seen.insert(normalize_id(slug)) {
models.push(make_runtime_model(slug, display_name));
}
if seen.insert(normalize_id(&model_alias)) {
models.push(make_runtime_model(&model_alias, Some(slug.to_string())));
}
} else if seen.insert(normalize_id(slug)) {
models.push(make_runtime_model(slug, display_name));
}
}
Ok(models)
}
@@ -808,6 +822,43 @@ mod tests {
assert_eq!(models[1].slug, "Model-B");
}
#[test]
fn runtime_parser_exposes_model_alias_as_a_runtime_slug() {
let models = runtime(serde_json::json!({
"models": [
{
"id": "gpt-5.5",
"alias": "gpt-5.5-xhigh",
"fork": true,
"context_window": 200000
},
{"id": "hidden-source", "alias": "visible-alias"}
]
}));
assert_eq!(
models
.iter()
.map(|model| model.slug.as_str())
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(models[1].display_name.as_deref(), Some("gpt-5.5"));
assert_eq!(models[1].context_window, Some(200_000));
assert_eq!(models[2].display_name.as_deref(), Some("hidden-source"));
let catalog = prepare_catalog_with_sources(&models, &test_sources()).unwrap();
assert_eq!(
catalog
.models
.iter()
.map(|model| model.name.as_str())
.collect::<Vec<_>>(),
vec!["gpt-5.5", "gpt-5.5-xhigh", "visible-alias"]
);
assert_eq!(catalog.models[1].alias.as_deref(), Some("gpt-5.5"));
}
#[test]
fn known_and_unknown_models_are_composed_from_the_correct_sources() {
let runtime = runtime(serde_json::json!({"models":[
+671 -64
View File
@@ -85,6 +85,12 @@ const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui";
const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json";
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR: &str = "CODEX_OAUTH_LOGIN_REQUIRED";
const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700";
const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[
"codex-api-key",
"openai-compatibility",
"claude-api-key",
"gemini-api-key",
];
const LEGACY_AGENT_MODIFICATION_STATE_VERSION: u8 = 1;
const AGENT_MODIFICATION_STATE_VERSION: u8 = 2;
const AGENT_APPLIED_STATE_VERSION: u8 = 3;
@@ -730,6 +736,14 @@ enum CodexAppTarget {
WindowsAppId(String),
}
#[derive(Clone, Debug)]
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
enum ClaudeDesktopTarget {
Application(PathBuf),
#[cfg(target_os = "windows")]
WindowsAppId(String),
}
impl AgentClient {
fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
@@ -1959,6 +1973,9 @@ async fn apply_agent_config(
validate_agent_can_enable(client, &home, config.port, api_key)?;
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
if client == AgentClient::ClaudeDesktop {
ensure_claude_desktop_model_alias(&config, &model).await?;
}
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
@@ -1997,6 +2014,9 @@ async fn reset_agent_config_to_default(
validate_agent_can_enable(client, &home, config.port, api_key)?;
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
if client == AgentClient::ClaudeDesktop {
ensure_claude_desktop_model_alias(&config, &model).await?;
}
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
@@ -2046,6 +2066,9 @@ async fn set_agent_config_enabled(
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model =
resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
if client == AgentClient::ClaudeDesktop {
ensure_claude_desktop_model_alias(&config, &model).await?;
}
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
@@ -2084,6 +2107,9 @@ async fn update_agent_config(
let api_key = effective_agent_api_key(&config);
let prepared = fetch_prepared_agent_models(client, &config).await?;
let model = resolve_available_agent_model(&prepared.models, &validate_agent_model(&model)?)?;
if client == AgentClient::ClaudeDesktop {
ensure_claude_desktop_model_alias(&config, &model).await?;
}
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
@@ -2164,13 +2190,14 @@ async fn launch_agent(
return Err(format!("{} 不支持桌面 App 启动方式", client.name()));
}
if client == AgentClient::ClaudeDesktop {
let target = find_claude_desktop_target(&home)
.ok_or_else(|| "未检测到 Claude Desktop 应用,请先安装或重新检测。".to_string())?;
return launch_claude_desktop(&target);
}
let executable = find_agent_executable(client, &home)
.ok_or_else(|| format!("未找到 {} 的可执行文件", client.name()))?;
if client == AgentClient::ClaudeDesktop {
launch_desktop_agent(&executable, client.name())
} else {
launch_cli_agent(&executable, client.name(), &home)
}
launch_cli_agent(&executable, client.name(), &home)
}
fn validate_agent_launch_modification(
@@ -2273,24 +2300,59 @@ fn parse_agent_model_options(payload: &serde_json::Value) -> Result<Vec<AgentMod
.trim()
.to_string()
};
if name.is_empty()
|| models
.iter()
.any(|model: &AgentModelOption| model.name.eq_ignore_ascii_case(&name))
{
continue;
}
let alias = ["alias", "display_name", "displayName"]
let display_name = ["display_name", "displayName"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name))
.map(str::to_string);
models.push(AgentModelOption { name, alias });
let model_alias = item
.get("alias")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(&name))
.map(str::to_string);
let keep_original = item
.get("fork")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if let Some(model_alias) = model_alias {
if keep_original {
append_agent_model_option(&mut models, &name, display_name);
}
append_agent_model_option(&mut models, &model_alias, Some(name));
} else {
append_agent_model_option(&mut models, &name, display_name);
}
}
Ok(models)
}
fn append_agent_model_option(
models: &mut Vec<AgentModelOption>,
name: &str,
alias: Option<String>,
) {
let name = name.trim();
if name.is_empty()
|| models
.iter()
.any(|model| model.name.eq_ignore_ascii_case(name))
{
return;
}
let alias = alias
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(name))
.map(str::to_string);
models.push(AgentModelOption {
name: name.to_string(),
alias,
});
}
fn parse_codex_model_definitions(
payload: &serde_json::Value,
) -> Result<Vec<CodexModelDefinition>, String> {
@@ -2531,12 +2593,12 @@ fn claude_desktop_config_paths(_home: &Path) -> Vec<PathBuf> {
(support.join("Claude"), support.join("Claude-3p"))
};
#[cfg(target_os = "windows")]
let (normal, threep) = {
{
let local = env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| _home.join("AppData/Local"));
(local.join("Claude"), local.join("Claude-3p"))
};
return claude_desktop_config_paths_from_local_app_data(&local);
}
#[cfg(target_os = "linux")]
let (normal, threep) = {
let config_home = env::var_os("XDG_CONFIG_HOME")
@@ -2548,18 +2610,60 @@ fn claude_desktop_config_paths(_home: &Path) -> Vec<PathBuf> {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Vec::new();
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let library = threep.join("configLibrary");
vec![
normal.join("claude_desktop_config.json"),
threep.join("claude_desktop_config.json"),
library.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")),
library.join("_meta.json"),
]
claude_desktop_config_paths_from_directories(normal, threep)
}
}
fn claude_desktop_config_paths_from_directories(normal: PathBuf, threep: PathBuf) -> Vec<PathBuf> {
let library = threep.join("configLibrary");
vec![
normal.join("claude_desktop_config.json"),
threep.join("claude_desktop_config.json"),
library.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")),
library.join("_meta.json"),
]
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_paths_from_local_app_data(local_app_data: &Path) -> Vec<PathBuf> {
// Claude Desktop may add a channel or version suffix to these directories.
// Match cc-switch's resolver so status detection and configuration writes use
// the same files as the installed Desktop client rather than only the legacy
// fixed `Claude` / `Claude-3p` locations.
let normal = find_windows_claude_data_directory(local_app_data, false)
.unwrap_or_else(|| local_app_data.join("Claude"));
let threep = find_windows_claude_data_directory(local_app_data, true)
.unwrap_or_else(|| local_app_data.join("Claude-3p"));
claude_desktop_config_paths_from_directories(normal, threep)
}
#[cfg(target_os = "windows")]
fn find_windows_claude_data_directory(local_app_data: &Path, threep: bool) -> Option<PathBuf> {
let exact_name = if threep { "Claude-3p" } else { "Claude" };
let exact = local_app_data.join(exact_name);
if exact.is_dir() {
return Some(exact);
}
let mut candidates = fs::read_dir(local_app_data)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| {
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let normalized = name.to_ascii_lowercase();
normalized.starts_with("claude") && normalized.contains("-3p") == threep
})
.collect::<Vec<_>>();
candidates.sort();
candidates.into_iter().next()
}
fn hermes_agent_config_path(home: &Path) -> PathBuf {
if let Some(directory) = env::var_os("HERMES_HOME")
.map(PathBuf::from)
@@ -2605,11 +2709,16 @@ fn inspect_agent_config(
};
let launch_targets = agent_launch_targets(client, home);
let executable = find_agent_executable(client, home);
let installed = !launch_targets.is_empty() || config_exists;
let version = executable
.as_deref()
.filter(|_| client != AgentClient::ClaudeDesktop)
.and_then(read_agent_version);
let installed = if client == AgentClient::ClaudeDesktop {
!launch_targets.is_empty()
} else {
!launch_targets.is_empty() || config_exists
};
let version = if client == AgentClient::ClaudeDesktop {
read_claude_desktop_version(home)
} else {
executable.as_deref().and_then(read_agent_version)
};
let mut warnings = Vec::new();
if !client.supported_platform() {
warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string());
@@ -2784,6 +2893,16 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result<bo
fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec<AgentLaunchTarget> {
let mut targets = Vec::new();
if client == AgentClient::ClaudeDesktop {
if let Some(target) = find_claude_desktop_target(home) {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "Claude Desktop".to_string(),
detail: claude_desktop_target_detail(&target),
});
}
return targets;
}
if client == AgentClient::Codex {
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Some(target) = find_codex_app_target(home) {
@@ -2828,6 +2947,14 @@ fn codex_app_target_detail(target: &CodexAppTarget) -> String {
}
}
fn claude_desktop_target_detail(target: &ClaudeDesktopTarget) -> String {
match target {
ClaudeDesktopTarget::Application(path) => path_to_string(path),
#[cfg(target_os = "windows")]
ClaudeDesktopTarget::WindowsAppId(app_id) => format!("Microsoft Store · {app_id}"),
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn launch_codex_app_via_cli(executable: &Path, home: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
@@ -2881,6 +3008,26 @@ fn find_codex_app_target(home: &Path) -> Option<CodexAppTarget> {
}
}
fn find_claude_desktop_target(home: &Path) -> Option<ClaudeDesktopTarget> {
#[cfg(target_os = "windows")]
{
return find_windows_claude_desktop_target(home);
}
#[cfg(not(target_os = "windows"))]
find_claude_desktop_executable(home).map(ClaudeDesktopTarget::Application)
}
fn read_claude_desktop_version(home: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
{
return read_windows_claude_desktop_store_version().or_else(|| {
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
});
}
#[cfg(not(target_os = "windows"))]
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
}
#[cfg(target_os = "windows")]
fn windows_system_root() -> PathBuf {
env::var_os("SystemRoot")
@@ -2934,6 +3081,101 @@ fn find_windows_codex_app_target(home: &Path) -> Option<CodexAppTarget> {
.or_else(|| find_windows_codex_app_executable(home).map(CodexAppTarget::Application))
}
#[cfg(target_os = "windows")]
fn find_windows_claude_desktop_target(home: &Path) -> Option<ClaudeDesktopTarget> {
find_windows_registered_claude_desktop_target()
.or_else(|| find_claude_desktop_executable(home).map(ClaudeDesktopTarget::Application))
}
#[cfg(target_os = "windows")]
fn find_windows_registered_claude_desktop_target() -> Option<ClaudeDesktopTarget> {
const DISCOVERY_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
$startApps = @(Get-StartApps)
$appId = $startApps |
Where-Object {
$_.AppID -like 'Claude_*!*' -or
$_.AppID -like 'Anthropic.Claude_*!*'
} |
Select-Object -First 1 -ExpandProperty AppID
if ($appId) {
Write-Output "APPID:$appId"
exit 0
}
$packages = @(Get-AppxPackage) |
Where-Object {
$_.Name -eq 'Claude' -or
$_.Name -like 'Anthropic.Claude*' -or
$_.PackageFamilyName -match '^(Claude|Anthropic\.Claude)_'
}
foreach ($package in $packages) {
$manifest = Get-AppxPackageManifest -Package $package.PackageFullName
$application = @($manifest.Package.Applications.Application) |
Where-Object { $_.Id } |
Select-Object -First 1
if ($application -and $package.PackageFamilyName) {
Write-Output "APPID:$($package.PackageFamilyName)!$($application.Id)"
exit 0
}
}
"#;
let encoded_command = windows_powershell_encoded_command(DISCOVERY_SCRIPT);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_windows_claude_desktop_discovery_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(target_os = "windows")]
fn read_windows_claude_desktop_store_version() -> Option<String> {
const VERSION_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$package = @(Get-AppxPackage) |
Where-Object {
$_.Name -eq 'Claude' -or
$_.Name -like 'Anthropic.Claude*' -or
$_.PackageFamilyName -match '^(Claude|Anthropic\.Claude)_'
} |
Select-Object -First 1
if ($package -and $package.Version) {
Write-Output "VERSION:$($package.Version)"
}
"#;
let encoded_command = windows_powershell_encoded_command(VERSION_SCRIPT);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_windows_claude_desktop_version_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(target_os = "windows")]
fn find_windows_registered_codex_app_target() -> Option<CodexAppTarget> {
const DISCOVERY_SCRIPT: &str = r#"
@@ -3051,6 +3293,25 @@ fn parse_windows_codex_app_discovery_output(output: &str) -> Option<CodexAppTarg
})
}
#[cfg(target_os = "windows")]
fn parse_windows_claude_desktop_discovery_output(output: &str) -> Option<ClaudeDesktopTarget> {
output.lines().find_map(|line| {
let app_id = line.trim().strip_prefix("APPID:")?.trim();
(!app_id.is_empty()).then(|| ClaudeDesktopTarget::WindowsAppId(app_id.to_string()))
})
}
#[cfg(target_os = "windows")]
fn parse_windows_claude_desktop_version_output(output: &str) -> Option<String> {
output.lines().find_map(|line| {
line.trim()
.strip_prefix("VERSION:")
.map(str::trim)
.filter(|version| !version.is_empty())
.map(str::to_string)
})
}
#[cfg(target_os = "windows")]
fn find_windows_codex_app_id_via_registry() -> Option<String> {
const PACKAGES_KEY: &str = r"HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
@@ -3385,6 +3646,28 @@ fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> {
.map_err(|error| format!("启动 {label} 失败: {error}"))
}
fn launch_claude_desktop(target: &ClaudeDesktopTarget) -> Result<(), String> {
match target {
ClaudeDesktopTarget::Application(executable) => {
launch_desktop_agent(executable, "Claude Desktop")
}
#[cfg(target_os = "windows")]
ClaudeDesktopTarget::WindowsAppId(app_id) => {
let mut command = Command::new(windows_explorer_executable());
command.arg(format!("shell:AppsFolder\\{app_id}"));
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
.spawn()
.map(|_| ())
.map_err(|error| format!("启动 Claude Desktop 失败: {error}"))
}
}
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
@@ -3564,6 +3847,11 @@ fn inspect_claude_desktop_agent_config(
.and_then(|model| {
model
.as_str()
.or_else(|| {
model
.get("labelOverride")
.and_then(serde_json::Value::as_str)
})
.or_else(|| model.get("name").and_then(serde_json::Value::as_str))
})
.map(str::trim)
@@ -4078,7 +4366,7 @@ fn build_claude_desktop_profile(
base_url: &str,
api_key: &str,
model: &str,
available_models: &[AgentModelOption],
_available_models: &[AgentModelOption],
) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?;
root.insert(
@@ -4105,16 +4393,18 @@ fn build_claude_desktop_profile(
"inferenceProvider".to_string(),
serde_json::json!("gateway"),
);
let inference_models = ordered_agent_models(available_models, model)
.into_iter()
.map(|model| match model.alias {
Some(alias) => serde_json::json!({
"name": model.name,
"labelOverride": alias,
}),
None => serde_json::json!(model.name),
// Claude Desktop should receive only the selected model. For a non-Claude
// CPA model, the name sent to the gateway is a stable Claude-style alias;
// its human-readable label remains the original selected model.
let inference_model = claude_desktop_inference_model_id(model)?;
let inference_models = vec![if inference_model.eq_ignore_ascii_case(model) {
serde_json::json!(inference_model)
} else {
serde_json::json!({
"name": inference_model,
"labelOverride": model,
})
.collect::<Vec<_>>();
}];
root.insert(
"inferenceModels".to_string(),
serde_json::Value::Array(inference_models),
@@ -4122,6 +4412,32 @@ fn build_claude_desktop_profile(
render_agent_json(root, "Claude Desktop 网关配置")
}
fn claude_desktop_inference_model_id(model: &str) -> Result<String, String> {
let model = validate_agent_model(model)?;
let normalized = model.to_ascii_lowercase();
if normalized.starts_with("claude-")
|| normalized.starts_with("opus-")
|| matches!(normalized.as_str(), "sonnet" | "opus")
{
return Ok(model);
}
let reversed = model
.chars()
.rev()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_lowercase()
} else if matches!(character, '-' | '.') {
character
} else {
'-'
}
})
.collect::<String>();
let alias = format!("opus-{reversed}");
validate_thinking_alias_model_id(&alias, "Claude Desktop 自动别名")
}
fn build_claude_desktop_meta(existing: Option<&str>) -> Result<String, String> {
let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?;
let entries = ensure_json_array_entry(&mut root, "entries");
@@ -9314,6 +9630,142 @@ async fn put_management_config_yaml(config: &GuiConfigFile, content: &str) -> Re
read_management_value(response).await.map(|_| ())
}
async fn ensure_claude_desktop_model_alias(
config: &GuiConfigFile,
model: &str,
) -> Result<(), String> {
let alias = claude_desktop_inference_model_id(model)?;
if alias.eq_ignore_ascii_case(model) {
return Ok(());
}
let content = fetch_management_config_yaml(config).await?;
let updated = ensure_claude_desktop_model_alias_in_yaml(&content, model, &alias)?;
if updated != content {
put_management_config_yaml(config, &updated).await?;
}
Ok(())
}
fn ensure_claude_desktop_model_alias_in_yaml(
content: &str,
source_model: &str,
alias: &str,
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut updated = document.get().clone();
let root = updated
.as_mapping_mut()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
if let Some((existing_source, existing_client_model)) =
configured_model_client_identity(root, alias)
{
if existing_client_model != existing_source
&& existing_source.eq_ignore_ascii_case(source_model)
{
return Ok(content.to_string());
}
return Err(format!(
"Claude Desktop 自动别名 {alias} 已被模型 {existing_source} 使用,请先修改冲突模型的别名"
));
}
if !append_claude_desktop_model_alias(root, source_model, alias)? {
return Err(format!(
"无法确定模型 {source_model} 的 CPA 配置来源,无法创建 Claude Desktop 自动别名"
));
}
render_updated_core_yaml(&mut document, updated)
}
fn configured_model_client_identity(
root: &serde_norway::Mapping,
client_model: &str,
) -> Option<(String, String)> {
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) =
yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for provider in providers {
let Some(provider) = provider.as_mapping() else {
continue;
};
let Some(models) =
yaml_mapping_value(provider, "models").and_then(serde_norway::Value::as_sequence)
else {
continue;
};
for model in models {
let Some((source, configured_client_model, _)) = configured_model_identity(model)
else {
continue;
};
if configured_client_model.eq_ignore_ascii_case(client_model) {
return Some((source, configured_client_model));
}
}
}
}
None
}
fn append_claude_desktop_model_alias(
root: &mut serde_norway::Mapping,
source_model: &str,
alias: &str,
) -> Result<bool, String> {
for section in MODEL_ALIAS_CONFIG_SECTIONS {
let Some(providers) = yaml_mapping_value_mut(root, section) else {
continue;
};
let providers = providers
.as_sequence_mut()
.ok_or_else(|| format!("{section} 必须是数组"))?;
for provider in providers {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(models) = yaml_mapping_value_mut(provider, "models") else {
continue;
};
let models = models
.as_sequence_mut()
.ok_or_else(|| format!("{section}.models 必须是数组"))?;
let Some(source) = models.iter().find_map(|model| {
let (upstream_model, client_model, _) = configured_model_identity(model)?;
client_model
.eq_ignore_ascii_case(source_model)
.then(|| (model.clone(), upstream_model))
}) else {
continue;
};
let (source, upstream_model) = source;
let mut alias_model = source.as_mapping().cloned().unwrap_or_else(|| {
let mut mapping = serde_norway::Mapping::new();
mapping.insert(
yaml_key("name"),
serde_norway::Value::String(upstream_model.clone()),
);
mapping
});
alias_model.insert(
yaml_key("name"),
serde_norway::Value::String(upstream_model),
);
alias_model.insert(
yaml_key("alias"),
serde_norway::Value::String(alias.to_string()),
);
models.push(serde_norway::Value::Mapping(alias_model));
return Ok(true);
}
}
Ok(false)
}
async fn fetch_codex_model_definitions(
config: &GuiConfigFile,
) -> Result<Vec<CodexModelDefinition>, String> {
@@ -9339,24 +9791,12 @@ fn resolved_thinking_alias_sources(
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut sources = definitions
.iter()
.filter(|definition| {
!definition.reasoning_levels.is_empty()
&& thinking_alias_model_is_available(available_models, &definition.id)
})
.map(|definition| ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("codex-oauth:{}", definition.id),
model: definition.id.clone(),
display_name: definition.display_name.clone(),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
protocol: "codex".to_string(),
},
location: ThinkingAliasSourceLocation::CodexOauth,
})
.collect::<Vec<_>>();
// A configured API-key model must win over a catalog entry with the same
// name. `model-definitions/codex` describes the OAuth channel's capabilities;
// it is not evidence that a model returned by /v1/models is using OAuth.
// Otherwise a Codex API alias would be written to oauth-model-alias, which
// CPA deliberately does not apply to codex-api-key credentials.
let mut sources = Vec::new();
collect_config_thinking_alias_sources(
root,
"codex-api-key",
@@ -9375,6 +9815,31 @@ fn resolved_thinking_alias_sources(
available_models,
&mut sources,
)?;
let configured_codex_api_models = sources
.iter()
.filter(|source| source.source.kind == "codex-api")
.map(|source| source.source.model.to_ascii_lowercase())
.collect::<std::collections::HashSet<_>>();
sources.extend(
definitions
.iter()
.filter(|definition| {
!definition.reasoning_levels.is_empty()
&& thinking_alias_model_is_available(available_models, &definition.id)
&& !configured_codex_api_models.contains(&definition.id.to_ascii_lowercase())
})
.map(|definition| ResolvedThinkingAliasSource {
source: ThinkingAliasSource {
id: format!("codex-oauth:{}", definition.id),
model: definition.id.clone(),
display_name: definition.display_name.clone(),
provider: "Codex OAuth".to_string(),
kind: "codex-oauth".to_string(),
protocol: "codex".to_string(),
},
location: ThinkingAliasSourceLocation::CodexOauth,
}),
);
Ok(sources)
}
@@ -14318,7 +14783,7 @@ mod tests {
assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY);
assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317");
assert_eq!(profile["inferenceModels"][0], "claude-sonnet-test");
assert_eq!(profile["inferenceModels"][1], "claude-opus-test");
assert_eq!(profile["inferenceModels"].as_array().unwrap().len(), 1);
assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID);
assert_eq!(meta["entries"].as_array().unwrap().len(), 2);
let managed_entry = meta["entries"]
@@ -14330,6 +14795,56 @@ mod tests {
assert_eq!(managed_entry["custom"], true);
}
#[test]
fn claude_desktop_profile_aliases_a_non_claude_model() {
let profile = build_claude_desktop_profile(
None,
"http://127.0.0.1:8317",
DEFAULT_API_KEY,
"gpt-5.6-sol",
&test_agent_models(&["gpt-5.6-sol"]),
)
.unwrap();
let profile: serde_json::Value = serde_json::from_str(&profile).unwrap();
assert_eq!(
profile["inferenceModels"],
serde_json::json!([{
"name": "opus-los-6.5-tpg",
"labelOverride": "gpt-5.6-sol",
}])
);
assert_eq!(
claude_desktop_inference_model_id("opus-los-6.5-tpg").unwrap(),
"opus-los-6.5-tpg"
);
}
#[cfg(target_os = "windows")]
#[test]
fn claude_desktop_detects_windows_variant_config_directories() {
let home = agent_test_home("claude-desktop-windows-variant-paths");
let local_app_data = home.join("AppData/Local");
let normal = local_app_data.join("Claude-Canary");
let threep = local_app_data.join("Claude-3p-Canary");
fs::create_dir_all(&normal).unwrap();
fs::create_dir_all(&threep).unwrap();
let paths = claude_desktop_config_paths_from_local_app_data(&local_app_data);
assert_eq!(paths.len(), 4);
assert_eq!(paths[0], normal.join("claude_desktop_config.json"));
assert_eq!(paths[1], threep.join("claude_desktop_config.json"));
assert_eq!(
paths[2],
threep
.join("configLibrary")
.join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json"))
);
assert_eq!(paths[3], threep.join("configLibrary/_meta.json"));
fs::remove_dir_all(home).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn claude_desktop_uses_linux_beta_config_paths() {
@@ -14473,14 +14988,15 @@ mod tests {
}
#[test]
fn agent_model_list_parser_accepts_core_response_and_deduplicates_ids() {
fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() {
let models = parse_agent_model_options(&serde_json::json!({
"object": "list",
"data": [
{"id": "gpt-5", "display_name": "GPT 5"},
{"name": "claude-sonnet", "alias": "Sonnet"},
{"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true},
{"name": "hidden-original", "alias": "visible-alias"},
"deepseek-chat",
{"id": "GPT-5", "alias": "duplicate"},
{"id": "GPT-5"},
{"id": ""}
]
}))
@@ -14495,7 +15011,15 @@ mod tests {
},
AgentModelOption {
name: "claude-sonnet".to_string(),
alias: Some("Sonnet".to_string()),
alias: None,
},
AgentModelOption {
name: "claude-sonnet-xhigh".to_string(),
alias: Some("claude-sonnet".to_string()),
},
AgentModelOption {
name: "visible-alias".to_string(),
alias: Some("hidden-original".to_string()),
},
AgentModelOption {
name: "deepseek-chat".to_string(),
@@ -14505,6 +15029,36 @@ mod tests {
);
}
#[test]
fn claude_desktop_auto_alias_is_added_once_to_its_source_provider() {
let input = "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1\n models:\n - name: gpt-5.6-sol\n";
let alias = claude_desktop_inference_model_id("gpt-5.6-sol").unwrap();
let rendered =
ensure_claude_desktop_model_alias_in_yaml(input, "gpt-5.6-sol", &alias).unwrap();
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
let root = value.as_mapping().unwrap();
let providers = yaml_mapping_value(root, "openai-compatibility")
.and_then(serde_norway::Value::as_sequence)
.unwrap();
let models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models")
.and_then(serde_norway::Value::as_sequence)
.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(
configured_model_identity(&models[1]),
Some((
"gpt-5.6-sol".to_string(),
"opus-los-6.5-tpg".to_string(),
None,
))
);
assert_eq!(
ensure_claude_desktop_model_alias_in_yaml(&rendered, "gpt-5.6-sol", &alias).unwrap(),
rendered
);
}
#[test]
fn agent_model_list_parser_rejects_unexpected_response_shape() {
assert!(parse_agent_model_options(&serde_json::json!({"data": null})).is_err());
@@ -14558,11 +15112,43 @@ mod tests {
.map(|source| source.source.model.as_str())
.collect::<Vec<_>>();
assert_eq!(source_models, vec!["gpt-runtime", "DeepSeek-Chat"]);
assert_eq!(source_models, vec!["DeepSeek-Chat", "gpt-runtime"]);
assert!(!source_models.contains(&"gpt-built-in-only"));
assert!(!source_models.contains(&"config-only"));
}
#[test]
fn thinking_alias_prefers_codex_api_key_model_over_same_named_oauth_definition() {
let input = "codex-api-key:\n - name: CPA\n api-key: test\n models:\n - name: gpt-5.6-luna\n";
let definitions = parse_codex_model_definitions(&serde_json::json!({
"models": [{
"id": "gpt-5.6-luna",
"thinking": { "levels": ["low", "high", "xhigh"] }
}]
}))
.unwrap();
let available_models = test_agent_models(&["gpt-5.6-luna"]);
let sources =
resolved_thinking_alias_sources(input, &definitions, &available_models).unwrap();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].source.model, "gpt-5.6-luna");
assert_eq!(sources[0].source.kind, "codex-api");
assert!(matches!(
sources[0].location,
ThinkingAliasSourceLocation::ConfigModel {
section: "codex-api-key",
..
}
));
let rendered =
add_thinking_alias_to_yaml(input, &sources[0], "gpt-5.6-luna-xhigh", "xhigh").unwrap();
assert!(rendered.contains("alias: gpt-5.6-luna-xhigh"), "{rendered}");
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
}
#[cfg(target_os = "windows")]
#[test]
fn windows_batch_agent_commands_use_call_without_embedded_quotes() {
@@ -14709,6 +15295,27 @@ mod tests {
.is_none());
}
#[cfg(target_os = "windows")]
#[test]
fn windows_claude_desktop_discovery_parser_accepts_store_app() {
let target = parse_windows_claude_desktop_discovery_output(
"notice\r\nAPPID:Claude_pzs8sxrjxfjjc!Claude\r\n",
)
.unwrap();
match target {
ClaudeDesktopTarget::WindowsAppId(app_id) => {
assert_eq!(app_id, "Claude_pzs8sxrjxfjjc!Claude");
}
ClaudeDesktopTarget::Application(_) => panic!("expected Store application ID"),
}
assert!(parse_windows_claude_desktop_discovery_output("no app found\r\n").is_none());
assert_eq!(
parse_windows_claude_desktop_version_output("VERSION:1.24012.9.0\r\n").as_deref(),
Some("1.24012.9.0")
);
assert!(parse_windows_claude_desktop_version_output("VERSION:\r\n").is_none());
}
#[test]
fn thinking_alias_adds_fork_and_matching_payload_rule() {
let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n";
-1
View File
@@ -523,7 +523,6 @@ export const jaOverrides = {
'agents.modify.default': 'デフォルト設定',
'agents.modify.clear': '設定を消去',
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。',
'agents.launch.enableFirst': '先に設定変更を適用してください',
'agents.launch.applyFirst': '先に選択中のモデルを適用してください',
'agents.launch.starting': '起動中',
-2
View File
@@ -526,7 +526,6 @@ export const zhCN = {
'agents.modify.default': '默认配置',
'agents.modify.clear': '清空配置',
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。',
'agents.launch.enableFirst': '请先应用配置修改',
'agents.launch.applyFirst': '请先应用当前模型选择',
'agents.launch.starting': '启动中',
@@ -1375,7 +1374,6 @@ export const en: Record<MessageKey, string> = {
'agents.modify.default': 'Default Configuration',
'agents.modify.clear': 'Clear Configuration',
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.',
'agents.launch.enableFirst': 'Apply the configuration changes first',
'agents.launch.applyFirst': 'Apply the selected model first',
'agents.launch.starting': 'Starting',
+1 -3
View File
@@ -653,9 +653,7 @@ export function AgentsPage() {
: t('agents.model.firstSelection', { count: models.length }));
const modificationDescription = activeStatus?.modificationState === 'invalid'
? t('agents.modify.invalid')
: activeStatus?.modificationState === 'applied'
? t('agents.modify.applied')
: '';
: '';
const refreshModels = () => {
void loadModels(selected);
};
+8
View File
@@ -1376,6 +1376,14 @@ textarea:focus-visible {
align-content: start;
}
.software-update-details .panel-detail-row {
grid-template-columns: 120px minmax(0, 1fr);
}
.software-update-details .panel-detail-row dt {
white-space: nowrap;
}
.software-update-actions {
grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
grid-auto-flow: row;