diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c1d5658 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Build portable releases + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + os: linux + arch: amd64 + suffix: tar.gz + - runner: ubuntu-24.04-arm + os: linux + arch: aarch64 + suffix: tar.gz + - runner: macos-15-intel + os: darwin + arch: amd64 + suffix: tar.gz + - runner: macos-15 + os: darwin + arch: aarch64 + suffix: tar.gz + - runner: windows-2025 + os: windows + arch: amd64 + suffix: zip + - runner: windows-11-arm + os: windows + arch: aarch64 + suffix: zip + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: dtolnay/rust-toolchain@stable + - name: Install Linux dependencies + if: matrix.os == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - name: Build GUI + run: | + bun install --frozen-lockfile + bun tauri build --no-bundle + shell: bash + - name: Prepare portable directory + run: >- + node scripts/prepare-portable.mjs + --binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}" + --output "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" + --os "${{ matrix.os }}" + --arch "${{ matrix.arch }}" + --download true + --clean-core true + shell: bash + - name: Archive Unix release + if: matrix.os != 'windows' + run: tar -czf "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" -C release "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" + - name: Archive Windows release + if: matrix.os == 'windows' + shell: pwsh + run: Compress-Archive -Path "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.zip" + - uses: actions/upload-artifact@v4 + with: + name: cpa-gui-${{ matrix.os }}-${{ matrix.arch }} + path: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }} + if-no-files-found: error + + publish: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" artifacts/* --generate-notes --verify-tag diff --git a/build.sh b/build.sh index be0d269..5031145 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,9 @@ if [ ! -x "$APP_BIN" ]; then fi mkdir -p "$BIN_DIR" -cp -f "$APP_BIN" "$BIN_OUT" +bun "$ROOT_DIR/scripts/prepare-portable.mjs" \ + --binary "$APP_BIN" \ + --output "$BIN_DIR" chmod +x "$BIN_OUT" echo "Built: $APP_BIN" diff --git a/core-version.txt b/core-version.txt new file mode 100644 index 0000000..953034d --- /dev/null +++ b/core-version.txt @@ -0,0 +1 @@ +7.2.83 diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs new file mode 100644 index 0000000..a71876b --- /dev/null +++ b/scripts/prepare-portable.mjs @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); +} + +const root = resolve(import.meta.dirname, '..'); +const output = resolve(args.get('--output') ?? join(root, 'bin-work')); +const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui')); +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 cleanCore = args.get('--clean-core') === 'true'; + +if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`); +if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`); + +const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim(); +if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`); +const version = rawVersion.replace(/^v/i, ''); +const extension = targetOS === 'windows' ? 'zip' : 'tar.gz'; +const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`; +const sourceDir = join(root, 'cpa-core'); +const sourceArchive = join(sourceDir, assetName); +const checksumsPath = join(sourceDir, 'checksums.txt'); + +await mkdir(sourceDir, { recursive: true }); +if (!existsSync(sourceArchive)) { + if (!shouldDownload) { + console.warn(`Built-in core archive not found; portable output keeps the existing core: ${sourceArchive}`); + await mkdir(output, { recursive: true }); + const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui'); + await copyFile(binary, outputBinary); + if (targetOS !== 'windows') await chmod(outputBinary, 0o755); + await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt')); + process.exit(0); + } + const tag = `v${version}`; + const releaseBase = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${tag}`; + const [archiveResponse, checksumsResponse] = await Promise.all([ + fetch(`${releaseBase}/${assetName}`), + fetch(`${releaseBase}/checksums.txt`), + ]); + if (!archiveResponse.ok) throw new Error(`Download ${assetName} failed: HTTP ${archiveResponse.status}`); + if (!checksumsResponse.ok) throw new Error(`Download checksums.txt failed: HTTP ${checksumsResponse.status}`); + await writeFile(sourceArchive, Buffer.from(await archiveResponse.arrayBuffer())); + await writeFile(checksumsPath, await checksumsResponse.text()); +} + +if (existsSync(checksumsPath)) { + const checksums = await readFile(checksumsPath, 'utf8'); + const expected = checksums.split(/\r?\n/).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1]?.replace(/^\*/, '') === assetName)?.[0]?.toLowerCase(); + if (!expected) throw new Error(`checksums.txt does not contain ${assetName}`); + const actual = createHash('sha256').update(await readFile(sourceArchive)).digest('hex'); + if (actual !== expected) throw new Error(`SHA-256 mismatch for ${assetName}`); +} + +await mkdir(output, { recursive: true }); +const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui'); +await copyFile(binary, outputBinary); +if (targetOS !== 'windows') await chmod(outputBinary, 0o755); +await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt')); + +const coreOutput = join(output, 'cpa-core'); +if (cleanCore) await rm(coreOutput, { recursive: true, force: true }); +await mkdir(coreOutput, { recursive: true }); +const extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' }); +if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName}`); +await copyFile(sourceArchive, join(coreOutput, assetName)); +if (existsSync(checksumsPath)) await copyFile(checksumsPath, join(coreOutput, 'checksums.txt')); +await writeFile(join(coreOutput, 'cpa-gui-meta.json'), `${JSON.stringify({ + version: `v${version}`, + assetName, + installedAtUnix: Math.floor(Date.now() / 1000), +}, null, 2)}\n`); + +console.log(`Prepared portable directory: ${output}`); +console.log(`Bundled core: ${basename(sourceArchive)}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 43d989b..09cd986 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -342,8 +354,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -417,11 +431,13 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" name = "cpa-gui" version = "0.1.0" dependencies = [ + "chrono", "flate2", "futures-util", "json5", "libc", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "serde_norway", @@ -807,6 +823,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1284,6 +1312,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1291,6 +1322,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1784,6 +1824,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2736,6 +2787,20 @@ dependencies = [ "text-size", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4098,6 +4163,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4892,6 +4963,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 79fcd62..a05c182 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,10 +9,12 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] +chrono = { version = "0.4", features = ["serde"] } flate2 = "1" futures-util = "0.3" json5 = "0.4" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_norway = "0.9" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cb1a870..c249b9c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod usage; + use flate2::read::GzDecoder; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; @@ -7,7 +9,7 @@ use sha2::{Digest, Sha256}; use std::{ env, fs, fs::File, - io::{self, Write}, + io::{self, Read, Write}, net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket}, path::{Component, Path, PathBuf}, process::{Child, Command, Stdio}, @@ -28,6 +30,8 @@ const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; const CORE_METADATA_FILE: &str = "cpa-gui-meta.json"; const CORE_CONFIG_FILE: &str = "config.yaml"; const CORE_EXAMPLE_CONFIG_FILE: &str = "config.example.yaml"; +const CORE_VERSION_FILE: &str = "core-version.txt"; +const CORE_CHECKSUMS_FILE: &str = "checksums.txt"; const GUI_CONFIG_FILE: &str = "config.toml"; const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml"; const OAUTH_DIR_NAME: &str = "oauth"; @@ -100,6 +104,14 @@ struct CoreLatest { asset_name: String, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct BundledCoreInfo { + version: String, + asset_name: String, + size_bytes: u64, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct CoreInstallResult { @@ -393,7 +405,12 @@ impl AgentClient { } fn supported_platform(self) -> bool { - self != Self::ClaudeDesktop || cfg!(any(target_os = "windows", target_os = "macos")) + self != Self::ClaudeDesktop + || cfg!(any( + target_os = "windows", + target_os = "macos", + target_os = "linux" + )) } fn executable_names(self) -> &'static [&'static str] { @@ -1002,7 +1019,14 @@ async fn set_agent_config_enabled( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - enable_agent_modification(client, &home, port, &model, codex_models.as_deref()) + enable_agent_modification( + client, + &home, + port, + &model, + &available_models, + codex_models.as_deref(), + ) } else { let _guard = AGENT_CONFIG_FILE_LOCK .lock() @@ -1035,12 +1059,20 @@ async fn update_agent_config( let _guard = AGENT_CONFIG_FILE_LOCK .lock() .map_err(|_| "智能体配置文件锁已损坏".to_string())?; - update_agent_modification(client, &home, port, &model, codex_models.as_deref()) + update_agent_modification( + client, + &home, + port, + &model, + &available_models, + codex_models.as_deref(), + ) } #[tauri::command] fn launch_agent( app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, client: String, target: Option, ) -> Result<(), String> { @@ -1052,12 +1084,25 @@ fn launch_agent( .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; + let port = gui_config_state.snapshot()?.port; + let status = inspect_agent_config(client, &home, port); + validate_agent_launch_modification( + client, + status.modification_enabled, + &status.modification_state, + )?; let requested_target = target .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); if client == AgentClient::Codex && requested_target != Some("cli") { + #[cfg(any(target_os = "macos", target_os = "windows"))] + if let Some(codex_cli) = find_agent_executable(AgentClient::Codex, &home) { + if launch_codex_app_via_cli(&codex_cli, &home).is_ok() { + return Ok(()); + } + } if let Some(app_target) = find_codex_app_target(&home) { return launch_codex_app(&app_target); } @@ -1087,10 +1132,24 @@ fn launch_agent( } } +fn validate_agent_launch_modification( + client: AgentClient, + enabled: bool, + state: &str, +) -> Result<(), String> { + if enabled && state == AGENT_PHASE_ACTIVE { + return Ok(()); + } + Err(format!( + "请先为 {} 开启“修改智能体配置”,确保 CPA 配置生效后再启动", + client.name() + )) +} + fn validate_agent_can_enable(client: AgentClient, home: &Path, port: u16) -> Result<(), String> { if !client.supported_platform() { return Err(format!( - "{} 当前仅支持在 Windows 和 macOS 上配置", + "{} 当前仅支持在 Windows、macOS 和 Linux 上配置", client.name() )); } @@ -1405,10 +1464,18 @@ fn claude_desktop_config_paths(_home: &Path) -> Vec { .unwrap_or_else(|| _home.join("AppData/Local")); (local.join("Claude"), local.join("Claude-3p")) }; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + let (normal, threep) = { + let config_home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| _home.join(".config")); + (config_home.join("Claude"), config_home.join("Claude-3p")) + }; + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] return Vec::new(); - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { let library = threep.join("configLibrary"); vec![ @@ -1600,7 +1667,14 @@ fn agent_has_managed_marker(client: AgentClient, paths: &[PathBuf]) -> Result Vec { let mut targets = Vec::new(); if client == AgentClient::Codex { - if let Some(target) = find_codex_app_target(home) { + #[cfg(any(target_os = "macos", target_os = "windows"))] + if let Some(executable) = find_agent_executable(AgentClient::Codex, home) { + targets.push(AgentLaunchTarget { + id: "app".to_string(), + label: "Codex App".to_string(), + detail: format!("{} app", path_to_string(&executable)), + }); + } else if let Some(target) = find_codex_app_target(home) { targets.push(AgentLaunchTarget { id: "app".to_string(), label: "Codex App".to_string(), @@ -1635,6 +1709,28 @@ fn codex_app_target_detail(target: &CodexAppTarget) -> String { } } +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn launch_codex_app_via_cli(executable: &Path, home: &Path) -> Result<(), String> { + let mut command = Command::new(executable); + command + .arg("app") + .arg(home) + .current_dir(home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command + .spawn() + .map(|_| ()) + .map_err(|error| format!("通过 Codex CLI 启动 Codex App 失败: {error}")) +} + fn find_codex_app_target(home: &Path) -> Option { #[cfg(target_os = "macos")] { @@ -1864,7 +1960,20 @@ fn find_claude_desktop_executable(home: &Path) -> Option { .into_iter() .find(|path| path.is_file()); } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + let mut candidates = agent_executable_directories(home) + .into_iter() + .map(|directory| directory.join("claude-desktop")) + .collect::>(); + candidates.extend([ + PathBuf::from("/opt/Claude/claude-desktop"), + PathBuf::from("/opt/Claude/claude"), + PathBuf::from("/opt/claude-desktop/claude-desktop"), + ]); + return candidates.into_iter().find(|path| path.is_file()); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { let _ = home; None @@ -1900,13 +2009,16 @@ fn launch_desktop_agent(executable: &Path, label: &str) -> Result<(), String> { #[cfg(target_os = "windows")] let mut command = Command::new(executable); - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + let mut command = Command::new(executable); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { let _ = (executable, label); return Err("当前平台不支持桌面智能体".to_string()); } - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] command .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -2270,6 +2382,7 @@ fn build_agent_updates( home: &Path, port: u16, model: &str, + models: &[AgentModelOption], codex_models: Option<&[CodexModelCatalogSpec]>, ) -> Result, String> { let paths = agent_config_paths(client, home); @@ -2309,6 +2422,7 @@ fn build_agent_updates( &root_base, DEFAULT_API_KEY, model, + models, )?, }, AgentFileUpdate { @@ -2340,6 +2454,7 @@ fn build_agent_updates( &openai_base, DEFAULT_API_KEY, model, + models, )?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), @@ -2353,6 +2468,7 @@ fn build_agent_updates( &openai_base, DEFAULT_API_KEY, model, + models, )?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), @@ -2361,8 +2477,13 @@ fn build_agent_updates( } AgentClient::Hermes => { let before = read_optional_text(&paths[0])?; - let after = - build_hermes_agent_config(before.as_deref(), &openai_base, DEFAULT_API_KEY, model)?; + let after = build_hermes_agent_config( + before.as_deref(), + &openai_base, + DEFAULT_API_KEY, + model, + models, + )?; Ok(vec![AgentFileUpdate { path: paths[0].clone(), after, @@ -2407,6 +2528,7 @@ fn build_claude_agent_config( ("ANTHROPIC_DEFAULT_HAIKU_MODEL", model), ("ANTHROPIC_DEFAULT_SONNET_MODEL", model), ("ANTHROPIC_DEFAULT_OPUS_MODEL", model), + ("ANTHROPIC_DEFAULT_FABLE_MODEL", model), ] { env.insert( key.to_string(), @@ -2448,6 +2570,34 @@ fn render_agent_json( Ok(rendered) } +fn ordered_agent_models( + models: &[AgentModelOption], + selected_model: &str, +) -> Vec { + let mut ordered = Vec::with_capacity(models.len().max(1)); + if let Some(selected) = models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(selected_model)) + { + ordered.push(selected.clone()); + } else { + ordered.push(AgentModelOption { + name: selected_model.to_string(), + alias: None, + }); + } + for model in models { + if ordered + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(&model.name)) + { + continue; + } + ordered.push(model.clone()); + } + ordered +} + fn build_claude_desktop_deployment_config(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置")?; root.insert("deploymentMode".to_string(), serde_json::json!("3p")); @@ -2459,6 +2609,7 @@ fn build_claude_desktop_profile( base_url: &str, api_key: &str, model: &str, + available_models: &[AgentModelOption], ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; root.insert( @@ -2485,7 +2636,20 @@ fn build_claude_desktop_profile( "inferenceProvider".to_string(), serde_json::json!("gateway"), ); - root.insert("inferenceModels".to_string(), serde_json::json!([model])); + 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), + }) + .collect::>(); + root.insert( + "inferenceModels".to_string(), + serde_json::Value::Array(inference_models), + ); render_agent_json(root, "Claude Desktop 网关配置") } @@ -2636,6 +2800,7 @@ fn build_opencode_agent_config( base_url: &str, api_key: &str, model: &str, + available_models: &[AgentModelOption], ) -> Result { let mut root = parse_agent_json_object(existing, "OpenCode opencode.json")?; root.entry("$schema".to_string()) @@ -2645,8 +2810,13 @@ fn build_opencode_agent_config( .or_insert_with(|| serde_json::json!({})) .as_object_mut() .ok_or_else(|| "OpenCode provider 必须是对象".to_string())?; - let mut models = serde_json::Map::new(); - models.insert(model.to_string(), serde_json::json!({ "name": model })); + let models = ordered_agent_models(available_models, model) + .into_iter() + .map(|model| { + let display_name = model.alias.as_deref().unwrap_or(&model.name).to_string(); + (model.name, serde_json::json!({ "name": display_name })) + }) + .collect::>(); providers.insert( MANAGED_AGENT_PROVIDER_ID.to_string(), serde_json::json!({ @@ -2671,6 +2841,7 @@ fn build_openclaw_agent_config( base_url: &str, api_key: &str, model: &str, + available_models: &[AgentModelOption], ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => json5::from_str::(value) @@ -2680,6 +2851,7 @@ fn build_openclaw_agent_config( let root = root .as_object_mut() .ok_or_else(|| "OpenClaw openclaw.json 根节点必须是对象".to_string())?; + let ordered_models = ordered_agent_models(available_models, model); let models = root .entry("models".to_string()) .or_insert_with(|| serde_json::json!({})) @@ -2699,7 +2871,10 @@ fn build_openclaw_agent_config( "baseUrl": base_url, "apiKey": api_key, "api": "openai-completions", - "models": [{ "id": model, "name": model }] + "models": ordered_models.iter().map(|model| serde_json::json!({ + "id": model.name.clone(), + "name": model.alias.as_deref().unwrap_or(&model.name), + })).collect::>() }), ); let agents = root @@ -2721,6 +2896,22 @@ fn build_openclaw_agent_config( "primary".to_string(), serde_json::json!(format!("{MANAGED_AGENT_PROVIDER_ID}/{model}")), ); + let model_catalog = defaults + .entry("models".to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or_else(|| "OpenClaw agents.defaults.models 必须是对象".to_string())?; + let managed_prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/"); + model_catalog.retain(|name, _| !name.starts_with(&managed_prefix)); + for model in &ordered_models { + let name = format!("{MANAGED_AGENT_PROVIDER_ID}/{}", model.name); + let value = model + .alias + .as_deref() + .map(|alias| serde_json::json!({ "alias": alias })) + .unwrap_or_else(|| serde_json::json!({})); + model_catalog.insert(name, value); + } render_agent_json(root.clone(), "OpenClaw 配置") } @@ -2729,6 +2920,7 @@ fn build_hermes_agent_config( base_url: &str, api_key: &str, model: &str, + available_models: &[AgentModelOption], ) -> Result { let mut root = match existing.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_yaml::from_str::(value) @@ -2746,8 +2938,10 @@ fn build_hermes_agent_config( providers.retain(|provider| { provider.get("name").and_then(serde_yaml::Value::as_str) != Some(MANAGED_AGENT_PROVIDER_ID) }); - let mut provider_models = serde_json::Map::new(); - provider_models.insert(model.to_string(), serde_json::json!({})); + let provider_models = ordered_agent_models(available_models, model) + .into_iter() + .map(|model| (model.name, serde_json::json!({}))) + .collect::>(); providers.push( serde_yaml::to_value(serde_json::json!({ "name": MANAGED_AGENT_PROVIDER_ID, @@ -3054,6 +3248,10 @@ fn fresh_agent_contents( ) -> Result, String> { let root_base = format!("http://127.0.0.1:{port}"); let openai_base = format!("{root_base}/v1"); + let models = [AgentModelOption { + name: model.to_string(), + alias: None, + }]; match client { AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config( None, @@ -3064,7 +3262,7 @@ fn fresh_agent_contents( AgentClient::ClaudeDesktop => Ok(vec![ build_claude_desktop_deployment_config(None)?, build_claude_desktop_deployment_config(None)?, - build_claude_desktop_profile(None, &root_base, DEFAULT_API_KEY, model)?, + build_claude_desktop_profile(None, &root_base, DEFAULT_API_KEY, model, &models)?, build_claude_desktop_meta(None)?, ]), AgentClient::Codex => Ok(vec![build_codex_agent_config( @@ -3078,18 +3276,21 @@ fn fresh_agent_contents( &openai_base, DEFAULT_API_KEY, model, + &models, )?]), AgentClient::OpenClaw => Ok(vec![build_openclaw_agent_config( None, &openai_base, DEFAULT_API_KEY, model, + &models, )?]), AgentClient::Hermes => Ok(vec![build_hermes_agent_config( None, &openai_base, DEFAULT_API_KEY, model, + &models, )?]), } } @@ -3528,6 +3729,7 @@ fn enable_agent_modification( home: &Path, port: u16, model: &str, + models: &[AgentModelOption], codex_models: Option<&[CodexModelCatalogSpec]>, ) -> Result { let paths = agent_config_paths(client, home); @@ -3560,7 +3762,7 @@ fn enable_agent_modification( ); } - let updates = build_agent_updates(client, home, port, model, codex_models)?; + let updates = build_agent_updates(client, home, port, model, models, codex_models)?; let update_paths = updates .iter() .map(|update| update.path.clone()) @@ -3670,6 +3872,7 @@ fn update_agent_modification( home: &Path, port: u16, model: &str, + models: &[AgentModelOption], codex_models: Option<&[CodexModelCatalogSpec]>, ) -> Result { let paths = agent_config_paths(client, home); @@ -3699,7 +3902,7 @@ fn update_agent_modification( )); } - let updates = build_agent_updates(client, home, port, model, codex_models)?; + let updates = build_agent_updates(client, home, port, model, models, codex_models)?; let (mut next, backup_snapshots) = extend_agent_record_for_updates(&record, &updates)?; next.phase = AGENT_PHASE_APPLYING.to_string(); next.model = model.to_string(); @@ -4189,6 +4392,28 @@ async fn check_latest_core() -> Result { }) } +#[tauri::command] +fn detect_bundled_core() -> Result, String> { + bundled_core_archive().map(|value| value.map(|(info, _)| info)) +} + +#[tauri::command] +fn install_bundled_core( + window: tauri::Window, + state: tauri::State<'_, CoreDownloadState>, +) -> Result { + let (info, archive_path) = bundled_core_archive()? + .ok_or_else(|| "当前发行包没有匹配此系统架构的内置内核".to_string())?; + let token = CancellationToken::new(); + state.start(token, Some(info.version.clone()))?; + let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path); + if result.is_err() { + let _ = cleanup_core_work_dirs(); + } + state.finish(&window, result.clone()); + result +} + #[tauri::command] fn cancel_core_install(state: tauri::State<'_, CoreDownloadState>) { state.cancel(); @@ -4320,6 +4545,8 @@ async fn install_core_version_inner( .strip_prefix(&staging_dir) .map_err(|err| format!("计算内核二进制相对路径失败: {err}"))? .to_path_buf(); + preserve_core_runtime_files(&install_dir, &staging_dir)?; + preserve_bundled_core_assets(&install_dir, &staging_dir)?; write_core_metadata( &staging_dir, &CoreMetadata { @@ -4340,6 +4567,68 @@ async fn install_core_version_inner( }) } +fn install_bundled_core_inner( + window: &tauri::Window, + state: &CoreDownloadState, + info: &BundledCoreInfo, + archive_path: &Path, +) -> Result { + let platform = current_core_platform()?; + let install_dir = core_install_dir()?; + let base_dir = core_base_dir()?; + let staging_dir = base_dir.join("cpa-core.staging"); + let backup_dir = base_dir.join("cpa-core.backup"); + + if current_core_status(None, None)?.running { + return Err("CPA 内核正在运行,请先停止后再使用内置内核".to_string()); + } + + let archive_size = fs::metadata(archive_path) + .map_err(|error| format!("读取内置内核压缩包失败: {error}"))? + .len(); + state.progress(window, "校验内置内核", 0, Some(archive_size), false); + validate_bundled_core_checksum(archive_path)?; + reset_dir(&staging_dir)?; + state.progress( + window, + "解压内置内核", + archive_size, + Some(archive_size), + false, + ); + match platform.archive_kind.as_str() { + "tar.gz" => extract_tar_gz(archive_path, &staging_dir)?, + "zip" => extract_zip(archive_path, &staging_dir)?, + other => return Err(format!("不支持的内置压缩包类型: {other}")), + } + + let binary_path = find_core_binary(&staging_dir) + .ok_or_else(|| "内置压缩包中没有 CPA 内核二进制文件".to_string())?; + let binary_relative_path = binary_path + .strip_prefix(&staging_dir) + .map_err(|error| format!("计算内置内核二进制路径失败: {error}"))? + .to_path_buf(); + preserve_core_runtime_files(&install_dir, &staging_dir)?; + preserve_bundled_core_assets(&install_dir, &staging_dir)?; + preserve_selected_bundled_core_asset(archive_path, &staging_dir)?; + write_core_metadata( + &staging_dir, + &CoreMetadata { + version: info.version.clone(), + asset_name: info.asset_name.clone(), + installed_at_unix: unix_now(), + }, + )?; + replace_install_dir(&install_dir, &staging_dir, &backup_dir)?; + + Ok(CoreInstallResult { + version: info.version.clone(), + asset_name: info.asset_name.clone(), + install_dir: path_to_string(&install_dir), + binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))), + }) +} + async fn fetch_release( client: &reqwest::Client, version: Option<&str>, @@ -4554,12 +4843,7 @@ fn select_release_asset<'a>( release: &'a GithubRelease, platform: &CorePlatform, ) -> Result<&'a GithubAsset, String> { - let version = normalize_version(&release.tag_name); - let version = version.trim_start_matches('v'); - let expected_name = format!( - "CLIProxyAPI_{}_{}_{}.{}", - version, platform.asset_os, platform.asset_arch, platform.archive_kind - ); + let expected_name = core_release_asset_name(&release.tag_name, platform); let mut matches = release .assets .iter() @@ -4575,6 +4859,15 @@ fn select_release_asset<'a>( Ok(asset) } +fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String { + let version = normalize_version(version); + let version = version.trim_start_matches('v'); + format!( + "CLIProxyAPI_{}_{}_{}.{}", + version, platform.asset_os, platform.asset_arch, platform.archive_kind + ) +} + // Download progress and cancellation require the complete transfer context here. #[allow(clippy::too_many_arguments)] async fn download_asset( @@ -5050,15 +5343,29 @@ fn set_core_yaml_nested_value( } fn patch_core_api_keys_yaml(content: &str, api_keys: &[String]) -> Result { - let file = content - .parse::() + let parsed = serde_norway::from_str::(content) .map_err(|err| format!("解析内核配置失败: {err}"))?; - let document = file - .document() - .ok_or_else(|| "内核配置没有 YAML 文档".to_string())?; - clear_legacy_api_key_paths(&document); - - let content = file.to_string(); + let root = parsed + .as_mapping() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let has_legacy_api_keys = nested_yaml_value( + root, + &["auth", "providers", "config-api-key", "api-key-entries"], + ) + .is_some() + || nested_yaml_value(root, &["auth", "providers", "config-api-key", "api-keys"]).is_some(); + let content = if has_legacy_api_keys { + let file = content + .parse::() + .map_err(|err| format!("解析内核配置失败: {err}"))?; + let document = file + .document() + .ok_or_else(|| "内核配置没有 YAML 文档".to_string())?; + clear_legacy_api_key_paths(&document); + file.to_string() + } else { + content.to_string() + }; let block = render_core_api_keys_yaml(api_keys)?; let updated = replace_top_level_yaml_block(&content, "api-keys", &block); serde_norway::from_str::(&updated) @@ -5105,7 +5412,9 @@ fn replace_top_level_yaml_block(content: &str, key: &str, block: &str) -> String if value.is_empty() || value.starts_with('#') { for (next_start, next_end) in lines.iter().copied().skip(line_index + 1) { let next = yaml_line_content(content, (next_start, next_end)); - if next.chars().next().is_some_and(char::is_whitespace) { + if next.chars().next().is_some_and(char::is_whitespace) + || is_indentationless_yaml_sequence_item(next) + { replace_end = next_end; } else { break; @@ -5135,6 +5444,13 @@ fn replace_top_level_yaml_block(content: &str, key: &str, block: &str) -> String replace_yaml_range(content, insertion, insertion, block) } +fn is_indentationless_yaml_sequence_item(line: &str) -> bool { + let Some(rest) = line.strip_prefix('-') else { + return false; + }; + rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace) +} + fn yaml_line_ranges(content: &str) -> Vec<(usize, usize)> { let mut ranges = Vec::new(); let mut start = 0; @@ -6532,6 +6848,11 @@ fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result Result { .ok_or_else(|| format!("当前程序路径没有父目录: {}", path_to_string(&exe_path))) } +fn bundled_core_archive() -> Result, String> { + let platform = current_core_platform()?; + let base_dir = core_base_dir()?; + let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))]; + if let Some(project_root) = source_project_root(&base_dir) { + if project_root != base_dir { + locations.push(( + project_root.join(CORE_VERSION_FILE), + project_root.join("cpa-core"), + )); + } + } + + let configured_version = locations.iter().find_map(|(version_path, _)| { + fs::read_to_string(version_path) + .ok() + .map(|value| normalize_version(value.trim())) + .filter(|value| value != "v") + }); + if let Some(version) = configured_version { + let asset_name = core_release_asset_name(&version, &platform); + for (_, archive_dir) in &locations { + let archive_path = archive_dir.join(&asset_name); + if !archive_path.is_file() { + continue; + } + let size_bytes = fs::metadata(&archive_path) + .map_err(|error| format!("读取内置内核信息失败: {error}"))? + .len(); + return Ok(Some(( + BundledCoreInfo { + version, + asset_name, + size_bytes, + }, + archive_path, + ))); + } + return Ok(None); + } + + let suffix = format!( + "_{}_{}.{}", + platform.asset_os, platform.asset_arch, platform.archive_kind + ); + let mut matches = Vec::new(); + for (_, archive_dir) in &locations { + if !archive_dir.is_dir() { + continue; + } + for entry in fs::read_dir(archive_dir) + .map_err(|error| format!("读取内置内核目录失败: {error}"))? + .filter_map(Result::ok) + { + let name = entry.file_name().to_string_lossy().into_owned(); + if !entry.path().is_file() + || !name.starts_with("CLIProxyAPI_") + || !name.ends_with(&suffix) + || name.contains("_no-plugin") + { + continue; + } + let Some(version) = name + .strip_prefix("CLIProxyAPI_") + .and_then(|value| value.strip_suffix(&suffix)) + else { + continue; + }; + let version = normalize_version(version); + if matches.iter().any(|(existing, _, _)| existing == &version) { + continue; + } + matches.push((version, name, entry.path())); + } + } + matches.sort_by(|left, right| left.0.cmp(&right.0)); + if matches.len() > 1 { + return Err(format!( + "发现多个匹配当前平台的内置内核,请在 {} 中指定发行版本", + CORE_VERSION_FILE + )); + } + let Some((version, asset_name, archive_path)) = matches.pop() else { + return Ok(None); + }; + let size_bytes = fs::metadata(&archive_path) + .map_err(|error| format!("读取内置内核信息失败: {error}"))? + .len(); + Ok(Some(( + BundledCoreInfo { + version, + asset_name, + size_bytes, + }, + archive_path, + ))) +} + +fn source_project_root(start: &Path) -> Option { + start.ancestors().find_map(|directory| { + (directory.join("package.json").is_file() && directory.join("src-tauri").is_dir()) + .then(|| directory.to_path_buf()) + }) +} + +fn preserve_bundled_core_assets(source_dir: &Path, target_dir: &Path) -> Result<(), String> { + if !source_dir.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(source_dir) + .map_err(|error| format!("读取内置内核文件失败: {error}"))? + .filter_map(Result::ok) + { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let is_archive = name.starts_with("CLIProxyAPI_") + && (name.ends_with(".tar.gz") || name.ends_with(".zip")) + && !name.contains("_no-plugin"); + if !is_archive && name != CORE_CHECKSUMS_FILE { + continue; + } + fs::copy(&path, target_dir.join(&name)) + .map_err(|error| format!("保留内置内核文件 {name} 失败: {error}"))?; + } + Ok(()) +} + +fn preserve_selected_bundled_core_asset( + archive_path: &Path, + target_dir: &Path, +) -> Result<(), String> { + let archive_name = archive_path + .file_name() + .ok_or_else(|| "内置内核压缩包文件名无效".to_string())?; + fs::copy(archive_path, target_dir.join(archive_name)) + .map_err(|error| format!("保留所选内置内核压缩包失败: {error}"))?; + if let Some(source_dir) = archive_path.parent() { + let checksums = source_dir.join(CORE_CHECKSUMS_FILE); + if checksums.is_file() { + fs::copy(&checksums, target_dir.join(CORE_CHECKSUMS_FILE)) + .map_err(|error| format!("保留内置内核校验文件失败: {error}"))?; + } + } + Ok(()) +} + +fn preserve_core_runtime_files(source_dir: &Path, target_dir: &Path) -> Result<(), String> { + if !source_dir.is_dir() { + return Ok(()); + } + let source = source_dir.join(CORE_CONFIG_FILE); + if source.is_file() { + fs::copy(&source, target_dir.join(CORE_CONFIG_FILE)) + .map_err(|error| format!("保留内核配置文件失败: {error}"))?; + } + Ok(()) +} + +fn validate_bundled_core_checksum(archive_path: &Path) -> Result<(), String> { + let Some(directory) = archive_path.parent() else { + return Err("内置内核压缩包没有父目录".to_string()); + }; + let checksums_path = directory.join(CORE_CHECKSUMS_FILE); + if !checksums_path.is_file() { + return Ok(()); + } + let archive_name = archive_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "内置内核压缩包文件名无效".to_string())?; + let checksums = fs::read_to_string(&checksums_path) + .map_err(|error| format!("读取内置内核校验文件失败: {error}"))?; + let expected = checksums.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let digest = fields.next()?; + let name = fields.next()?.trim_start_matches('*'); + (name == archive_name && digest.len() == 64).then(|| digest.to_ascii_lowercase()) + }); + let Some(expected) = expected else { + return Err(format!("校验文件中没有 {archive_name} 的 SHA-256")); + }; + let actual = sha256_file(archive_path)?; + if actual != expected { + return Err("内置内核压缩包 SHA-256 校验失败".to_string()); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|error| format!("打开校验文件失败: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("读取校验文件失败: {error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + fn read_core_metadata(install_dir: &Path) -> Option { let metadata_path = install_dir.join(CORE_METADATA_FILE); let content = fs::read_to_string(metadata_path).ok()?; @@ -7415,8 +7943,13 @@ fn main() { let app = tauri::Builder::default() .manage(CoreDownloadState::default()) .manage(CoreProcessState::default()) + .manage(usage::UsageCollectorState::default()) .manage(GuiConfigState::new(gui_config)) .setup(|app| { + if let Err(error) = usage::initialize_usage_storage() { + eprintln!("初始化使用记录目录失败: {error}"); + } + usage::start_usage_collector(app.handle().clone()); let gui_config_state = app.state::(); let process_state = app.state::(); let config = gui_config_state.snapshot().map_err(io::Error::other)?; @@ -7460,9 +7993,15 @@ fn main() { submit_oauth_callback, open_external_url, check_latest_core, + detect_bundled_core, install_core_version, + install_bundled_core, cancel_core_install, get_core_install_task, + usage::get_usage_collector_status, + usage::get_usage_overview, + usage::get_usage_analysis, + usage::get_usage_events, start_core_process, stop_core_process, restart_core_process @@ -7472,6 +8011,7 @@ fn main() { app.run(|app_handle, event| { if matches!(event, tauri::RunEvent::Exit) { + usage::stop_usage_collector(app_handle); let process_state = app_handle.state::(); let gui_config_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); @@ -7496,15 +8036,18 @@ mod tests { path } - fn test_codex_models(names: &[&str]) -> Vec { - let models = names + fn test_agent_models(names: &[&str]) -> Vec { + names .iter() .map(|name| AgentModelOption { name: (*name).to_string(), alias: None, }) - .collect::>(); - merge_codex_model_catalog_specs(&models, &[]) + .collect() + } + + fn test_codex_models(names: &[&str]) -> Vec { + merge_codex_model_catalog_specs(&test_agent_models(names), &[]) } fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingAliasSource { @@ -7592,11 +8135,13 @@ mod tests { #[test] fn claude_desktop_config_builds_gateway_profile_and_index() { + let models = test_agent_models(&["claude-sonnet-test", "claude-opus-test"]); let profile = build_claude_desktop_profile( Some(r#"{"keep":true}"#), "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-sonnet-test", + &models, ) .unwrap(); let meta = @@ -7609,17 +8154,53 @@ 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!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); assert_eq!(meta["entries"].as_array().unwrap().len(), 2); } + #[cfg(target_os = "linux")] + #[test] + fn claude_desktop_uses_linux_beta_config_paths() { + let home = agent_test_home("claude-desktop-linux-paths"); + let config_home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(".config")); + let paths = claude_desktop_config_paths(&home); + + assert!(AgentClient::ClaudeDesktop.supported_platform()); + assert_eq!(paths.len(), 4); + assert_eq!( + paths[0], + config_home.join("Claude/claude_desktop_config.json") + ); + assert_eq!( + paths[1], + config_home.join("Claude-3p/claude_desktop_config.json") + ); + assert_eq!( + paths[2], + config_home + .join("Claude-3p/configLibrary") + .join(format!("{CLAUDE_DESKTOP_PROFILE_ID}.json")) + ); + assert_eq!( + paths[3], + config_home.join("Claude-3p/configLibrary/_meta.json") + ); + fs::remove_dir_all(home).unwrap(); + } + #[test] fn opencode_agent_config_preserves_other_providers() { + let models = test_agent_models(&["gpt-test", "deepseek-test"]); let rendered = build_opencode_agent_config( Some(r#"{"theme":"dark","provider":{"other":{"npm":"other"}}}"#), "http://127.0.0.1:8317/v1", DEFAULT_API_KEY, "gpt-test", + &models, ) .unwrap(); let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); @@ -7631,15 +8212,21 @@ mod tests { "http://127.0.0.1:8317/v1" ); assert_eq!(value["model"], "cpa-gui/gpt-test"); + assert!(value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["gpt-test"].is_object()); + assert!( + value["provider"][MANAGED_AGENT_PROVIDER_ID]["models"]["deepseek-test"].is_object() + ); } #[test] fn openclaw_agent_config_accepts_json5_and_preserves_unknown_fields() { + let models = test_agent_models(&["gpt-test", "deepseek-test"]); let rendered = build_openclaw_agent_config( Some("{ theme: 'dark', models: { mode: 'merge', providers: {} } }"), "http://127.0.0.1:8317/v1", DEFAULT_API_KEY, "gpt-test", + &models, ) .unwrap(); let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); @@ -7653,15 +8240,26 @@ mod tests { value["agents"]["defaults"]["model"]["primary"], "cpa-gui/gpt-test" ); + assert_eq!( + value["models"]["providers"][MANAGED_AGENT_PROVIDER_ID]["models"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert!(value["agents"]["defaults"]["models"]["cpa-gui/gpt-test"].is_object()); + assert!(value["agents"]["defaults"]["models"]["cpa-gui/deepseek-test"].is_object()); } #[test] fn hermes_agent_config_preserves_unknown_fields_and_uses_current_schema() { + let models = test_agent_models(&["gpt-test", "deepseek-test"]); let rendered = build_hermes_agent_config( Some("theme: dark\ncustom_providers:\n - name: other\n base_url: https://example.com\n"), "http://127.0.0.1:8317/v1", DEFAULT_API_KEY, "gpt-test", + &models, ) .unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&rendered).unwrap(); @@ -7675,6 +8273,7 @@ mod tests { assert_eq!(managed["api_mode"].as_str(), Some("chat_completions")); assert_eq!(managed["model"].as_str(), Some("gpt-test")); assert!(managed["models"]["gpt-test"].is_mapping()); + assert!(managed["models"]["deepseek-test"].is_mapping()); assert_eq!( value["model"]["provider"].as_str(), Some(MANAGED_AGENT_PROVIDER_ID) @@ -7925,10 +8524,17 @@ mod tests { let original = b"# original comment\napproval_policy = \"on-request\"\n"; fs::write(&path, original).unwrap(); - let models = test_codex_models(&["gpt-one", "gpt-two", "gpt-three"]); - let enabled = - enable_agent_modification(AgentClient::Codex, &home, 8317, "gpt-one", Some(&models)) - .unwrap(); + let available_models = test_agent_models(&["gpt-one", "gpt-two", "gpt-three"]); + let codex_models = test_codex_models(&["gpt-one", "gpt-two", "gpt-three"]); + let enabled = enable_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-one", + &available_models, + Some(&codex_models), + ) + .unwrap(); assert_eq!(enabled.outcome, "enabled"); let backup = agent_backup_path(&path).unwrap(); let state = agent_state_path(&[path.clone()]).unwrap(); @@ -7937,9 +8543,15 @@ mod tests { assert!(state.is_file()); assert!(catalog_path.is_file()); - let updated = - update_agent_modification(AgentClient::Codex, &home, 8317, "gpt-two", Some(&models)) - .unwrap(); + let updated = update_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-two", + &available_models, + Some(&codex_models), + ) + .unwrap(); assert_eq!(updated.outcome, "updated"); assert_eq!(fs::read(&backup).unwrap(), original); assert!(fs::read_to_string(&path).unwrap().contains("gpt-two")); @@ -7955,7 +8567,8 @@ mod tests { &home, 8317, "gpt-three", - Some(&models), + &available_models, + Some(&codex_models), ) .unwrap_err() .contains("其他程序修改")); @@ -7979,7 +8592,16 @@ mod tests { let home = agent_test_home("opencode-absent"); let path = home.join(".config/opencode/opencode.json"); - enable_agent_modification(AgentClient::OpenCode, &home, 8317, "gpt-test", None).unwrap(); + let models = test_agent_models(&["gpt-test"]); + enable_agent_modification( + AgentClient::OpenCode, + &home, + 8317, + "gpt-test", + &models, + None, + ) + .unwrap(); assert!(path.is_file()); assert!(!agent_backup_path(&path).unwrap().exists()); @@ -7998,13 +8620,15 @@ mod tests { let state_path = agent_state_path(&[path.clone()]).unwrap(); fs::create_dir(&state_path).unwrap(); - let models = test_codex_models(&["gpt-test"]); + let available_models = test_agent_models(&["gpt-test"]); + let codex_models = test_codex_models(&["gpt-test"]); assert!(enable_agent_modification( AgentClient::Codex, &home, 8317, "gpt-test", - Some(&models), + &available_models, + Some(&codex_models), ) .is_err()); assert_eq!(fs::read(&path).unwrap(), b"approval_policy = \"never\"\n"); @@ -8101,9 +8725,17 @@ mod tests { assert_eq!(record.files.len(), 1); write_agent_state(&agent_state_path(&[path.clone()]).unwrap(), &record).unwrap(); - let models = test_codex_models(&["gpt-new"]); - update_agent_modification(AgentClient::Codex, &home, 8317, "gpt-new", Some(&models)) - .unwrap(); + let available_models = test_agent_models(&["gpt-new"]); + let codex_models = test_codex_models(&["gpt-new"]); + update_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-new", + &available_models, + Some(&codex_models), + ) + .unwrap(); let upgraded = load_agent_record(AgentClient::Codex, &[path.clone()]) .unwrap() .unwrap(); @@ -8129,9 +8761,17 @@ mod tests { let path = home.join(".codex/config.toml"); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "approval_policy = \"never\"\n").unwrap(); - let models = test_codex_models(&["gpt-test"]); - enable_agent_modification(AgentClient::Codex, &home, 8317, "gpt-test", Some(&models)) - .unwrap(); + let available_models = test_agent_models(&["gpt-test"]); + let codex_models = test_codex_models(&["gpt-test"]); + enable_agent_modification( + AgentClient::Codex, + &home, + 8317, + "gpt-test", + &available_models, + Some(&codex_models), + ) + .unwrap(); let state_path = agent_state_path(&[path.clone()]).unwrap(); let mut record = load_agent_record(AgentClient::Codex, &[path]) @@ -8614,6 +9254,22 @@ mod tests { ); } + #[test] + fn runtime_api_key_patch_replaces_indentationless_core_sequence() { + let input = "host: 0.0.0.0\nport: 8317\nauth-dir: /tmp/oauth\napi-keys:\n- '123456'\ndebug: false\n"; + let rendered = patch_core_api_keys_yaml(input, &[DEFAULT_API_KEY.to_string()]) + .unwrap_or_else(|error| panic!("patch failed: {error}")); + let parsed = serde_norway::from_str::(&rendered) + .unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}")); + + assert_eq!( + core_config_settings_from_value(&parsed).unwrap().api_keys, + vec![DEFAULT_API_KEY] + ); + assert_eq!(rendered.matches("- '123456'").count(), 1, "{rendered}"); + assert!(rendered.contains("debug: false"), "{rendered}"); + } + #[test] fn yaml_edit_runtime_patch_migrates_legacy_api_key_entries() { let input = "auth:\n providers:\n config-api-key:\n api-key-entries:\n - api-key: first-key\n - key: second-key\nplugins:\n enabled: false\n"; @@ -8708,6 +9364,7 @@ mod tests { assert_eq!(document["api-keys"][1], "gui-key"); assert_eq!(document["plugins"]["enabled"], true, "{merged}"); assert_eq!(document["routing"]["strategy"], "fill-first"); + assert_eq!(document["usage-statistics-enabled"], true); assert_eq!(document["new-option"], serde_norway::Value::Bool(true)); assert_eq!( document["nested"]["keep"], @@ -8747,6 +9404,7 @@ mod tests { assert_eq!(document["api-keys"][0], DEFAULT_API_KEY, "{merged}"); assert_eq!(document["plugins"]["enabled"], false); assert_eq!(document["routing"]["strategy"], "round-robin"); + assert_eq!(document["usage-statistics-enabled"], true); } #[test] @@ -8784,6 +9442,132 @@ mod tests { ); } + #[test] + fn release_asset_names_cover_the_six_supported_gui_targets() { + let targets = [ + ("linux", "amd64", "tar.gz"), + ("linux", "aarch64", "tar.gz"), + ("darwin", "amd64", "tar.gz"), + ("darwin", "aarch64", "tar.gz"), + ("windows", "amd64", "zip"), + ("windows", "aarch64", "zip"), + ]; + for (os, arch, archive_kind) in targets { + let platform = CorePlatform { + os: os.to_string(), + arch: arch.to_string(), + asset_os: os.to_string(), + asset_arch: arch.to_string(), + archive_kind: archive_kind.to_string(), + }; + assert_eq!( + core_release_asset_name("v7.2.83", &platform), + format!("CLIProxyAPI_7.2.83_{os}_{arch}.{archive_kind}") + ); + } + } + + #[test] + fn agent_launch_requires_an_active_managed_configuration() { + assert!( + validate_agent_launch_modification(AgentClient::Codex, true, AGENT_PHASE_ACTIVE) + .is_ok() + ); + assert!( + validate_agent_launch_modification(AgentClient::Codex, false, AGENT_PHASE_ACTIVE) + .is_err() + ); + assert!( + validate_agent_launch_modification(AgentClient::Codex, true, AGENT_PHASE_CONFLICT) + .is_err() + ); + } + + #[test] + fn replacing_a_core_preserves_only_regular_bundled_assets() { + let root = agent_test_home("bundled-assets"); + let source = root.join("source"); + let target = root.join("target"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&target).unwrap(); + fs::write( + source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz"), + b"archive", + ) + .unwrap(); + fs::write( + source.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz"), + b"portable", + ) + .unwrap(); + fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap(); + + preserve_bundled_core_assets(&source, &target).unwrap(); + + assert!(target + .join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz") + .is_file()); + assert!(!target + .join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz") + .exists()); + assert!(target.join(CORE_CHECKSUMS_FILE).is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn replacing_a_core_preserves_the_existing_configuration_bytes() { + let root = agent_test_home("core-config-preserve"); + let source = root.join("source"); + let target = root.join("target"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&target).unwrap(); + let original = b"host: 127.0.0.1\n# keep this comment\ndebug: true\n"; + fs::write(source.join(CORE_CONFIG_FILE), original).unwrap(); + + preserve_core_runtime_files(&source, &target).unwrap(); + + assert_eq!(fs::read(target.join(CORE_CONFIG_FILE)).unwrap(), original); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn source_project_root_is_detected_from_the_portable_development_directory() { + let root = agent_test_home("bundled-source-root"); + fs::create_dir_all(root.join("src-tauri")).unwrap(); + fs::create_dir_all(root.join("bin-work")).unwrap(); + fs::write(root.join("package.json"), b"{}").unwrap(); + + assert_eq!( + source_project_root(&root.join("bin-work")), + Some(root.clone()) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn selected_source_archive_and_checksums_are_copied_into_the_installation() { + let root = agent_test_home("selected-bundled-asset"); + let source = root.join("source"); + let target = root.join("target"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&target).unwrap(); + let archive = source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz"); + fs::write(&archive, b"archive").unwrap(); + fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap(); + + preserve_selected_bundled_core_asset(&archive, &target).unwrap(); + + assert_eq!( + fs::read(target.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")).unwrap(), + b"archive" + ); + assert_eq!( + fs::read(target.join(CORE_CHECKSUMS_FILE)).unwrap(), + b"checksums" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn release_page_assets_parse_download_links_and_sha256() { let html = r#" diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs new file mode 100644 index 0000000..a51f72b --- /dev/null +++ b/src-tauri/src/usage.rs @@ -0,0 +1,1495 @@ +use super::{ + current_core_status, management_authorization, management_endpoint, management_http_client, + CoreProcessState, GuiConfigFile, GuiConfigState, +}; +use chrono::{DateTime, Local}; +use rusqlite::{ + params, params_from_iter, types::Value as SqlValue, Connection, OptionalExtension, Row, + Transaction, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +#[cfg(test)] +use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Mutex, + time::Duration, +}; +use tauri::{Emitter, Manager}; +use tokio_util::sync::CancellationToken; + +const USAGE_DIR_NAME: &str = "usage-records"; +const USAGE_DATABASE_FILE: &str = "usage.db"; +const LEGACY_USAGE_EVENTS_DIR: &str = "events"; +const LEGACY_USAGE_INBOX_DIR: &str = "inbox"; +const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1"; +const USAGE_UPDATED_EVENT: &str = "usage-records-updated"; +const USAGE_SCHEMA_VERSION: u8 = 1; +const USAGE_QUEUE_BATCH_SIZE: usize = 500; +const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5; + +pub(crate) struct UsageCollectorState { + inner: Mutex, +} + +struct UsageCollectorInner { + token: Option, + status: UsageCollectorStatus, +} + +impl Default for UsageCollectorState { + fn default() -> Self { + Self { + inner: Mutex::new(UsageCollectorInner { + token: None, + status: UsageCollectorStatus::waiting(), + }), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageCollectorStatus { + state: String, + message: String, + last_collected_at: Option, + total_records: u64, +} + +impl UsageCollectorStatus { + fn waiting() -> Self { + Self { + state: "waiting-core".to_string(), + message: "等待内核启动".to_string(), + last_collected_at: None, + total_records: 0, + } + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +struct UsageTokenStats { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + reasoning_tokens: u64, + #[serde(default)] + cache_read_tokens: u64, + #[serde(default)] + cache_creation_tokens: u64, + #[serde(default)] + total_tokens: u64, +} + +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct UsageRecord { + id: String, + timestamp: String, + #[serde(default)] + latency_ms: u64, + #[serde(default)] + ttft_ms: Option, + #[serde(default)] + source: String, + #[serde(default)] + auth_index: String, + #[serde(default)] + failed: bool, + #[serde(default)] + provider: String, + #[serde(default)] + model: String, + #[serde(default)] + alias: String, + #[serde(default)] + reasoning_effort: String, + #[serde(default)] + service_tier: String, + #[serde(default)] + response_service_tier: String, + #[serde(default)] + executor_type: String, + #[serde(default)] + endpoint: String, + #[serde(default)] + auth_type: String, + #[serde(default)] + api_key_hash: String, + #[serde(default)] + api_key_display: String, + #[serde(default)] + api_key_remark: String, + #[serde(default)] + request_id: String, + #[serde(default)] + tokens: UsageTokenStats, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyUsageHourFile { + schema_version: u8, + records: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyUsageInboxFile { + schema_version: u8, + records: Vec, +} + +#[derive(Clone, Default, Deserialize)] +pub(crate) struct UsageQuery { + #[serde(default)] + start: Option, + #[serde(default)] + end: Option, + #[serde(default)] + model: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + source: Option, + #[serde(default)] + api_key_hash: Option, + #[serde(default)] + failed: Option, + #[serde(default)] + page: Option, + #[serde(default)] + page_size: Option, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageOverview { + total_requests: u64, + success_count: u64, + failure_count: u64, + success_rate: f64, + input_tokens: u64, + output_tokens: u64, + reasoning_tokens: u64, + cache_read_tokens: u64, + cache_creation_tokens: u64, + total_tokens: u64, + rpm: f64, + tpm: f64, + average_latency_ms: f64, + timeline: Vec, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageTimelinePoint { + hour: String, + requests: u64, + success: u64, + failure: u64, + tokens: u64, +} + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageAnalysis { + models: Vec, + providers: Vec, + sources: Vec, + api_keys: Vec, +} + +#[derive(Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct UsageCategory { + key: String, + label: String, + requests: u64, + failures: u64, + tokens: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageEventPage { + items: Vec, + total: usize, + page: usize, + page_size: usize, + total_pages: usize, +} + +struct UsageSqlFilter { + clause: String, + params: Vec, +} + +impl UsageCollectorState { + fn start(&self) -> Option { + let mut inner = self.inner.lock().ok()?; + if inner.token.is_some() { + return None; + } + let token = CancellationToken::new(); + inner.token = Some(token.clone()); + Some(token) + } + + fn stop(&self) { + if let Ok(mut inner) = self.inner.lock() { + if let Some(token) = inner.token.take() { + token.cancel(); + } + } + } + + fn set_status(&self, status: UsageCollectorStatus) { + if let Ok(mut inner) = self.inner.lock() { + inner.status = status; + } + } + + fn status(&self) -> Result { + self.inner + .lock() + .map(|inner| inner.status.clone()) + .map_err(|_| "使用记录采集状态锁已损坏".to_string()) + } +} + +pub(crate) fn initialize_usage_storage() -> Result<(), String> { + initialize_usage_storage_at(&usage_root_dir()?) +} + +fn initialize_usage_storage_at(root: &Path) -> Result<(), String> { + fs::create_dir_all(root).map_err(|error| format!("创建使用记录目录失败: {error}"))?; + let mut connection = open_usage_database_at(root)?; + connection + .pragma_update(None, "journal_mode", "WAL") + .map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?; + initialize_usage_schema(&connection)?; + migrate_legacy_json_storage(&mut connection, root) +} + +fn initialize_usage_schema(connection: &Connection) -> Result<(), String> { + connection + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS usage_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + timestamp TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL, + local_hour TEXT NOT NULL, + latency_ms INTEGER NOT NULL DEFAULT 0, + ttft_ms INTEGER, + source TEXT NOT NULL DEFAULT '', + auth_index TEXT NOT NULL DEFAULT '', + failed INTEGER NOT NULL DEFAULT 0, + provider TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + alias TEXT NOT NULL DEFAULT '', + reasoning_effort TEXT NOT NULL DEFAULT '', + service_tier TEXT NOT NULL DEFAULT '', + response_service_tier TEXT NOT NULL DEFAULT '', + executor_type TEXT NOT NULL DEFAULT '', + endpoint TEXT NOT NULL DEFAULT '', + auth_type TEXT NOT NULL DEFAULT '', + api_key_hash TEXT NOT NULL DEFAULT '', + api_key_display TEXT NOT NULL DEFAULT '', + api_key_remark TEXT NOT NULL DEFAULT '', + request_id TEXT NOT NULL DEFAULT '', + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp + ON usage_events(timestamp_ms DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_local_hour + ON usage_events(local_hour, timestamp_ms DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_model_timestamp + ON usage_events(model, timestamp_ms DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_provider_timestamp + ON usage_events(provider, timestamp_ms DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_source_timestamp + ON usage_events(source, timestamp_ms DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_api_key_timestamp + ON usage_events(api_key_hash, timestamp_ms DESC); + CREATE INDEX IF NOT EXISTS idx_usage_events_failed_timestamp + ON usage_events(failed, timestamp_ms DESC); + + PRAGMA user_version = 1; + "#, + ) + .map_err(|error| format!("初始化 SQLite 使用记录结构失败: {error}")) +} + +fn open_usage_database() -> Result { + open_usage_database_at(&usage_root_dir()?) +} + +fn open_usage_database_at(root: &Path) -> Result { + fs::create_dir_all(root).map_err(|error| format!("创建使用记录目录失败: {error}"))?; + let path = root.join(USAGE_DATABASE_FILE); + let connection = Connection::open(&path) + .map_err(|error| format!("打开 SQLite 使用记录数据库失败 {}: {error}", path.display()))?; + connection + .busy_timeout(Duration::from_secs(SQLITE_BUSY_TIMEOUT_SECONDS)) + .map_err(|error| format!("设置 SQLite busy timeout 失败: {error}"))?; + connection + .pragma_update(None, "foreign_keys", "ON") + .map_err(|error| format!("启用 SQLite foreign keys 失败: {error}"))?; + connection + .pragma_update(None, "synchronous", "NORMAL") + .map_err(|error| format!("设置 SQLite synchronous 模式失败: {error}"))?; + Ok(connection) +} + +fn migrate_legacy_json_storage(connection: &mut Connection, root: &Path) -> Result<(), String> { + let migrated = connection + .query_row( + "SELECT value FROM usage_metadata WHERE key = ?1", + params![LEGACY_JSON_MIGRATION_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("读取旧使用记录迁移状态失败: {error}"))? + .is_some(); + if migrated { + return Ok(()); + } + + let transaction = connection + .transaction() + .map_err(|error| format!("开始旧使用记录迁移事务失败: {error}"))?; + let mut migrated_records = 0_usize; + + for path in sorted_json_files(&root.join(LEGACY_USAGE_EVENTS_DIR))? { + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取旧使用记录失败 {}: {error}", path.display()))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析旧使用记录失败 {}: {error}", path.display()))?; + validate_legacy_schema(file.schema_version, &path)?; + migrated_records = migrated_records.saturating_add(insert_usage_records_in_transaction( + &transaction, + &file.records, + )?); + } + + for path in sorted_json_files(&root.join(LEGACY_USAGE_INBOX_DIR))? { + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取旧使用记录收件箱失败 {}: {error}", path.display()))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析旧使用记录收件箱失败 {}: {error}", path.display()))?; + validate_legacy_schema(file.schema_version, &path)?; + migrated_records = migrated_records.saturating_add(insert_usage_records_in_transaction( + &transaction, + &file.records, + )?); + } + + transaction + .execute( + "INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)", + params![LEGACY_JSON_MIGRATION_KEY, migrated_records.to_string()], + ) + .map_err(|error| format!("记录旧使用记录迁移状态失败: {error}"))?; + transaction + .commit() + .map_err(|error| format!("提交旧使用记录迁移失败: {error}"))?; + Ok(()) +} + +fn sorted_json_files(directory: &Path) -> Result, String> { + if !directory.is_dir() { + return Ok(Vec::new()); + } + let mut paths = fs::read_dir(directory) + .map_err(|error| format!("读取旧使用记录目录失败 {}: {error}", directory.display()))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json")) + .collect::>(); + paths.sort(); + Ok(paths) +} + +fn validate_legacy_schema(version: u8, path: &Path) -> Result<(), String> { + if version == USAGE_SCHEMA_VERSION { + Ok(()) + } else { + Err(format!( + "不支持的旧使用记录版本 {version}: {}", + path.display() + )) + } +} + +pub(crate) fn start_usage_collector(app: tauri::AppHandle) { + let state = app.state::(); + let Some(token) = state.start() else { + return; + }; + tauri::async_runtime::spawn(async move { + usage_collector_loop(app, token).await; + }); +} + +pub(crate) fn stop_usage_collector(app: &tauri::AppHandle) { + app.state::().stop(); +} + +async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { + let root = match usage_root_dir() { + Ok(root) => root, + Err(error) => { + set_collector_error(&app, error); + return; + } + }; + + let mut retry_seconds = 1_u64; + loop { + if token.is_cancelled() { + return; + } + let config = match app.state::().snapshot() { + Ok(config) => config, + Err(error) => { + set_collector_error(&app, error); + wait_or_cancel(&token, retry_seconds).await; + retry_seconds = (retry_seconds * 2).min(10); + continue; + } + }; + let process_state = app.state::(); + let core_running = current_core_status(Some(process_state.inner()), Some(config.port)) + .map(|status| status.running) + .unwrap_or(false); + if !core_running { + set_collector_status(&app, "waiting-core", "等待内核启动", None); + retry_seconds = 1; + wait_or_cancel(&token, 1).await; + continue; + } + + match fetch_usage_queue(&config).await { + Ok(items) if items.is_empty() => { + set_collector_status(&app, "collecting", "使用记录采集中", None); + retry_seconds = 1; + wait_or_cancel(&token, 1).await; + } + Ok(items) => match persist_queue_items(&root, items, &config) { + Ok(saved) => { + let collected_at = Local::now().to_rfc3339(); + set_collector_status( + &app, + "collecting", + &format!("已保存 {saved} 条新记录"), + Some(collected_at.clone()), + ); + if saved > 0 { + let _ = app.emit(USAGE_UPDATED_EVENT, collected_at); + } + retry_seconds = 1; + } + Err(error) => { + set_collector_error(&app, error); + wait_or_cancel(&token, retry_seconds).await; + retry_seconds = (retry_seconds * 2).min(10); + } + }, + Err(error) => { + set_collector_error(&app, error); + wait_or_cancel(&token, retry_seconds).await; + retry_seconds = (retry_seconds * 2).min(10); + } + } + } +} + +async fn wait_or_cancel(token: &CancellationToken, seconds: u64) { + tokio::select! { + _ = token.cancelled() => {}, + _ = tokio::time::sleep(Duration::from_secs(seconds)) => {}, + } +} + +async fn fetch_usage_queue(config: &GuiConfigFile) -> Result, String> { + let client = management_http_client()?; + let response = client + .get(management_endpoint(config, "usage-queue")?) + .header("Authorization", management_authorization(config)?) + .query(&[("count", USAGE_QUEUE_BATCH_SIZE)]) + .send() + .await + .map_err(|error| format!("读取 CPA 使用记录队列失败: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("读取 CPA 使用记录响应失败: {error}"))?; + if !status.is_success() { + return Err(format!( + "CPA 使用记录队列返回 HTTP {}: {}", + status.as_u16(), + text.trim() + )); + } + serde_json::from_str::>(&text) + .map_err(|error| format!("解析 CPA 使用记录失败: {error}")) +} + +fn set_collector_error(app: &tauri::AppHandle, error: String) { + set_collector_status(app, "error", &error, None); +} + +fn set_collector_status( + app: &tauri::AppHandle, + state_name: &str, + message: &str, + last_collected_at: Option, +) { + let state = app.state::(); + let previous = state.status().ok(); + let total_records = total_usage_records() + .ok() + .or_else(|| previous.as_ref().map(|value| value.total_records)) + .unwrap_or(0); + state.set_status(UsageCollectorStatus { + state: state_name.to_string(), + message: message.to_string(), + last_collected_at: last_collected_at + .or_else(|| previous.and_then(|value| value.last_collected_at)), + total_records, + }); +} + +fn persist_queue_items( + root: &Path, + items: Vec, + config: &GuiConfigFile, +) -> Result { + let records = items + .into_iter() + .filter(Value::is_object) + .filter(is_generated_usage_event) + .map(|item| normalize_usage_record(item, config)) + .collect::, _>>()?; + if records.is_empty() { + return Ok(0); + } + let mut connection = open_usage_database_at(root)?; + insert_usage_records(&mut connection, &records) +} + +fn insert_usage_records( + connection: &mut Connection, + records: &[UsageRecord], +) -> Result { + let transaction = connection + .transaction() + .map_err(|error| format!("开始 SQLite 使用记录事务失败: {error}"))?; + let inserted = insert_usage_records_in_transaction(&transaction, records)?; + transaction + .commit() + .map_err(|error| format!("提交 SQLite 使用记录失败: {error}"))?; + Ok(inserted) +} + +fn insert_usage_records_in_transaction( + transaction: &Transaction<'_>, + records: &[UsageRecord], +) -> Result { + if records.is_empty() { + return Ok(0); + } + let mut statement = transaction + .prepare( + r#" + INSERT OR IGNORE INTO usage_events ( + event_key, timestamp, timestamp_ms, local_hour, latency_ms, ttft_ms, + source, auth_index, failed, provider, model, alias, reasoning_effort, + service_tier, response_service_tier, executor_type, endpoint, auth_type, + api_key_hash, api_key_display, api_key_remark, request_id, + input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, + cache_creation_tokens, total_tokens, created_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, + ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29 + ) + "#, + ) + .map_err(|error| format!("准备 SQLite 使用记录写入失败: {error}"))?; + let created_at = Local::now().to_rfc3339(); + let mut inserted = 0_usize; + for record in records { + inserted = inserted.saturating_add( + statement + .execute(params![ + record.id, + record.timestamp, + record_timestamp_millis(record), + record_local_hour(record), + to_sql_i64(record.latency_ms), + record.ttft_ms.map(to_sql_i64), + record.source, + record.auth_index, + record.failed, + record.provider, + record.model, + record.alias, + record.reasoning_effort, + record.service_tier, + record.response_service_tier, + record.executor_type, + record.endpoint, + record.auth_type, + record.api_key_hash, + record.api_key_display, + record.api_key_remark, + record.request_id, + to_sql_i64(record.tokens.input_tokens), + to_sql_i64(record.tokens.output_tokens), + to_sql_i64(record.tokens.reasoning_tokens), + to_sql_i64(record.tokens.cache_read_tokens), + to_sql_i64(record.tokens.cache_creation_tokens), + to_sql_i64(record.tokens.total_tokens), + created_at, + ]) + .map_err(|error| format!("写入 SQLite 使用记录失败: {error}"))?, + ); + } + Ok(inserted) +} + +fn is_generated_usage_event(value: &Value) -> bool { + if value.get("generate").and_then(Value::as_bool) == Some(false) { + return false; + } + let legacy_websocket_prewarm = value + .get("executor_type") + .and_then(Value::as_str) + .is_some_and(|value| value.trim() == "CodexWebsocketsExecutor") + && value + .get("tokens") + .and_then(Value::as_object) + .is_some_and(|tokens| { + [ + "input_tokens", + "output_tokens", + "reasoning_tokens", + "cached_tokens", + "cache_read_tokens", + "cache_creation_tokens", + "total_tokens", + ] + .into_iter() + .all(|key| u64_field(tokens, key) == 0) + }); + !legacy_websocket_prewarm +} + +fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result { + let object = value + .as_object() + .ok_or_else(|| "CPA 使用记录必须是 JSON 对象".to_string())?; + let timestamp = string_field(object, "timestamp") + .filter(|value| DateTime::parse_from_rfc3339(value).is_ok()) + .unwrap_or_else(|| Local::now().to_rfc3339()); + let request_id = string_field(object, "request_id").unwrap_or_default(); + let api_key = string_field(object, "api_key").unwrap_or_default(); + let api_key_hash = hash_text(&api_key); + let api_key_remark = config + .api_keys + .iter() + .find(|entry| entry.key == api_key) + .map(|entry| entry.remark.clone()) + .unwrap_or_default(); + let tokens_object = object.get("tokens").and_then(Value::as_object); + let mut tokens = UsageTokenStats { + input_tokens: token_u64(tokens_object, "input_tokens"), + output_tokens: token_u64(tokens_object, "output_tokens"), + reasoning_tokens: token_u64(tokens_object, "reasoning_tokens"), + cache_read_tokens: token_u64(tokens_object, "cache_read_tokens") + .max(token_u64(tokens_object, "cached_tokens")), + cache_creation_tokens: token_u64(tokens_object, "cache_creation_tokens"), + total_tokens: token_u64(tokens_object, "total_tokens"), + }; + if tokens.total_tokens == 0 { + tokens.total_tokens = tokens.input_tokens.saturating_add(tokens.output_tokens); + } + let mut canonical = object.clone(); + canonical.remove("response_headers"); + canonical.remove("api_key"); + let id = if request_id.is_empty() { + hash_text(&serde_json::to_string(&canonical).unwrap_or_default()) + } else { + request_id.clone() + }; + Ok(UsageRecord { + id, + timestamp, + latency_ms: u64_field(object, "latency_ms"), + ttft_ms: optional_u64_field(object, "ttft_ms"), + source: string_field(object, "source").unwrap_or_default(), + auth_index: string_field(object, "auth_index").unwrap_or_default(), + failed: object + .get("failed") + .and_then(Value::as_bool) + .unwrap_or(false), + provider: string_field(object, "provider").unwrap_or_default(), + model: string_field(object, "model").unwrap_or_else(|| "unknown".to_string()), + alias: string_field(object, "alias").unwrap_or_default(), + reasoning_effort: string_field(object, "reasoning_effort").unwrap_or_default(), + service_tier: string_field(object, "service_tier").unwrap_or_default(), + response_service_tier: string_field(object, "response_service_tier").unwrap_or_default(), + executor_type: string_field(object, "executor_type").unwrap_or_default(), + endpoint: string_field(object, "endpoint").unwrap_or_default(), + auth_type: string_field(object, "auth_type").unwrap_or_default(), + api_key_hash, + api_key_display: mask_api_key(&api_key), + api_key_remark, + request_id, + tokens, + }) +} + +fn build_usage_filter(query: &UsageQuery) -> UsageSqlFilter { + let mut clauses = Vec::::new(); + let mut params = Vec::::new(); + if let Some(start) = query.start.as_deref().and_then(parse_timestamp_millis) { + clauses.push("timestamp_ms >= ?".to_string()); + params.push(SqlValue::Integer(start)); + } + if let Some(end) = query.end.as_deref().and_then(parse_timestamp_millis) { + clauses.push("timestamp_ms <= ?".to_string()); + params.push(SqlValue::Integer(end)); + } + add_text_filter(&mut clauses, &mut params, "model", query.model.as_deref()); + add_text_filter( + &mut clauses, + &mut params, + "provider", + query.provider.as_deref(), + ); + add_text_filter(&mut clauses, &mut params, "source", query.source.as_deref()); + add_text_filter( + &mut clauses, + &mut params, + "api_key_hash", + query.api_key_hash.as_deref(), + ); + if let Some(failed) = query.failed { + clauses.push("failed = ?".to_string()); + params.push(SqlValue::Integer(i64::from(failed))); + } + UsageSqlFilter { + clause: if clauses.is_empty() { + String::new() + } else { + format!(" WHERE {}", clauses.join(" AND ")) + }, + params, + } +} + +fn add_text_filter( + clauses: &mut Vec, + params: &mut Vec, + column: &str, + value: Option<&str>, +) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + clauses.push(format!("{column} = ? COLLATE NOCASE")); + params.push(SqlValue::Text(value.to_string())); + } +} + +#[tauri::command] +pub(crate) fn get_usage_collector_status( + state: tauri::State<'_, UsageCollectorState>, +) -> Result { + state.status() +} + +#[tauri::command] +pub(crate) fn get_usage_overview(query: UsageQuery) -> Result { + let connection = open_usage_database()?; + load_usage_overview(&connection, &query) +} + +fn load_usage_overview( + connection: &Connection, + query: &UsageQuery, +) -> Result { + let filter = build_usage_filter(query); + let summary_sql = format!( + r#" + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(reasoning_tokens), 0), + COALESCE(SUM(cache_read_tokens), 0), + COALESCE(SUM(cache_creation_tokens), 0), + COALESCE(SUM(total_tokens), 0), + COALESCE(SUM(latency_ms), 0), + MIN(timestamp_ms), + MAX(timestamp_ms) + FROM usage_events{} + "#, + filter.clause + ); + let summary = connection + .query_row( + &summary_sql, + params_from_iter(filter.params.iter()), + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }, + ) + .map_err(|error| format!("统计 SQLite 使用记录失败: {error}"))?; + + let timeline_sql = format!( + r#" + SELECT + local_hour, + COUNT(*), + COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(total_tokens), 0) + FROM usage_events{} + GROUP BY local_hour + ORDER BY local_hour ASC + "#, + filter.clause + ); + let mut statement = connection + .prepare(&timeline_sql) + .map_err(|error| format!("准备 SQLite 使用趋势查询失败: {error}"))?; + let timeline = statement + .query_map(params_from_iter(filter.params.iter()), |row| { + Ok(UsageTimelinePoint { + hour: row.get(0)?, + requests: from_sql_i64(row.get(1)?), + success: from_sql_i64(row.get(2)?), + failure: from_sql_i64(row.get(3)?), + tokens: from_sql_i64(row.get(4)?), + }) + }) + .map_err(|error| format!("查询 SQLite 使用趋势失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取 SQLite 使用趋势失败: {error}"))?; + + let mut overview = UsageOverview { + total_requests: from_sql_i64(summary.0), + success_count: from_sql_i64(summary.1), + failure_count: from_sql_i64(summary.2), + input_tokens: from_sql_i64(summary.3), + output_tokens: from_sql_i64(summary.4), + reasoning_tokens: from_sql_i64(summary.5), + cache_read_tokens: from_sql_i64(summary.6), + cache_creation_tokens: from_sql_i64(summary.7), + total_tokens: from_sql_i64(summary.8), + timeline, + ..UsageOverview::default() + }; + if overview.total_requests > 0 { + overview.success_rate = + overview.success_count as f64 * 100.0 / overview.total_requests as f64; + overview.average_latency_ms = + from_sql_i64(summary.9) as f64 / overview.total_requests as f64; + let minutes = query_window_minutes(query, summary.10, summary.11); + overview.rpm = overview.total_requests as f64 / minutes; + overview.tpm = overview.total_tokens as f64 / minutes; + } + Ok(overview) +} + +#[tauri::command] +pub(crate) fn get_usage_analysis(query: UsageQuery) -> Result { + let connection = open_usage_database()?; + load_usage_analysis(&connection, &query) +} + +fn load_usage_analysis( + connection: &Connection, + query: &UsageQuery, +) -> Result { + Ok(UsageAnalysis { + models: load_simple_categories(connection, query, "model", "unknown")?, + providers: load_simple_categories(connection, query, "provider", "未知 Provider")?, + sources: load_simple_categories(connection, query, "source", "未知来源")?, + api_keys: load_api_key_categories(connection, query)?, + }) +} + +fn load_simple_categories( + connection: &Connection, + query: &UsageQuery, + column: &str, + fallback: &str, +) -> Result, String> { + let filter = build_usage_filter(query); + let sql = format!( + r#" + SELECT + COALESCE(NULLIF(TRIM({column}), ''), ?), + COUNT(*), + COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(total_tokens), 0) + FROM usage_events{} + GROUP BY 1 + ORDER BY 4 DESC, 2 DESC + "#, + filter.clause + ); + let mut values = Vec::with_capacity(filter.params.len() + 1); + values.push(SqlValue::Text(fallback.to_string())); + values.extend(filter.params); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("准备 SQLite 使用分析查询失败: {error}"))?; + let categories = statement + .query_map(params_from_iter(values.iter()), |row| { + let key = row.get::<_, String>(0)?; + Ok(UsageCategory { + label: key.clone(), + key, + requests: from_sql_i64(row.get(1)?), + failures: from_sql_i64(row.get(2)?), + tokens: from_sql_i64(row.get(3)?), + }) + }) + .map_err(|error| format!("查询 SQLite 使用分析失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取 SQLite 使用分析失败: {error}"))?; + Ok(categories) +} + +fn load_api_key_categories( + connection: &Connection, + query: &UsageQuery, +) -> Result, String> { + let filter = build_usage_filter(query); + let sql = format!( + r#" + SELECT + COALESCE(NULLIF(TRIM(api_key_hash), ''), '未记录密钥'), + MAX(TRIM(api_key_remark)), + MAX(TRIM(api_key_display)), + COUNT(*), + COALESCE(SUM(CASE WHEN failed != 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(total_tokens), 0) + FROM usage_events{} + GROUP BY 1 + ORDER BY 6 DESC, 4 DESC + "#, + filter.clause + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("准备 SQLite API Key 使用分析查询失败: {error}"))?; + let categories = statement + .query_map(params_from_iter(filter.params.iter()), |row| { + let key = row.get::<_, String>(0)?; + let remark = row.get::<_, String>(1)?; + let display = row.get::<_, String>(2)?; + let label = if !remark.is_empty() { + if display.is_empty() { + remark + } else { + format!("{remark} · {display}") + } + } else if !display.is_empty() { + display + } else { + "未记录密钥".to_string() + }; + Ok(UsageCategory { + key, + label, + requests: from_sql_i64(row.get(3)?), + failures: from_sql_i64(row.get(4)?), + tokens: from_sql_i64(row.get(5)?), + }) + }) + .map_err(|error| format!("查询 SQLite API Key 使用分析失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取 SQLite API Key 使用分析失败: {error}"))?; + Ok(categories) +} + +#[tauri::command] +pub(crate) fn get_usage_events(query: UsageQuery) -> Result { + let connection = open_usage_database()?; + load_usage_events(&connection, &query) +} + +fn load_usage_events( + connection: &Connection, + query: &UsageQuery, +) -> Result { + let filter = build_usage_filter(query); + let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause); + let total = connection + .query_row(&total_sql, params_from_iter(filter.params.iter()), |row| { + row.get::<_, i64>(0) + }) + .map(from_sql_i64) + .map_err(|error| format!("统计 SQLite 使用事件失败: {error}"))? + .min(usize::MAX as u64) as usize; + let page_size = query.page_size.unwrap_or(50).clamp(20, 200); + let total_pages = total.div_ceil(page_size).max(1); + let page = query.page.unwrap_or(1).clamp(1, total_pages); + let offset = (page - 1).saturating_mul(page_size); + + let sql = format!( + r#" + SELECT + event_key, timestamp, latency_ms, ttft_ms, source, auth_index, failed, + provider, model, alias, reasoning_effort, service_tier, + response_service_tier, executor_type, endpoint, auth_type, + api_key_hash, api_key_display, api_key_remark, request_id, + input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, + cache_creation_tokens, total_tokens + FROM usage_events{} + ORDER BY timestamp_ms DESC, id DESC + LIMIT ? OFFSET ? + "#, + filter.clause + ); + let mut values = filter.params; + values.push(SqlValue::Integer(page_size as i64)); + values.push(SqlValue::Integer(offset.min(i64::MAX as usize) as i64)); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?; + let items = statement + .query_map(params_from_iter(values.iter()), usage_record_from_row) + .map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?; + Ok(UsageEventPage { + items, + total, + page, + page_size, + total_pages, + }) +} + +fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(UsageRecord { + id: row.get(0)?, + timestamp: row.get(1)?, + latency_ms: from_sql_i64(row.get(2)?), + ttft_ms: row.get::<_, Option>(3)?.map(from_sql_i64), + source: row.get(4)?, + auth_index: row.get(5)?, + failed: row.get::<_, i64>(6)? != 0, + provider: row.get(7)?, + model: row.get(8)?, + alias: row.get(9)?, + reasoning_effort: row.get(10)?, + service_tier: row.get(11)?, + response_service_tier: row.get(12)?, + executor_type: row.get(13)?, + endpoint: row.get(14)?, + auth_type: row.get(15)?, + api_key_hash: row.get(16)?, + api_key_display: row.get(17)?, + api_key_remark: row.get(18)?, + request_id: row.get(19)?, + tokens: UsageTokenStats { + input_tokens: from_sql_i64(row.get(20)?), + output_tokens: from_sql_i64(row.get(21)?), + reasoning_tokens: from_sql_i64(row.get(22)?), + cache_read_tokens: from_sql_i64(row.get(23)?), + cache_creation_tokens: from_sql_i64(row.get(24)?), + total_tokens: from_sql_i64(row.get(25)?), + }, + }) +} + +fn total_usage_records() -> Result { + let connection = open_usage_database()?; + connection + .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { + row.get::<_, i64>(0) + }) + .map(from_sql_i64) + .map_err(|error| format!("统计 SQLite 使用记录总数失败: {error}")) +} + +fn query_window_minutes(query: &UsageQuery, first: Option, last: Option) -> f64 { + let start = query + .start + .as_deref() + .and_then(parse_timestamp_millis) + .or(first) + .unwrap_or(0); + let end = query + .end + .as_deref() + .and_then(parse_timestamp_millis) + .or(last) + .unwrap_or(start); + ((end.saturating_sub(start)) as f64 / 60_000.0).max(1.0) +} + +fn usage_root_dir() -> Result { + let executable = + std::env::current_exe().map_err(|error| format!("读取程序路径失败: {error}"))?; + let directory = executable + .parent() + .ok_or_else(|| "程序路径没有父目录".to_string())?; + Ok(directory.join(USAGE_DIR_NAME)) +} + +fn record_local_hour(record: &UsageRecord) -> String { + local_hour_from_timestamp(&record.timestamp) + .unwrap_or_else(|| Local::now().format("%Y-%m-%d-%H").to_string()) +} + +fn local_hour_from_timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value).ok().map(|timestamp| { + timestamp + .with_timezone(&Local) + .format("%Y-%m-%d-%H") + .to_string() + }) +} + +fn record_timestamp_millis(record: &UsageRecord) -> i64 { + parse_timestamp_millis(&record.timestamp).unwrap_or(0) +} + +fn parse_timestamp_millis(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn to_sql_i64(value: u64) -> i64 { + value.min(i64::MAX as u64) as i64 +} + +fn from_sql_i64(value: i64) -> u64 { + value.max(0) as u64 +} + +#[cfg(test)] +fn unique_file_stamp() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) +} + +fn string_field(object: &serde_json::Map, key: &str) -> Option { + object + .get(key) + .and_then(|value| match value { + Value::String(value) => Some(value.trim().to_string()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) + .filter(|value| !value.is_empty()) +} + +fn u64_field(object: &serde_json::Map, key: &str) -> u64 { + optional_u64_field(object, key).unwrap_or(0) +} + +fn optional_u64_field(object: &serde_json::Map, key: &str) -> Option { + object.get(key).and_then(|value| { + value.as_u64().or_else(|| { + value + .as_i64() + .map(|number| number.max(0) as u64) + .or_else(|| value.as_str().and_then(|text| text.parse::().ok())) + }) + }) +} + +fn token_u64(object: Option<&serde_json::Map>, key: &str) -> u64 { + object.map(|object| u64_field(object, key)).unwrap_or(0) +} + +fn hash_text(value: &str) -> String { + if value.is_empty() { + return String::new(); + } + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +fn mask_api_key(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + return String::new(); + } + if value.chars().count() <= 8 { + return format!("{}••••", value.chars().take(2).collect::()); + } + let start = value.chars().take(4).collect::(); + let end = value + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + format!("{start}••••{end}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "cpa-gui-usage-{name}-{}-{}", + std::process::id(), + unique_file_stamp() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + fn open_test_database(root: &Path) -> Connection { + initialize_usage_storage_at(root).unwrap(); + open_usage_database_at(root).unwrap() + } + + fn sample_record(id: &str, timestamp: &str, model: &str) -> UsageRecord { + UsageRecord { + id: id.to_string(), + timestamp: timestamp.to_string(), + latency_ms: 100, + ttft_ms: Some(20), + source: "source".to_string(), + auth_index: "auth".to_string(), + failed: false, + provider: "openai".to_string(), + model: model.to_string(), + alias: String::new(), + reasoning_effort: "high".to_string(), + service_tier: String::new(), + response_service_tier: String::new(), + executor_type: String::new(), + endpoint: "POST /v1/responses".to_string(), + auth_type: "oauth".to_string(), + api_key_hash: "hash".to_string(), + api_key_display: "12••••".to_string(), + api_key_remark: "内置密钥".to_string(), + request_id: id.to_string(), + tokens: UsageTokenStats { + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 5, + cache_read_tokens: 2, + cache_creation_tokens: 0, + total_tokens: 30, + }, + } + } + + #[test] + fn masks_api_keys_without_exposing_the_full_value() { + assert_eq!(mask_api_key("123456"), "12••••"); + assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890"); + } + + #[test] + fn local_hour_key_uses_year_month_day_and_hour() { + let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model"); + assert_eq!(record_local_hour(&record).len(), "2026-07-17-20".len()); + } + + #[test] + fn normalizes_queue_records_without_persisting_secrets_or_headers() { + let config = GuiConfigFile { + port: 8317, + allow_lan: false, + run_on_startup: false, + auth_dir: String::new(), + api_keys: Vec::new(), + management_secret_key: "123456".to_string(), + plugins_enabled: false, + routing_strategy: "round-robin".to_string(), + }; + let record = normalize_usage_record( + serde_json::json!({ + "timestamp": "2026-07-17T20:30:00+08:00", + "request_id": "request-1", + "api_key": "secret-client-key", + "response_headers": { "authorization": ["secret-upstream-token"] }, + "model": "gpt-test", + "tokens": { "input_tokens": 10, "output_tokens": 20 } + }), + &config, + ) + .unwrap(); + let rendered = serde_json::to_string(&record).unwrap(); + + assert!(!rendered.contains("secret-client-key")); + assert!(!rendered.contains("secret-upstream-token")); + assert_eq!(record.tokens.total_tokens, 30); + assert!(!record.api_key_hash.is_empty()); + } + + #[test] + fn sqlite_storage_uses_wal_and_reference_busy_timeout() { + let root = test_root("sqlite-pragmas"); + let connection = open_test_database(&root); + let journal_mode = connection + .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) + .unwrap(); + let busy_timeout = connection + .query_row("PRAGMA busy_timeout", [], |row| row.get::<_, i64>(0)) + .unwrap(); + let foreign_keys = connection + .query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0)) + .unwrap(); + + assert_eq!(journal_mode.to_ascii_lowercase(), "wal"); + assert_eq!(busy_timeout, 5_000); + assert_eq!(foreign_keys, 1); + assert!(root.join(USAGE_DATABASE_FILE).is_file()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn sqlite_storage_deduplicates_event_keys_transactionally() { + let root = test_root("sqlite-deduplicate"); + let mut connection = open_test_database(&root); + let record = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-test"); + + assert_eq!( + insert_usage_records(&mut connection, &[record.clone()]).unwrap(), + 1 + ); + assert_eq!(insert_usage_records(&mut connection, &[record]).unwrap(), 0); + let count = connection + .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + + assert_eq!(count, 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn legacy_hour_and_inbox_json_are_migrated_once() { + let root = test_root("legacy-migration"); + let events_dir = root.join(LEGACY_USAGE_EVENTS_DIR); + let inbox_dir = root.join(LEGACY_USAGE_INBOX_DIR); + fs::create_dir_all(&events_dir).unwrap(); + fs::create_dir_all(&inbox_dir).unwrap(); + let first = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a"); + let second = sample_record("request-2", "2026-07-17T20:31:00+08:00", "gpt-b"); + fs::write( + events_dir.join("2026-07-17-20.json"), + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": USAGE_SCHEMA_VERSION, + "hour": "2026-07-17-20", + "timezone": "+08:00", + "records": [first] + })) + .unwrap(), + ) + .unwrap(); + fs::write( + inbox_dir.join("pending.json"), + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": USAGE_SCHEMA_VERSION, + "records": [second] + })) + .unwrap(), + ) + .unwrap(); + + initialize_usage_storage_at(&root).unwrap(); + initialize_usage_storage_at(&root).unwrap(); + let connection = open_usage_database_at(&root).unwrap(); + let count = connection + .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + let marker = connection + .query_row( + "SELECT value FROM usage_metadata WHERE key = ?1", + params![LEGACY_JSON_MIGRATION_KEY], + |row| row.get::<_, String>(0), + ) + .unwrap(); + + assert_eq!(count, 2); + assert_eq!(marker, "2"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn sqlite_queries_filter_aggregate_and_paginate() { + let root = test_root("sqlite-query"); + let mut connection = open_test_database(&root); + let success = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-a"); + let mut failed = sample_record("request-2", "2026-07-17T21:30:00+08:00", "gpt-b"); + failed.failed = true; + insert_usage_records(&mut connection, &[success, failed]).unwrap(); + let query = UsageQuery { + model: Some("GPT-B".to_string()), + failed: Some(true), + page_size: Some(20), + ..UsageQuery::default() + }; + + let overview = load_usage_overview(&connection, &query).unwrap(); + let analysis = load_usage_analysis(&connection, &query).unwrap(); + let events = load_usage_events(&connection, &query).unwrap(); + + assert_eq!(overview.total_requests, 1); + assert_eq!(overview.failure_count, 1); + assert_eq!(analysis.models[0].key, "gpt-b"); + assert_eq!(events.total, 1); + assert_eq!(events.items[0].id, "request-2"); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/App.tsx b/src/App.tsx index fc333ae..5e61a52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Bot, FileKey, Gauge, GitFork, LogIn, Network, ServerCog, Settings } from 'lucide-react'; +import { Bot, FileKey, Gauge, GitFork, History, LogIn, Network, ServerCog, Settings } from 'lucide-react'; import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; import { ConfigPanelPage } from './pages/ConfigPanel'; @@ -10,6 +10,7 @@ import { OAuthLoginPage } from './pages/ManagementPages'; import { QuotaPage } from './pages/QuotaPage'; import { AgentsPage } from './pages/AgentsPage'; import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage'; +import { UsageRecordsPage } from './pages/UsageRecordsPage'; const pages = [ { @@ -54,6 +55,12 @@ const pages = [ icon: Gauge, component: QuotaPage, }, + { + id: 'usage-records', + label: '使用记录', + icon: History, + component: UsageRecordsPage, + }, { id: 'agents', label: '智能体', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 08921ce..9ac13c2 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -96,7 +96,7 @@ const agentDefinitions: AgentDefinition[] = [ id: 'claude-desktop', name: 'Claude Desktop', icon: claudeIcon, - description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS', + description: '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta', }, { id: 'codex', @@ -125,6 +125,29 @@ const agentDefinitions: AgentDefinition[] = [ ]; const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1'; +const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1'; + +const readSelectedAgentClient = (): AgentClientId => { + const fallback = agentDefinitions[0].id; + if (typeof window === 'undefined') return fallback; + try { + const saved = window.localStorage.getItem(AGENT_SELECTED_CLIENT_KEY); + return agentDefinitions.some((agent) => agent.id === saved) + ? (saved as AgentClientId) + : fallback; + } catch { + return fallback; + } +}; + +const writeSelectedAgentClient = (client: AgentClientId) => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(AGENT_SELECTED_CLIENT_KEY, client); + } catch { + // Keep the current in-memory selection when persistent storage is unavailable. + } +}; const readAgentModelSelections = (): Partial> => { if (typeof window === 'undefined') return {}; @@ -365,7 +388,7 @@ function AgentModelPicker({ } export function AgentsPage() { - const [selected, setSelected] = useState('claude-code'); + const [selected, setSelected] = useState(readSelectedAgentClient); const [statuses, setStatuses] = useState([]); const [models, setModels] = useState([]); const [modelByClient, setModelByClient] = useState>>( @@ -419,6 +442,10 @@ export function AgentsPage() { void refresh(); }, [refresh]); + useEffect(() => { + writeSelectedAgentClient(selected); + }, [selected]); + useEffect(() => { setLaunchTargetByClient((current) => agentDefinitions.reduce>>( (next, definition) => { @@ -460,6 +487,8 @@ export function AgentsPage() { const canLaunch = Boolean( activeStatus?.supportedPlatform && activeStatus.installed + && activeStatus.modificationEnabled + && activeStatus.modificationState === 'active' && selectedLaunchTarget, ); @@ -513,7 +542,7 @@ export function AgentsPage() { } setRestoreConflict(null); setNotice(result.enabled - ? `${activeDefinition.name} 已启用配置修改,请重启客户端` + ? `${activeDefinition.name} 已启用配置修改;退出软件前建议关闭“修改智能体配置”,恢复原配置文件` : `${activeDefinition.name} 的原配置已恢复,请重启客户端`); await loadStatuses(); } catch (requestError) { @@ -529,10 +558,10 @@ export function AgentsPage() { setNotice(''); try { let appliedBeforeLaunch = false; - const shouldSyncCodexCatalog = Boolean( - selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption, + const shouldSyncModelCatalog = Boolean( + activeStatus?.modificationEnabled && selectedModelOption, ); - if (draftChanged || shouldSyncCodexCatalog) { + if (draftChanged || shouldSyncModelCatalog) { if (activeStatus?.modificationState !== 'active') { throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型'); } @@ -547,9 +576,11 @@ export function AgentsPage() { await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id }); setNotice(appliedBeforeLaunch ? draftChanged - ? `${activeDefinition.name} 已应用所选模型并启动` - : `${activeDefinition.name} 已同步模型目录并启动` - : `${activeDefinition.name} 已启动`); + ? `${activeDefinition.name} 已应用模型并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件` + : `${activeDefinition.name} 已同步模型目录并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件` + : activeStatus?.modificationEnabled + ? `${activeDefinition.name} 已启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件` + : `${activeDefinition.name} 已启动`); if (appliedBeforeLaunch) await loadStatuses(); } catch (requestError) { setError(String(requestError)); @@ -655,7 +686,7 @@ export function AgentsPage() {
-
使用模型只能选择内核当前实际开放的模型
+
使用模型同步内核当前开放的模型,并将所选项设为默认模型
{draftChanged ? 尚未应用 : null}
+ : '启动客户端前必须开启;程序会先备份原配置,再写入 CPA 配置'}