diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5f4f34..c53eb52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,16 +16,22 @@ permissions: jobs: test: - name: Rust unit tests + name: Unit tests runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 with: workspaces: './src-tauri -> target' - name: Install Linux deps run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev + # i18n dictionary parity + the repo-wide 220-line module rule. + - name: npm test + run: node test/run.js - name: cargo test run: cargo test --lib --manifest-path src-tauri/Cargo.toml diff --git a/build/generate-tray-icon.js b/build/generate-tray-icon.js index c5323c7..8040c22 100644 --- a/build/generate-tray-icon.js +++ b/build/generate-tray-icon.js @@ -113,8 +113,5 @@ const out2x = path.join(__dirname, 'iconTemplate@2x.png'); fs.writeFileSync(out1x, generatePng(22, 1)); fs.writeFileSync(out2x, generatePng(44, 2)); -// Copy to source folder -fs.copyFileSync(out1x, path.join(__dirname, '..', 'src', 'main', 'iconTemplate.png')); -fs.copyFileSync(out2x, path.join(__dirname, '..', 'src', 'main', 'iconTemplate@2x.png')); - +// build/iconTemplate.png is the tray icon the Rust backend embeds (include_bytes! in lib.rs). console.log('Successfully generated minimalist template icons.'); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4a45ee0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,53 @@ +# Architecture + +CC Buddy is a Tauri app: a Rust backend (`src-tauri/`) and a plain-ESM renderer (`src/renderer/`) +with no bundler. This document records the two rules that shape the codebase. + +## Rule 1 — no source file over 220 lines + +Enforced by `test/file-size.test.js` (part of `npm test`). Vendored bundles, the generated +`styles.css` and the build-synced `src/renderer/shared/` copy are exempt; everything else is +hand-maintained source and must stay under the limit. Reaching for a split is the intended +response, not raising the number. + +The corollary is a module per responsibility. Where a file would otherwise grow, it becomes a +directory module (`foo.rs` → `foo/mod.rs` + siblings; a view → `views//index.js` + siblings) +whose `mod.rs` / `index.js` is the only public surface. + +## Rule 2 — nothing on the cold-start path that a later click could load + +**Backend.** `setup()` in `src-tauri/src/lib.rs` only builds the window, tray and event hooks. +Every filesystem-heavy boot step — one-time `historyDirs` migrations, plugin reconcile, CLI +connection repair, gateway and plugin start, history-watcher registration, usage-cache warm — +runs off the main thread from `startup::spawn_background_boot`, in the same order as before. +The window paints and accepts input while that work proceeds. + +**Renderer.** `index.html` ships the shell plus the first view (服务) only. `js/main.js` and the +`js/core/` modules it imports are the only JavaScript parsed before first paint: + +| Loaded at startup | Loaded on demand | +| --- | --- | +| shell markup + 服务 view | 插件 / 监控 / 设置 / 会话 markup and modules (`views/registry.js`) | +| active locale's dictionary parts | the other four locales (`shared/i18n/parts.js` manifest) | +| `theme-boot.js` (pre-paint theme/lang stamp) | `marked` + `highlight.js` (`core/loader.js`, on first transcript or inspector open) | +| analytics queue stub (deferred) | provider modal, request-inspector drawer, plugin forms | + +Two idle prefetches warm the heaviest lazy paths (vendor bundles, the 会话 view) after boot, so +the first click still feels instant without costing startup. + +## Module map + +``` +src/renderer/ + js/core/ bridge (IPC) · state · i18n · loader · dom · icons · theme · toast + js/views/ registry + providers/ plugins/ monitor/ settings/ conversations/ + js/popover/ tray usage window + css/ numbered partials; input.css is the @import manifest (order = cascade order) +src/shared/i18n/ per-language, per-domain dictionary parts + index (Node) / parts.js (browser) +src-tauri/src/ startup · gateway/ · history/ · protocol/ · codex/ · store · usage · plugin · … +``` + +## Testing + +- `npm test` — i18n dictionary parity across locales, part-manifest integrity, file-size rule. +- `cd src-tauri && cargo test` — the gateway, history, usage, export and protocol engines. diff --git a/package-lock.json b/package-lock.json index 791a227..d84f014 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "ccbud", - "version": "1.3.5", + "version": "1.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ccbud", - "version": "1.3.5", + "version": "1.3.8", "license": "GPL-3.0-only", "dependencies": { - "@microsoft/clarity": "^1.0.2", - "js-tiktoken": "^1.0.21" + "@microsoft/clarity": "^1.0.2" }, "devDependencies": { "@highlightjs/cdn-assets": "^11.11.1", @@ -214,9 +213,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -238,9 +234,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +255,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -286,9 +276,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -310,9 +297,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -334,9 +318,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -585,9 +566,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -605,9 +583,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +600,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -645,9 +617,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -876,9 +845,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -896,9 +862,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -916,9 +879,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -936,9 +896,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -956,9 +913,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1019,26 +973,6 @@ "node": ">= 10" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1139,15 +1073,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tiktoken": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", - "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.5.1" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1291,9 +1216,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1315,9 +1237,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1339,9 +1258,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1363,9 +1279,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index ac3ec06..05f57f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccbud", - "version": "1.3.8", + "version": "1.3.9", "description": "CC Buddy — Coding CLI Buddy. A cross-platform desktop app that proxies Claude Code to any Anthropic-compatible provider (one-click switching, model mapping) and browses your Claude Code & Codex session history.", "author": { "name": "loadchange", @@ -19,7 +19,7 @@ "release": "node scripts/release.js", "start": "tauri dev", "dev": "tauri dev", - "test": "node test/selftest.js", + "test": "node test/run.js", "update:cask": "node scripts/update-cask.js", "dist": "tauri build", "dist:mac": "tauri build", @@ -34,7 +34,6 @@ "tailwindcss": "^4.3.1" }, "dependencies": { - "@microsoft/clarity": "^1.0.2", - "js-tiktoken": "^1.0.21" + "@microsoft/clarity": "^1.0.2" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f41abd..2f1504d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,7 +89,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "1.3.8" +version = "1.3.9" dependencies = [ "arboard", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8ccb2d..61fad0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.3.8" +version = "1.3.9" description = "CCBuddy — Coding CLI Buddy" authors = ["loadchange "] license = "GPL-3.0-only" diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs deleted file mode 100644 index 57599ba..0000000 --- a/src-tauri/src/antigravity.rs +++ /dev/null @@ -1,725 +0,0 @@ -// Google Antigravity CLI (`agy`) session support — reads its per-conversation SQLite stores -// (`~/.gemini/antigravity-cli/conversations/.db`, `steps` table) plus the sibling -// `conversation_summaries.db` (title / preview / workspace uris — plain text), and normalizes -// them into the SAME session/message shape the renderer consumes (history::Norm). -// -// A step's `step_payload` is a protobuf blob with no published schema. A minimal wire-format -// walker recovers the stable fields (reverse-engineered against real conversations): -// #1 step type enum #4 status -// #5 metadata: #5.1 {sec,nanos} created · #5.4 tool call {#1 id, #2 name, #3 args-JSON, -// #7 result (opaque/encrypted — not recoverable)} · #5.9 generation stats -// {#2 input tokens, #3 output tokens} -// #19 user input: #19.2 text · #19.9 attachments {#1 mime, #2 bytes, #5 path} -// #20 model turn: #20.1 assistant text -// Steps whose payload drifts from this map degrade to being skipped (never crash) — the -// summaries DB alone still lists the conversation. Tool RESULTS are stored in a non-readable -// encoding, so tool cards show name/args and the renderer's "no result" marker. -// -// DBs may be WAL-journaled and open in a live agy process: connections are read-only with a -// short busy timeout, and freshness checks use max(mtime(db), mtime(db-wal)). -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `antigravity:` — the DBs belong to another tool and are never written. - -#![allow(dead_code)] - -use crate::history::Norm; -use rusqlite::{Connection, OpenFlags}; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Antigravity CLI's data dir as a history-dir entry string (`~/.gemini/antigravity-cli`). -pub fn default_root() -> PathBuf { - home().join(".gemini").join("antigravity-cli") -} - -pub fn agy_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -pub fn root_exists() -> bool { - default_root().join("conversations").is_dir() -} - -/// Walk every conversation DB under a `conversations/` dir. -pub fn walk(conversations_dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(conversations_dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("db") { - cb(p); - } - } -} - -/// Container-shape test for detail/edit routing: `…/conversations/.db`. -pub fn looks_agy_path(file: &Path) -> bool { - file.extension().and_then(|e| e.to_str()) == Some("db") - && file - .parent() - .and_then(|d| d.file_name()) - .and_then(|n| n.to_str()) - .map(|n| n == "conversations") - .unwrap_or(false) -} - -fn session_uuid(file: &Path) -> String { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() -} - -fn sidecar_key(file: &Path) -> String { - format!("antigravity:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "antigravity:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// WAL-aware freshness stamp: a live agy writes into `-wal` without touching the main -/// file's mtime, so cache keys must take the max of both. -pub fn wal_mtime_ms(file: &Path) -> f64 { - let m = |p: &Path| { - fs::metadata(p) - .and_then(|md| md.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) - }; - let mut wal = file.as_os_str().to_os_string(); - wal.push("-wal"); - m(file).max(m(Path::new(&wal))) -} - -fn open_ro(path: &Path) -> Option { - let conn = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .ok()?; - let _ = conn.busy_timeout(std::time::Duration::from_millis(400)); - Some(conn) -} - -// ---- protobuf wire walker (schema-less) ---- - -enum Wire { - Varint(u64), - Bytes(Vec), - Fixed, -} - -/// One message level → (field number, value) pairs. None when the buffer isn't a valid message. -fn wire_fields(buf: &[u8]) -> Option> { - let mut out = vec![]; - let mut i = 0usize; - fn varint(buf: &[u8], i: &mut usize) -> Option { - let mut v: u64 = 0; - let mut shift = 0u32; - loop { - let b = *buf.get(*i)?; - *i += 1; - v |= ((b & 0x7f) as u64) << shift; - if b & 0x80 == 0 { - return Some(v); - } - shift += 7; - if shift > 63 { - return None; - } - } - } - while i < buf.len() { - let tag = varint(buf, &mut i)?; - let (field, wt) = ((tag >> 3) as u32, tag & 7); - if field == 0 { - return None; - } - match wt { - 0 => out.push((field, Wire::Varint(varint(buf, &mut i)?))), - 2 => { - let len = varint(buf, &mut i)? as usize; - if i + len > buf.len() { - return None; - } - out.push((field, Wire::Bytes(buf[i..i + len].to_vec()))); - i += len; - } - 5 => { - if i + 4 > buf.len() { - return None; - } - i += 4; - out.push((field, Wire::Fixed)); - } - 1 => { - if i + 8 > buf.len() { - return None; - } - i += 8; - out.push((field, Wire::Fixed)); - } - _ => return None, - } - } - Some(out) -} - -fn field_bytes<'a>(fields: &'a [(u32, Wire)], no: u32) -> Option<&'a [u8]> { - fields.iter().find_map(|(f, w)| match w { - Wire::Bytes(b) if *f == no => Some(b.as_slice()), - _ => None, - }) -} - -fn field_msg(fields: &[(u32, Wire)], no: u32) -> Option> { - wire_fields(field_bytes(fields, no)?) -} - -fn field_str(fields: &[(u32, Wire)], no: u32) -> Option { - let b = field_bytes(fields, no)?; - let s = std::str::from_utf8(b).ok()?; - Some(s.to_string()) -} - -fn field_varint(fields: &[(u32, Wire)], no: u32) -> Option { - fields.iter().find_map(|(f, w)| match w { - Wire::Varint(v) if *f == no => Some(*v), - _ => None, - }) -} - -/// `{#1 seconds, #2 nanos}` timestamp message → RFC3339 (ms precision). -fn ts_of(fields: &[(u32, Wire)], no: u32) -> Option { - let m = field_msg(fields, no)?; - let secs = field_varint(&m, 1)? as i64; - let nanos = field_varint(&m, 2).unwrap_or(0) as u32; - let dt = chrono::DateTime::from_timestamp(secs, nanos)?; - Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) -} - -fn ts_ms_of(fields: &[(u32, Wire)], no: u32) -> Option { - let m = field_msg(fields, no)?; - let secs = field_varint(&m, 1)? as f64; - let nanos = field_varint(&m, 2).unwrap_or(0) as f64; - Some(secs * 1000.0 + (nanos / 1_000_000.0).floor()) -} - -// ---- content mapping ---- - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -fn b64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity((data.len() + 2) / 3 * 4); - for chunk in data.chunks(3) { - let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; - let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -/// Antigravity tool name + parsed arguments → (renderer tool name, renderer input). The args -/// JSON carries display strings (toolAction/toolSummary) alongside the real params — dropped -/// from generic passthrough to keep cards clean. -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - match name { - "run_command" => { - let mut input = json!({ "command": s("CommandLine") }); - if !s("Cwd").is_empty() { - input["description"] = json!(s("Cwd")); - } - ("Bash".into(), input) - } - "view_file" => ("Read".into(), json!({ "file_path": s("AbsolutePath") })), - "list_dir" => ("LS".into(), json!({ "path": s("DirectoryPath") })), - "grep_search" => { - let mut input = json!({ "pattern": s("Query") }); - if !s("SearchPath").is_empty() { - input["path"] = json!(s("SearchPath")); - } - ("Grep".into(), input) - } - "find_by_name" => ("Glob".into(), json!({ "pattern": s("Pattern"), "path": s("SearchDirectory") })), - "replace_file_content" => ( - "Edit".into(), - json!({ "file_path": s("TargetFile"), "old_string": s("TargetContent"), "new_string": s("ReplacementContent") }), - ), - "write_to_file" => ( - "Write".into(), - json!({ "file_path": s("TargetFile"), "content": s("CodeContent") }), - ), - "read_url_content" => ("WebFetch".into(), json!({ "url": s("Url") })), - "search_web" => ("WebSearch".into(), json!({ "query": s("query") })), - _ => { - let mut input = args.clone(); - if let Some(o) = input.as_object_mut() { - o.remove("toolAction"); - o.remove("toolSummary"); - } - (name.to_string(), if input.is_object() { input } else { json!({}) }) - } - } -} - -/// Decode one step row into zero or more renderer messages, accumulating usage into `n`. -fn push_step(n: &mut Norm, payload: &[u8]) { - let fields = match wire_fields(payload) { - Some(f) => f, - None => return, - }; - let meta5 = field_msg(&fields, 5).unwrap_or_default(); - let ts = ts_of(&meta5, 1); - let with_ts = |mut m: Value| { - if let Some(t) = &ts { - m["ts"] = json!(t); - } - m - }; - - // user turn: #19 {2: text, 9: attachments {1 mime, 2 bytes, 5 path}} - if let Some(user) = field_msg(&fields, 19) { - let mut blocks: Vec = vec![]; - if let Some(text) = field_str(&user, 2) { - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - } - for (f, w) in &user { - if *f != 9 { - continue; - } - if let Wire::Bytes(b) = w { - if let Some(att) = wire_fields(b) { - let mime = field_str(&att, 1).unwrap_or_default(); - let data = field_bytes(&att, 2); - match data { - // cap embedded images at 8 MB raw — larger ones degrade to a path note - Some(bytes) if mime.starts_with("image/") && bytes.len() <= 8_000_000 => { - blocks.push(json!({ - "type": "image", - "source": { "type": "base64", "media_type": mime, "data": b64_encode(bytes) } - })); - } - _ => { - if let Some(p) = field_str(&att, 5) { - blocks.push(json!({ "type": "text", "text": format!("[attachment: {}]", p) })); - } - } - } - } - } - } - if !blocks.is_empty() { - n.messages.push(with_ts(json!({ "role": "user", "content": blocks }))); - } - return; - } - - // tool call: #5.4 {1 id, 2 name, 3 args-json} (results are stored opaquely — omitted) - if let Some(call) = field_msg(&meta5, 4) { - let name = field_str(&call, 2).unwrap_or_else(|| "tool".into()); - let args: Value = field_str(&call, 3) - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(json!({})); - let (tname, input) = map_tool(&name, &args); - let id = field_str(&call, 1).unwrap_or_default(); - n.messages.push(with_ts(json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }))); - return; - } - - // model turn: #20.1 assistant text; #5.9 {2 input, 3 output} token stats - let turn20 = field_msg(&fields, 20); - let text = turn20.as_ref().and_then(|t| field_str(t, 1).or_else(|| field_str(t, 8))); - let stats = field_msg(&meta5, 9); - let usage = stats.as_ref().map(|st| { - let input = field_varint(st, 2).unwrap_or(0) as i64; - let output = field_varint(st, 3).unwrap_or(0) as i64; - json!({ "inputTokens": input, "outputTokens": output, "cacheRead": 0, "cacheCreation": 0 }) - }); - if let Some(text) = text { - if !text.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); - if let Some(u) = &usage { - let input = u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - let output = u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - if input + output > 0 { - m["usage"] = u.clone(); - let t = n.totals.as_object_mut().unwrap(); - t["in"] = json!(t["in"].as_i64().unwrap_or(0) + input); - t["out"] = json!(t["out"].as_i64().unwrap_or(0) + output); - t["turns"] = json!(t["turns"].as_i64().unwrap_or(0) + 1); - } - } - n.messages.push(with_ts(m)); - } - } -} - -/// Depth-first search for the first utf8 string field with `prefix` anywhere in a message tree. -fn find_str_with_prefix(buf: &[u8], prefix: &str, depth: u8) -> Option { - let fields = wire_fields(buf)?; - for (_, w) in &fields { - if let Wire::Bytes(b) = w { - if let Ok(s) = std::str::from_utf8(b) { - if s.starts_with(prefix) { - return Some(s.to_string()); - } - } - if depth < 6 { - if let Some(found) = find_str_with_prefix(b, prefix, depth + 1) { - return Some(found); - } - } - } - } - None -} - -fn uri_to_path(uri: &str) -> String { - crate::grok::percent_decode(uri.strip_prefix("file://").unwrap_or(uri)) -} - -/// Workspace cwd for conversations the summaries DB hasn't indexed (a few percent of real -/// stores): the per-conversation trajectory_metadata_blob embeds the workspace file:// uri. -fn fallback_cwd(file: &Path) -> Option { - let conn = open_ro(file)?; - let blob: Vec = conn - .query_row("SELECT data FROM trajectory_metadata_blob LIMIT 1", [], |r| r.get(0)) - .ok()?; - find_str_with_prefix(&blob, "file://", 0).map(|u| uri_to_path(&u)) -} - -/// Title-of-last-resort for un-indexed conversations: the first user step's prose. -fn first_user_step_text(file: &Path) -> Option { - let conn = open_ro(file)?; - let payload: Vec = conn - .query_row("SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx LIMIT 1", [], |r| r.get(0)) - .ok()?; - let fields = wire_fields(&payload)?; - let user = field_msg(&fields, 19)?; - let text = field_str(&user, 2)?; - let t: String = text.split_whitespace().collect::>().join(" "); - if t.is_empty() { - None - } else { - Some(t.chars().take(90).collect()) - } -} - -/// One conversation's summaries-DB row: (title, preview, first workspace path, step_count). -fn summaries_row(file: &Path) -> Option<(String, String, Option, i64)> { - let root = file.parent()?.parent()?; - let conn = open_ro(&root.join("conversation_summaries.db"))?; - let uuid = session_uuid(file); - conn.query_row( - "SELECT title, preview, workspace_uris, step_count FROM conversation_summaries WHERE conversation_id = ?1", - [&uuid], - |row| { - let title: String = row.get(0).unwrap_or_default(); - let preview: String = row.get(1).unwrap_or_default(); - let uris: String = row.get(2).unwrap_or_default(); - let steps: i64 = row.get(3).unwrap_or(0); - Ok((title, preview, uris, steps)) - }, - ) - .ok() - .map(|(title, preview, uris, steps)| { - let cwd = serde_json::from_str::(&uris) - .ok() - .and_then(|v| v.as_array().and_then(|a| a.first().cloned())) - .and_then(|u| u.as_str().map(|s| s.to_string())) - .map(|u| uri_to_path(&u)); - (title, preview, cwd, steps) - }) -} - -/// Read + normalize a conversation DB into the renderer's message model. -pub fn normalize_db(file: &Path) -> Norm { - let mut n = Norm::default(); - if let Some((_, _, cwd, _)) = summaries_row(file) { - n.cwd = cwd; - } - if n.cwd.is_none() { - n.cwd = fallback_cwd(file); - } - n.session_id = Some(session_uuid(file)); - let conn = match open_ro(file) { - Some(c) => c, - None => return n, - }; - let mut stmt = match conn.prepare("SELECT step_payload FROM steps ORDER BY idx") { - Ok(s) => s, - Err(_) => return n, - }; - let rows = stmt.query_map([], |row| row.get::<_, Vec>(0)); - if let Ok(rows) = rows { - for payload in rows.flatten() { - push_step(&mut n, &payload); - } - } - n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n -} - -/// Creation stamp (ms) from the first step's timestamp — content-derived, immune to file -/// rewrites, matching record_created_ms semantics for jsonl sources. -fn first_step_ms(file: &Path) -> Option { - let conn = open_ro(file)?; - let payload: Vec = conn - .query_row("SELECT step_payload FROM steps ORDER BY idx LIMIT 1", [], |r| r.get(0)) - .ok()?; - let fields = wire_fields(&payload)?; - let meta5 = field_msg(&fields, 5)?; - ts_ms_of(&meta5, 1) -} - -/// List-row meta — summaries DB + first-step timestamp; never parses the full step log. -pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let sum = summaries_row(file); - let (sum_title, preview, cwd) = match &sum { - Some((t, p, c, _)) => (t.trim().to_string(), p.trim().to_string(), c.clone()), - None => (String::new(), String::new(), None), - }; - let cwd = cwd.or_else(|| fallback_cwd(file)); - let auto_title: String = if !sum_title.is_empty() { - sum_title - } else if !preview.is_empty() { - preview.chars().take(90).collect() - } else { - first_user_step_text(file).unwrap_or_default() - }; - let created = first_step_ms(file).unwrap_or_else(|| crate::history::created_ms(file)); - Some(json!({ - "id": format!("antigravity:{}", uuid), - "file": file.to_string_lossy(), - "source": "antigravity", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": uuid, - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": Value::Null, - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": Value::Null, - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": wal_mtime_ms(file), - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here — the source is SQLite, not jsonl). -pub fn session_from(file: &str) -> Value { - let path = Path::new(file); - let n = normalize_db(path); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let sum = summaries_row(path); - let sum_title = sum - .as_ref() - .map(|(t, _, _, _)| t.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - json!({ - "meta": { - "id": format!("antigravity:{}", uuid), - "file": file, - "source": "antigravity", - "assistant": "Antigravity", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": uuid, - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": Value::Null, - "version": Value::Null, - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // hand-rolled wire encoding helpers (tests only) - fn enc_varint(mut v: u64, out: &mut Vec) { - loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(b); - break; - } - out.push(b | 0x80); - } - } - fn tag(field: u32, wt: u8, out: &mut Vec) { - enc_varint(((field as u64) << 3) | wt as u64, out); - } - fn put_varint(field: u32, v: u64, out: &mut Vec) { - tag(field, 0, out); - enc_varint(v, out); - } - fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { - tag(field, 2, out); - enc_varint(data.len() as u64, out); - out.extend_from_slice(data); - } - fn put_str(field: u32, s: &str, out: &mut Vec) { - put_bytes(field, s.as_bytes(), out); - } - fn ts_msg(secs: u64) -> Vec { - let mut m = vec![]; - put_varint(1, secs, &mut m); - put_varint(2, 500_000_000, &mut m); - m - } - - fn user_step(text: &str) -> Vec { - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_237), &mut meta5); - let mut u19 = vec![]; - put_str(2, text, &mut u19); - let mut att = vec![]; - put_str(1, "image/png", &mut att); - put_bytes(2, b"ABC", &mut att); - put_str(5, "/tmp/x.png", &mut att); - put_bytes(9, &att, &mut u19); - let mut step = vec![]; - put_varint(1, 14, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(19, &u19, &mut step); - step - } - - fn tool_step() -> Vec { - let mut call = vec![]; - put_str(1, "call-9", &mut call); - put_str(2, "run_command", &mut call); - put_str(3, "{\"CommandLine\":\"ls -la\",\"Cwd\":\"/tmp\",\"toolSummary\":\"Run\"}", &mut call); - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_240), &mut meta5); - put_bytes(4, &call, &mut meta5); - let mut step = vec![]; - put_varint(1, 21, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - step - } - - fn gen_step(text: &str) -> Vec { - let mut stats = vec![]; - put_varint(1, 1132, &mut stats); - put_varint(2, 20245, &mut stats); - put_varint(3, 346, &mut stats); - let mut meta5 = vec![]; - put_bytes(1, &ts_msg(1_783_811_242), &mut meta5); - put_bytes(9, &stats, &mut meta5); - let mut t20 = vec![]; - put_str(1, text, &mut t20); - let mut step = vec![]; - put_varint(1, 15, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(20, &t20, &mut step); - step - } - - #[test] - fn decodes_steps() { - let mut n = Norm::default(); - push_step(&mut n, &user_step("修复登录")); - push_step(&mut n, &tool_step()); - push_step(&mut n, &gen_step("已修复。")); - assert_eq!(n.messages.len(), 3); - assert_eq!(n.messages[0]["role"], "user"); - assert_eq!(n.messages[0]["content"][0]["text"], "修复登录"); - assert_eq!(n.messages[0]["content"][1]["type"], "image"); - assert_eq!(n.messages[0]["content"][1]["source"]["data"], "QUJD"); - let tool = &n.messages[1]["content"][0]; - assert_eq!(tool["name"], "Bash"); - assert_eq!(tool["input"]["command"], "ls -la"); - assert_eq!(tool["id"], "call-9"); - assert_eq!(n.messages[2]["content"][0]["text"], "已修复。"); - assert_eq!(n.messages[2]["usage"]["inputTokens"], 20245); - assert_eq!(n.messages[2]["usage"]["outputTokens"], 346); - assert_eq!(n.totals["in"], 20245); - assert_eq!(n.totals["turns"], 1); - assert!(n.messages[0]["ts"].as_str().unwrap().starts_with("2026-")); - } - - #[test] - fn garbage_payload_is_skipped() { - let mut n = Norm::default(); - push_step(&mut n, &[0xff, 0x00, 0x13, 0x37]); - push_step(&mut n, b""); - assert!(n.messages.is_empty()); - } - - #[test] - fn b64_matches_reference() { - assert_eq!(b64_encode(b"ABC"), "QUJD"); - assert_eq!(b64_encode(b"AB"), "QUI="); - assert_eq!(b64_encode(b"A"), "QQ=="); - assert_eq!(b64_encode(b""), ""); - } - - #[test] - fn detects_paths() { - assert!(looks_agy_path(Path::new("/x/antigravity-cli/conversations/ab-1.db"))); - assert!(!looks_agy_path(Path::new("/x/antigravity-cli/conversation_summaries.db"))); - assert!(!looks_agy_path(Path::new("/x/conversations/notes.txt"))); - } -} diff --git a/src-tauri/src/antigravity/content.rs b/src-tauri/src/antigravity/content.rs new file mode 100644 index 0000000..db87cb9 --- /dev/null +++ b/src-tauri/src/antigravity/content.rs @@ -0,0 +1,65 @@ +// Attachment base64 and the Antigravity tool → renderer tool mapping. Moved verbatim from +// antigravity.rs. + +use serde_json::{json, Value}; + +// ---- content mapping ---- + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub(super) fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +/// Antigravity tool name + parsed arguments → (renderer tool name, renderer input). The args +/// JSON carries display strings (toolAction/toolSummary) alongside the real params — dropped +/// from generic passthrough to keep cards clean. +pub(super) fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + match name { + "run_command" => { + let mut input = json!({ "command": s("CommandLine") }); + if !s("Cwd").is_empty() { + input["description"] = json!(s("Cwd")); + } + ("Bash".into(), input) + } + "view_file" => ("Read".into(), json!({ "file_path": s("AbsolutePath") })), + "list_dir" => ("LS".into(), json!({ "path": s("DirectoryPath") })), + "grep_search" => { + let mut input = json!({ "pattern": s("Query") }); + if !s("SearchPath").is_empty() { + input["path"] = json!(s("SearchPath")); + } + ("Grep".into(), input) + } + "find_by_name" => ("Glob".into(), json!({ "pattern": s("Pattern"), "path": s("SearchDirectory") })), + "replace_file_content" => ( + "Edit".into(), + json!({ "file_path": s("TargetFile"), "old_string": s("TargetContent"), "new_string": s("ReplacementContent") }), + ), + "write_to_file" => ( + "Write".into(), + json!({ "file_path": s("TargetFile"), "content": s("CodeContent") }), + ), + "read_url_content" => ("WebFetch".into(), json!({ "url": s("Url") })), + "search_web" => ("WebSearch".into(), json!({ "query": s("query") })), + _ => { + let mut input = args.clone(); + if let Some(o) = input.as_object_mut() { + o.remove("toolAction"); + o.remove("toolSummary"); + } + (name.to_string(), if input.is_object() { input } else { json!({}) }) + } + } +} diff --git a/src-tauri/src/antigravity/mod.rs b/src-tauri/src/antigravity/mod.rs new file mode 100644 index 0000000..1746ab4 --- /dev/null +++ b/src-tauri/src/antigravity/mod.rs @@ -0,0 +1,40 @@ +// Google Antigravity CLI (`agy`) session support — reads its per-conversation SQLite stores +// (`~/.gemini/antigravity-cli/conversations/.db`, `steps` table) plus the sibling +// `conversation_summaries.db` (title / preview / workspace uris — plain text), and normalizes +// them into the SAME session/message shape the renderer consumes (history::Norm). +// +// A step's `step_payload` is a protobuf blob with no published schema. A minimal wire-format +// walker recovers the stable fields (reverse-engineered against real conversations): +// #1 step type enum #4 status +// #5 metadata: #5.1 {sec,nanos} created · #5.4 tool call {#1 id, #2 name, #3 args-JSON, +// #7 result (opaque/encrypted — not recoverable)} · #5.9 generation stats +// {#2 input tokens, #3 output tokens} +// #19 user input: #19.2 text · #19.9 attachments {#1 mime, #2 bytes, #5 path} +// #20 model turn: #20.1 assistant text +// Steps whose payload drifts from this map degrade to being skipped (never crash) — the +// summaries DB alone still lists the conversation. Tool RESULTS are stored in a non-readable +// encoding, so tool cards show name/args and the renderer's "no result" marker. +// +// DBs may be WAL-journaled and open in a live agy process: connections are read-only with a +// short busy timeout, and freshness checks use max(mtime(db), mtime(db-wal)). +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `antigravity:` — the DBs belong to another tool and are never written. + +#![allow(dead_code)] +mod content; +mod roots; +mod session; +mod steps; +mod wire; +#[cfg(test)] +mod tests; + +pub use roots::{ + agy_label, is_deleted, looks_agy_path, root_exists, set_meta, wal_mtime_ms, walk, +}; +// Part of the module's API but currently only referenced from within it — a non-test build sees +// the re-export as unused; allow that instead of dropping the path. +#[allow(unused_imports)] +pub use roots::default_root; +pub use session::{normalize_db, session_from, session_meta_from}; diff --git a/src-tauri/src/antigravity/roots.rs b/src-tauri/src/antigravity/roots.rs new file mode 100644 index 0000000..4525593 --- /dev/null +++ b/src-tauri/src/antigravity/roots.rs @@ -0,0 +1,99 @@ +// Antigravity data root, path routing, the shared foreign-CLI sidecar, freshness and the +// read-only SQLite connection. Moved verbatim from antigravity.rs. + +use rusqlite::{Connection, OpenFlags}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Antigravity CLI's data dir as a history-dir entry string (`~/.gemini/antigravity-cli`). +pub fn default_root() -> PathBuf { + home().join(".gemini").join("antigravity-cli") +} + +pub fn agy_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("conversations").is_dir() +} + +/// Walk every conversation DB under a `conversations/` dir. +pub fn walk(conversations_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(conversations_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("db") { + cb(p); + } + } +} + +/// Container-shape test for detail/edit routing: `…/conversations/.db`. +pub fn looks_agy_path(file: &Path) -> bool { + file.extension().and_then(|e| e.to_str()) == Some("db") + && file + .parent() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .map(|n| n == "conversations") + .unwrap_or(false) +} + +pub(super) fn session_uuid(file: &Path) -> String { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("antigravity:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "antigravity:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// WAL-aware freshness stamp: a live agy writes into `-wal` without touching the main +/// file's mtime, so cache keys must take the max of both. +pub fn wal_mtime_ms(file: &Path) -> f64 { + let m = |p: &Path| { + fs::metadata(p) + .and_then(|md| md.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) + }; + let mut wal = file.as_os_str().to_os_string(); + wal.push("-wal"); + m(file).max(m(Path::new(&wal))) +} + +pub(super) fn open_ro(path: &Path) -> Option { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let _ = conn.busy_timeout(std::time::Duration::from_millis(400)); + Some(conn) +} diff --git a/src-tauri/src/antigravity/session.rs b/src-tauri/src/antigravity/session.rs new file mode 100644 index 0000000..81c8d0d --- /dev/null +++ b/src-tauri/src/antigravity/session.rs @@ -0,0 +1,196 @@ +// Whole-conversation normalization: the summaries DB row, the per-conversation steps DB, and +// the session/meta payloads the renderer consumes. Moved verbatim from antigravity.rs. + +use crate::history::Norm; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::roots::{open_ro, session_uuid, sidecar_meta, wal_mtime_ms}; +use super::steps::{find_str_with_prefix, push_step}; +use super::wire::{field_msg, field_str, ts_ms_of, wire_fields}; + +fn uri_to_path(uri: &str) -> String { + crate::grok::percent_decode(uri.strip_prefix("file://").unwrap_or(uri)) +} + +/// Workspace cwd for conversations the summaries DB hasn't indexed (a few percent of real +/// stores): the per-conversation trajectory_metadata_blob embeds the workspace file:// uri. +fn fallback_cwd(file: &Path) -> Option { + let conn = open_ro(file)?; + let blob: Vec = conn + .query_row("SELECT data FROM trajectory_metadata_blob LIMIT 1", [], |r| r.get(0)) + .ok()?; + find_str_with_prefix(&blob, "file://", 0).map(|u| uri_to_path(&u)) +} + +/// Title-of-last-resort for un-indexed conversations: the first user step's prose. +fn first_user_step_text(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let user = field_msg(&fields, 19)?; + let text = field_str(&user, 2)?; + let t: String = text.split_whitespace().collect::>().join(" "); + if t.is_empty() { + None + } else { + Some(t.chars().take(90).collect()) + } +} + +/// One conversation's summaries-DB row: (title, preview, first workspace path, step_count). +fn summaries_row(file: &Path) -> Option<(String, String, Option, i64)> { + let root = file.parent()?.parent()?; + let conn = open_ro(&root.join("conversation_summaries.db"))?; + let uuid = session_uuid(file); + conn.query_row( + "SELECT title, preview, workspace_uris, step_count FROM conversation_summaries WHERE conversation_id = ?1", + [&uuid], + |row| { + let title: String = row.get(0).unwrap_or_default(); + let preview: String = row.get(1).unwrap_or_default(); + let uris: String = row.get(2).unwrap_or_default(); + let steps: i64 = row.get(3).unwrap_or(0); + Ok((title, preview, uris, steps)) + }, + ) + .ok() + .map(|(title, preview, uris, steps)| { + let cwd = serde_json::from_str::(&uris) + .ok() + .and_then(|v| v.as_array().and_then(|a| a.first().cloned())) + .and_then(|u| u.as_str().map(|s| s.to_string())) + .map(|u| uri_to_path(&u)); + (title, preview, cwd, steps) + }) +} + +/// Read + normalize a conversation DB into the renderer's message model. +pub fn normalize_db(file: &Path) -> Norm { + let mut n = Norm::default(); + if let Some((_, _, cwd, _)) = summaries_row(file) { + n.cwd = cwd; + } + if n.cwd.is_none() { + n.cwd = fallback_cwd(file); + } + n.session_id = Some(session_uuid(file)); + let conn = match open_ro(file) { + Some(c) => c, + None => return n, + }; + let mut stmt = match conn.prepare("SELECT step_payload FROM steps ORDER BY idx") { + Ok(s) => s, + Err(_) => return n, + }; + let rows = stmt.query_map([], |row| row.get::<_, Vec>(0)); + if let Ok(rows) = rows { + for payload in rows.flatten() { + push_step(&mut n, &payload); + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} + +/// Creation stamp (ms) from the first step's timestamp — content-derived, immune to file +/// rewrites, matching record_created_ms semantics for jsonl sources. +fn first_step_ms(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let meta5 = field_msg(&fields, 5)?; + ts_ms_of(&meta5, 1) +} + +/// List-row meta — summaries DB + first-step timestamp; never parses the full step log. +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum = summaries_row(file); + let (sum_title, preview, cwd) = match &sum { + Some((t, p, c, _)) => (t.trim().to_string(), p.trim().to_string(), c.clone()), + None => (String::new(), String::new(), None), + }; + let cwd = cwd.or_else(|| fallback_cwd(file)); + let auto_title: String = if !sum_title.is_empty() { + sum_title + } else if !preview.is_empty() { + preview.chars().take(90).collect() + } else { + first_user_step_text(file).unwrap_or_default() + }; + let created = first_step_ms(file).unwrap_or_else(|| crate::history::created_ms(file)); + Some(json!({ + "id": format!("antigravity:{}", uuid), + "file": file.to_string_lossy(), + "source": "antigravity", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": uuid, + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": Value::Null, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": wal_mtime_ms(file), + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here — the source is SQLite, not jsonl). +pub fn session_from(file: &str) -> Value { + let path = Path::new(file); + let n = normalize_db(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum = summaries_row(path); + let sum_title = sum + .as_ref() + .map(|(t, _, _, _)| t.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("antigravity:{}", uuid), + "file": file, + "source": "antigravity", + "assistant": "Antigravity", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": uuid, + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/antigravity/steps.rs b/src-tauri/src/antigravity/steps.rs new file mode 100644 index 0000000..a2c9756 --- /dev/null +++ b/src-tauri/src/antigravity/steps.rs @@ -0,0 +1,127 @@ +// One `steps` row → renderer messages, plus the depth-first string probe the cwd fallback uses. +// Moved verbatim from antigravity.rs. + +use crate::history::Norm; +use serde_json::{json, Value}; + +use super::content::{b64_encode, map_tool}; +use super::wire::{ + field_bytes, field_msg, field_str, field_varint, ts_of, wire_fields, Wire, +}; + +/// Decode one step row into zero or more renderer messages, accumulating usage into `n`. +pub(super) fn push_step(n: &mut Norm, payload: &[u8]) { + let fields = match wire_fields(payload) { + Some(f) => f, + None => return, + }; + let meta5 = field_msg(&fields, 5).unwrap_or_default(); + let ts = ts_of(&meta5, 1); + let with_ts = |mut m: Value| { + if let Some(t) = &ts { + m["ts"] = json!(t); + } + m + }; + + // user turn: #19 {2: text, 9: attachments {1 mime, 2 bytes, 5 path}} + if let Some(user) = field_msg(&fields, 19) { + let mut blocks: Vec = vec![]; + if let Some(text) = field_str(&user, 2) { + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + for (f, w) in &user { + if *f != 9 { + continue; + } + if let Wire::Bytes(b) = w { + if let Some(att) = wire_fields(b) { + let mime = field_str(&att, 1).unwrap_or_default(); + let data = field_bytes(&att, 2); + match data { + // cap embedded images at 8 MB raw — larger ones degrade to a path note + Some(bytes) if mime.starts_with("image/") && bytes.len() <= 8_000_000 => { + blocks.push(json!({ + "type": "image", + "source": { "type": "base64", "media_type": mime, "data": b64_encode(bytes) } + })); + } + _ => { + if let Some(p) = field_str(&att, 5) { + blocks.push(json!({ "type": "text", "text": format!("[attachment: {}]", p) })); + } + } + } + } + } + } + if !blocks.is_empty() { + n.messages.push(with_ts(json!({ "role": "user", "content": blocks }))); + } + return; + } + + // tool call: #5.4 {1 id, 2 name, 3 args-json} (results are stored opaquely — omitted) + if let Some(call) = field_msg(&meta5, 4) { + let name = field_str(&call, 2).unwrap_or_else(|| "tool".into()); + let args: Value = field_str(&call, 3) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let (tname, input) = map_tool(&name, &args); + let id = field_str(&call, 1).unwrap_or_default(); + n.messages.push(with_ts(json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }))); + return; + } + + // model turn: #20.1 assistant text; #5.9 {2 input, 3 output} token stats + let turn20 = field_msg(&fields, 20); + let text = turn20.as_ref().and_then(|t| field_str(t, 1).or_else(|| field_str(t, 8))); + let stats = field_msg(&meta5, 9); + let usage = stats.as_ref().map(|st| { + let input = field_varint(st, 2).unwrap_or(0) as i64; + let output = field_varint(st, 3).unwrap_or(0) as i64; + json!({ "inputTokens": input, "outputTokens": output, "cacheRead": 0, "cacheCreation": 0 }) + }); + if let Some(text) = text { + if !text.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); + if let Some(u) = &usage { + let input = u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + if input + output > 0 { + m["usage"] = u.clone(); + let t = n.totals.as_object_mut().unwrap(); + t["in"] = json!(t["in"].as_i64().unwrap_or(0) + input); + t["out"] = json!(t["out"].as_i64().unwrap_or(0) + output); + t["turns"] = json!(t["turns"].as_i64().unwrap_or(0) + 1); + } + } + n.messages.push(with_ts(m)); + } + } +} + +/// Depth-first search for the first utf8 string field with `prefix` anywhere in a message tree. +pub(super) fn find_str_with_prefix(buf: &[u8], prefix: &str, depth: u8) -> Option { + let fields = wire_fields(buf)?; + for (_, w) in &fields { + if let Wire::Bytes(b) = w { + if let Ok(s) = std::str::from_utf8(b) { + if s.starts_with(prefix) { + return Some(s.to_string()); + } + } + if depth < 6 { + if let Some(found) = find_str_with_prefix(b, prefix, depth + 1) { + return Some(found); + } + } + } + } + None +} diff --git a/src-tauri/src/antigravity/tests.rs b/src-tauri/src/antigravity/tests.rs new file mode 100644 index 0000000..a153ff7 --- /dev/null +++ b/src-tauri/src/antigravity/tests.rs @@ -0,0 +1,136 @@ +use super::content::b64_encode; +use super::roots::looks_agy_path; +use super::steps::push_step; +use crate::history::Norm; +use std::path::Path; + +// hand-rolled wire encoding helpers (tests only) +fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } +} +fn tag(field: u32, wt: u8, out: &mut Vec) { + enc_varint(((field as u64) << 3) | wt as u64, out); +} +fn put_varint(field: u32, v: u64, out: &mut Vec) { + tag(field, 0, out); + enc_varint(v, out); +} +fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + tag(field, 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); +} +fn put_str(field: u32, s: &str, out: &mut Vec) { + put_bytes(field, s.as_bytes(), out); +} +fn ts_msg(secs: u64) -> Vec { + let mut m = vec![]; + put_varint(1, secs, &mut m); + put_varint(2, 500_000_000, &mut m); + m +} + +fn user_step(text: &str) -> Vec { + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_237), &mut meta5); + let mut u19 = vec![]; + put_str(2, text, &mut u19); + let mut att = vec![]; + put_str(1, "image/png", &mut att); + put_bytes(2, b"ABC", &mut att); + put_str(5, "/tmp/x.png", &mut att); + put_bytes(9, &att, &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + step +} + +fn tool_step() -> Vec { + let mut call = vec![]; + put_str(1, "call-9", &mut call); + put_str(2, "run_command", &mut call); + put_str(3, "{\"CommandLine\":\"ls -la\",\"Cwd\":\"/tmp\",\"toolSummary\":\"Run\"}", &mut call); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_240), &mut meta5); + put_bytes(4, &call, &mut meta5); + let mut step = vec![]; + put_varint(1, 21, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + step +} + +fn gen_step(text: &str) -> Vec { + let mut stats = vec![]; + put_varint(1, 1132, &mut stats); + put_varint(2, 20245, &mut stats); + put_varint(3, 346, &mut stats); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_242), &mut meta5); + put_bytes(9, &stats, &mut meta5); + let mut t20 = vec![]; + put_str(1, text, &mut t20); + let mut step = vec![]; + put_varint(1, 15, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(20, &t20, &mut step); + step +} + +#[test] +fn decodes_steps() { + let mut n = Norm::default(); + push_step(&mut n, &user_step("修复登录")); + push_step(&mut n, &tool_step()); + push_step(&mut n, &gen_step("已修复。")); + assert_eq!(n.messages.len(), 3); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[0]["content"][1]["source"]["data"], "QUJD"); + let tool = &n.messages[1]["content"][0]; + assert_eq!(tool["name"], "Bash"); + assert_eq!(tool["input"]["command"], "ls -la"); + assert_eq!(tool["id"], "call-9"); + assert_eq!(n.messages[2]["content"][0]["text"], "已修复。"); + assert_eq!(n.messages[2]["usage"]["inputTokens"], 20245); + assert_eq!(n.messages[2]["usage"]["outputTokens"], 346); + assert_eq!(n.totals["in"], 20245); + assert_eq!(n.totals["turns"], 1); + assert!(n.messages[0]["ts"].as_str().unwrap().starts_with("2026-")); +} + +#[test] +fn garbage_payload_is_skipped() { + let mut n = Norm::default(); + push_step(&mut n, &[0xff, 0x00, 0x13, 0x37]); + push_step(&mut n, b""); + assert!(n.messages.is_empty()); +} + +#[test] +fn b64_matches_reference() { + assert_eq!(b64_encode(b"ABC"), "QUJD"); + assert_eq!(b64_encode(b"AB"), "QUI="); + assert_eq!(b64_encode(b"A"), "QQ=="); + assert_eq!(b64_encode(b""), ""); +} + +#[test] +fn detects_paths() { + assert!(looks_agy_path(Path::new("/x/antigravity-cli/conversations/ab-1.db"))); + assert!(!looks_agy_path(Path::new("/x/antigravity-cli/conversation_summaries.db"))); + assert!(!looks_agy_path(Path::new("/x/conversations/notes.txt"))); +} diff --git a/src-tauri/src/antigravity/wire.rs b/src-tauri/src/antigravity/wire.rs new file mode 100644 index 0000000..d3cbdcd --- /dev/null +++ b/src-tauri/src/antigravity/wire.rs @@ -0,0 +1,106 @@ +// The schema-less protobuf wire walker used to recover a step payload's stable fields. Moved +// verbatim from antigravity.rs. + +// ---- protobuf wire walker (schema-less) ---- + +pub(super) enum Wire { + Varint(u64), + Bytes(Vec), + Fixed, +} + +/// One message level → (field number, value) pairs. None when the buffer isn't a valid message. +pub(super) fn wire_fields(buf: &[u8]) -> Option> { + let mut out = vec![]; + let mut i = 0usize; + fn varint(buf: &[u8], i: &mut usize) -> Option { + let mut v: u64 = 0; + let mut shift = 0u32; + loop { + let b = *buf.get(*i)?; + *i += 1; + v |= ((b & 0x7f) as u64) << shift; + if b & 0x80 == 0 { + return Some(v); + } + shift += 7; + if shift > 63 { + return None; + } + } + } + while i < buf.len() { + let tag = varint(buf, &mut i)?; + let (field, wt) = ((tag >> 3) as u32, tag & 7); + if field == 0 { + return None; + } + match wt { + 0 => out.push((field, Wire::Varint(varint(buf, &mut i)?))), + 2 => { + let len = varint(buf, &mut i)? as usize; + if i + len > buf.len() { + return None; + } + out.push((field, Wire::Bytes(buf[i..i + len].to_vec()))); + i += len; + } + 5 => { + if i + 4 > buf.len() { + return None; + } + i += 4; + out.push((field, Wire::Fixed)); + } + 1 => { + if i + 8 > buf.len() { + return None; + } + i += 8; + out.push((field, Wire::Fixed)); + } + _ => return None, + } + } + Some(out) +} + +pub(super) fn field_bytes<'a>(fields: &'a [(u32, Wire)], no: u32) -> Option<&'a [u8]> { + fields.iter().find_map(|(f, w)| match w { + Wire::Bytes(b) if *f == no => Some(b.as_slice()), + _ => None, + }) +} + +pub(super) fn field_msg(fields: &[(u32, Wire)], no: u32) -> Option> { + wire_fields(field_bytes(fields, no)?) +} + +pub(super) fn field_str(fields: &[(u32, Wire)], no: u32) -> Option { + let b = field_bytes(fields, no)?; + let s = std::str::from_utf8(b).ok()?; + Some(s.to_string()) +} + +pub(super) fn field_varint(fields: &[(u32, Wire)], no: u32) -> Option { + fields.iter().find_map(|(f, w)| match w { + Wire::Varint(v) if *f == no => Some(*v), + _ => None, + }) +} + +/// `{#1 seconds, #2 nanos}` timestamp message → RFC3339 (ms precision). +pub(super) fn ts_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as i64; + let nanos = field_varint(&m, 2).unwrap_or(0) as u32; + let dt = chrono::DateTime::from_timestamp(secs, nanos)?; + Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) +} + +pub(super) fn ts_ms_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as f64; + let nanos = field_varint(&m, 2).unwrap_or(0) as f64; + Some(secs * 1000.0 + (nanos / 1_000_000.0).floor()) +} diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs deleted file mode 100644 index 0ab17c1..0000000 --- a/src-tauri/src/codex.rs +++ /dev/null @@ -1,1624 +0,0 @@ -// Codex CLI session support — reads OpenAI Codex's on-disk rollout logs -// (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME -// session/message shape the renderer consumes for Claude Code history, so the 对话 view -// (list / detail / search / live-follow / export) browses both without renderer forks. -// -// A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, -// response_item, event_msg, compacted}. Conversation content lives in response_item payloads -// (message / reasoning / function_call / function_call_output / local_shell_call / -// custom_tool_call / web_search_call); event_msg mostly duplicates that content, but token_count -// carries usage and user_message supplies a bounded title fallback for image-heavy first turns. -// Very old Codex builds wrote -// payload objects directly per line (no envelope) — handled by treating such a line as its -// own payload. -// -// Tool calls are mapped onto the tool vocabulary the renderer already draws natively: -// shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, -// web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). -// -// Title/tags/soft-delete: Codex files belong to another tool, so per-conversation -// customization never rewrites them (unlike Claude's in-file `__ccbud__`) — it lives in a -// sidecar map at `~/.ccbud/codex-meta.json`, keyed by the rollout file stem. - -#![allow(dead_code)] - -use crate::history::{image_block, Norm}; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use serde_json::{json, Value}; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time -/// startup migration that adds it to `historyDirs`. Honors CODEX_HOME like the codex CLI. -pub fn codex_label() -> String { - let root = sessions_root(); - let dir = root.parent().unwrap_or(&root); - crate::store::collapse_home(&dir.to_string_lossy()) -} - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Codex's DEFAULT sessions tree. Honors CODEX_HOME the way the codex CLI does. Only the -/// auto-add migration keys off this — browsing walks `/sessions` of every configured dir. -pub fn sessions_root() -> PathBuf { - match std::env::var("CODEX_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("sessions"), - _ => home().join(".codex").join("sessions"), - } -} - -pub fn root_exists() -> bool { - sessions_root().is_dir() -} - -fn codex_home_for_rollout(file: &Path) -> Option { - file.ancestors() - .find(|dir| { - matches!( - dir.file_name().and_then(|name| name.to_str()), - Some("sessions") | Some("archived_sessions") - ) - }) - .and_then(Path::parent) - .map(Path::to_path_buf) -} - -fn resolve_sqlite_home_path(raw: &str, codex_home: &Path) -> Option { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - if raw == "~" { - return Some(home()); - } - if let Some(rest) = raw.strip_prefix("~/") { - return Some(home().join(rest)); - } - let path = PathBuf::from(raw); - Some(if path.is_absolute() { path } else { codex_home.join(path) }) -} - -fn configured_sqlite_home(codex_home: &Path) -> Option { - let raw = fs::read_to_string(codex_home.join("config.toml")).ok()?; - let doc = raw.parse::().ok()?; - resolve_sqlite_home_path(doc.get("sqlite_home")?.as_str()?, codex_home) -} - -/// Codex treats the completed state DB's rollout_path as authoritative for a canonical thread id. -/// This is intentionally queried only when ccbud has found duplicate physical candidates, so the -/// normal list walk never opens SQLite per row. A missing/stale/incomplete DB simply means callers -/// fall back to validated metadata + mtime, just as Codex does during scan-and-repair. -pub fn preferred_rollout_path(file: &Path, thread_id: &str) -> Option { - let codex_home = codex_home_for_rollout(file)?; - let sqlite_home = configured_sqlite_home(&codex_home) - .or_else(|| { - std::env::var("CODEX_SQLITE_HOME") - .ok() - .and_then(|value| resolve_sqlite_home_path(&value, &codex_home)) - }) - .unwrap_or(codex_home); - let db = sqlite_home.join("state_5.sqlite"); - if !db.is_file() { - return None; - } - let conn = Connection::open_with_flags( - db, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .ok()?; - let status = conn - .query_row( - "SELECT status FROM backfill_state WHERE id = 1", - [], - |row| row.get::<_, String>(0), - ) - .optional() - .ok()??; - if status != "complete" { - return None; - } - let rollout = conn - .query_row( - "SELECT rollout_path FROM threads WHERE id = ?1 AND archived = 0", - [thread_id], - |row| row.get::<_, String>(0), - ) - .optional() - .ok()??; - let rollout = PathBuf::from(rollout); - rollout.is_file().then_some(rollout) -} - -/// Walk every rollout .jsonl under a sessions tree (date-sharded YYYY/MM/DD, but walked -/// generically so a layout change doesn't lose sessions). Depth-capped against cycles. -pub fn walk_sessions(root: &Path, mut cb: F) { - fn walk(dir: &Path, depth: u32, cb: &mut F) { - if depth > 6 { - return; - } - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_dir() { - walk(&p, depth + 1, cb); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p); - } - } - } - walk(root, 0, &mut cb); -} - -/// Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies -/// imported into the app store, where the path no longer says so). Claude Code records never -/// use these type tags, and old-format bare Codex items lack Claude's `.message` wrapper. -pub fn looks_codex(recs: &[Value]) -> bool { - recs.iter().take(8).any(|r| { - match r.get("type").and_then(|v| v.as_str()) { - Some("session_meta") | Some("turn_context") | Some("event_msg") | Some("compacted") => true, - Some("response_item") => r.get("payload").is_some(), - // old envelope-less rollout: response items at the top level - Some("message") | Some("function_call") | Some("function_call_output") - | Some("reasoning") | Some("local_shell_call") => r.get("message").is_none(), - _ => r.get("record_type").is_some(), - } - }) -} - -/// (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. -fn split_line(rec: &Value) -> (&str, &Value, Option<&str>) { - let ts = rec.get("timestamp").and_then(|v| v.as_str()); - let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if let Some(p) = rec.get("payload") { - return (t, p, ts); - } - match t { - "message" | "function_call" | "function_call_output" | "reasoning" | "local_shell_call" - | "custom_tool_call" | "custom_tool_call_output" | "web_search_call" => ("response_item", rec, ts), - // old first line: bare SessionMeta {id, timestamp, instructions, cwd?, git?} - "" if rec.get("id").is_some() && rec.get("timestamp").is_some() => ("session_meta", rec, ts), - _ => (t, rec, ts), - } -} - -#[derive(Default)] -struct CanonicalThreadMeta { - thread_id: Option, - root_session_id: Option, - parent_thread_id: Option, - forked_from_id: Option, - is_subagent: bool, - agent_path: Option, - agent_nickname: Option, - agent_role: Option, - agent_depth: Option, -} - -// The first SessionMeta is canonical for the physical rollout. Subagent/fork rollouts can copy -// ancestor SessionMeta records behind it, and every thread in that tree intentionally shares the -// same session_id. The unique thread key is the first meta's id. -fn canonical_thread_meta(payload: &Value) -> CanonicalThreadMeta { - let subagent = payload - .get("source") - .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) - .or_else(|| { - payload - .get("thread_source") - .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) - }); - let detail = subagent.and_then(|source| { - ["thread_spawn", "review", "compact", "other"] - .iter() - .find_map(|key| source.get(*key).filter(|value| value.is_object())) - .or_else(|| source.as_object().and_then(|object| object.values().find(|value| value.is_object()))) - }); - let string = |value: Option<&Value>| { - value - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - }; - let thread_id = string( - payload - .get("id") - .or_else(|| payload.get("thread_id")), - ); - let root_session_id = string(payload.get("session_id")).or_else(|| thread_id.clone()); - let parent_thread_id = string( - payload - .get("parent_thread_id") - .or_else(|| detail.and_then(|value| value.get("parent_thread_id"))), - ); - let is_subagent = subagent.is_some() - || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") - || payload - .get("agent_path") - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()) - || payload - .get("agent_nickname") - .and_then(Value::as_str) - .is_some_and(|value| !value.is_empty()) - || (parent_thread_id.is_some() && thread_id != root_session_id); - CanonicalThreadMeta { - thread_id, - root_session_id, - parent_thread_id, - forked_from_id: string(payload.get("forked_from_id")), - is_subagent, - // Current Codex stores the canonical Agent identity on SessionMeta itself. Older - // rollouts only carried it inside source.subagent., so keep that as a fallback. - agent_path: string( - payload - .get("agent_path") - .or_else(|| detail.and_then(|value| value.get("agent_path"))), - ), - agent_nickname: string( - payload - .get("agent_nickname") - .or_else(|| detail.and_then(|value| value.get("agent_nickname"))), - ), - agent_role: string( - payload - .get("agent_role") - .or_else(|| payload.get("agent_type")) - .or_else(|| detail.and_then(|value| value.get("agent_role"))) - .or_else(|| detail.and_then(|value| value.get("agent_type"))), - ), - agent_depth: detail.and_then(|value| value.get("depth")).and_then(|value| value.as_i64()), - } -} - -/// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't -/// human prose — hidden from the timeline, exactly like Claude's isMeta records. -fn is_meta_user_text(t: &str) -> bool { - let t = t.trim_start(); - ["", "", " bool { - let source = t.trim_start(); - let Some(heading) = source.strip_prefix('#') else { return false; }; - let heading = heading.trim_start().to_ascii_lowercase(); - heading.starts_with("agents.md instructions for ") - && heading.contains("") -} - -// Codex serializes a loaded Skill as a synthetic user turn. Keep the snapshot embedded in the -// rollout rather than reading the current SKILL.md from disk: historical sessions must show the -// exact instructions that were loaded at the time. Anchoring the whole envelope leaves quoted -// markup in normal user prose untouched. -fn skill_load_block(t: &str) -> Option { - static SKILL_ENVELOPE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = SKILL_ENVELOPE_RE.get_or_init(|| { - regex::Regex::new( - r"(?is)^\s*\s*(.*?)\s*(.*?)(.*)\s*$", - ) - .unwrap() - }); - let captures = re.captures(t)?; - let name = captures.get(1)?.as_str().trim(); - let path = captures.get(2)?.as_str().trim(); - if name.is_empty() || path.is_empty() { - return None; - } - let snapshot = captures.get(3)?.as_str(); - Some(json!({ - "type": "skill_load", - "name": name, - "path": path, - "snapshot": snapshot, - })) -} - -fn joined_text(content: &Value, kinds: &[&str]) -> String { - let arr = match content.as_array() { - Some(a) => a, - None => return content.as_str().unwrap_or("").to_string(), - }; - arr.iter() - .filter(|b| kinds.contains(&b.get("type").and_then(|t| t.as_str()).unwrap_or(""))) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n") -} - -// Codex surrounds each real input_image with text-only transport tags. Replace the opening tag -// with its safe display name (`[Image #1]`) and drop the closing tag; image_block still carries -// the actual bitmap to the renderer. -fn image_transport_label(text: &str) -> Option { - let source = text.trim(); - let lower = source.to_ascii_lowercase(); - if !lower.starts_with("') { - return None; - } - let boundary = source.as_bytes().get(6).copied(); - if !matches!(boundary, Some(b'>')) && !boundary.map(|b| b.is_ascii_whitespace()).unwrap_or(false) { - return None; - } - - let mut label = None; - if let Some(pos) = lower.find("name") { - let rest = source[pos + 4..].trim_start(); - if let Some(value) = rest.strip_prefix('=') { - let value = value.trim_start(); - label = if let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') { - value[quote.len_utf8()..] - .find(quote) - .map(|end| value[quote.len_utf8()..quote.len_utf8() + end].to_string()) - } else if value.starts_with('[') { - value.find(']').map(|end| value[..=end].to_string()) - } else { - Some(value.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or("").to_string()) - }; - } - } - Some(label.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[Image]".to_string())) -} - -fn joined_user_text(content: &Value) -> String { - let arr = match content.as_array() { - Some(a) => a, - None => return content.as_str().unwrap_or("").to_string(), - }; - let has_image = arr - .iter() - .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")); - arr.iter() - .filter(|b| matches!(b.get("type").and_then(|t| t.as_str()), Some("input_text") | Some("text"))) - .filter_map(|b| { - let text = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); - if has_image { - if text.trim().eq_ignore_ascii_case("") { - return None; - } - if let Some(label) = image_transport_label(text) { - return Some(label); - } - } - if text.is_empty() { None } else { Some(text.to_string()) } - }) - .collect::>() - .join("\n") -} - -fn event_user_display_text(payload: &Value) -> String { - let message = payload.get("message").and_then(|v| v.as_str()).unwrap_or("").trim(); - let image_count = payload.get("images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0) - + payload.get("local_images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0); - let labels = (1..=image_count) - .map(|i| format!("[Image #{}]", i)) - .collect::>() - .join(" "); - format!("{}{}{}", labels, if !labels.is_empty() && !message.is_empty() { " " } else { "" }, message) - .trim() - .to_string() -} - -fn event_user_title_from_record(rec: &Value) -> String { - let (ty, payload, _) = split_line(rec); - if ty != "event_msg" || payload.get("type").and_then(|v| v.as_str()) != Some("user_message") { - return String::new(); - } - let text = event_user_display_text(payload); - if text.is_empty() { - String::new() - } else { - crate::history::first_user_text(&[json!({ - "role": "user", - "content": [{ "type": "text", "text": text }], - })]) - } -} - -fn first_event_user_title(recs: &[Value]) -> String { - recs.iter() - .map(event_user_title_from_record) - .find(|title| !title.is_empty()) - .unwrap_or_default() -} - -fn append_scan_segment(line: &mut Vec, dropping: &mut bool, segment: &[u8], max_line: usize) { - if *dropping || segment.is_empty() { - return; - } - if line.len().saturating_add(segment.len()) > max_line { - line.clear(); - *dropping = true; - } else { - line.extend_from_slice(segment); - } -} - -fn event_user_title_from_line(line: &[u8]) -> String { - let line = line.strip_suffix(b"\r").unwrap_or(line); - serde_json::from_slice::(line) - .map(|rec| event_user_title_from_record(&rec)) - .unwrap_or_default() -} - -// List metadata normally parses only the first 128 KiB. If an image-first response_item is a -// larger single JSON line, stream past it and read the following compact user_message event. -fn scan_event_user_title(file: &Path) -> String { - const MAX_SCAN: usize = 64 * 1024 * 1024; - const MAX_LINE: usize = 256 * 1024; - let input = match fs::File::open(file) { - Ok(file) => file, - Err(_) => return String::new(), - }; - let mut reader = BufReader::new(input); - let mut line: Vec = vec![]; - let mut dropping = false; - let mut scanned = 0usize; - while scanned < MAX_SCAN { - let available = match reader.fill_buf() { - Ok(buf) if !buf.is_empty() => buf, - Ok(_) | Err(_) => break, - }; - let take = available.len().min(MAX_SCAN - scanned); - let mut start = 0usize; - for i in 0..take { - if available[i] == b'\n' { - append_scan_segment(&mut line, &mut dropping, &available[start..i], MAX_LINE); - let title = if dropping { String::new() } else { event_user_title_from_line(&line) }; - line.clear(); - dropping = false; - if !title.is_empty() { - return title; - } - start = i + 1; - } - } - append_scan_segment(&mut line, &mut dropping, &available[start..take], MAX_LINE); - reader.consume(take); - scanned = scanned.saturating_add(take); - } - if dropping { String::new() } else { event_user_title_from_line(&line) } -} - -/// argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. -fn join_argv(cmd: &Value) -> String { - if let Some(s) = cmd.as_str() { - return s.to_string(); - } - let arr = match cmd.as_array() { - Some(a) => a, - None => return String::new(), - }; - let parts: Vec = arr.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect(); - if parts.len() == 3 - && ["bash", "sh", "zsh", "dash"].contains(&parts[0].as_str()) - && ["-lc", "-c"].contains(&parts[1].as_str()) - { - return parts[2].clone(); - } - parts - .iter() - .map(|p| { - if p.is_empty() || p.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { - format!("{:?}", p) // debug-quote args with spaces/quotes - } else { - p.clone() - } - }) - .collect::>() - .join(" ") -} - -/// Codex tool name + parsed arguments → (renderer tool name, renderer input). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - match name { - "shell" | "local_shell" | "container.exec" => { - let mut input = json!({ "command": join_argv(args.get("command").unwrap_or(&Value::Null)) }); - let desc = if !s("justification").is_empty() { s("justification") } else { s("workdir") }; - if !desc.is_empty() { - input["description"] = json!(desc); - } - ("Bash".into(), input) - } - "shell_command" => ("Bash".into(), json!({ "command": s("command") })), - "exec_command" => { - let cmd = if !s("cmd").is_empty() { s("cmd") } else { s("command") }; - ("Bash".into(), json!({ "command": cmd })) - } - "apply_patch" => { - let patch = if !s("input").is_empty() { s("input") } else { s("patch") }; - ("ApplyPatch".into(), json!({ "patch": patch })) - } - "update_plan" => { - let todos: Vec = args - .get("plan") - .and_then(|p| p.as_array()) - .map(|a| { - a.iter() - .map(|st| { - json!({ - "content": st.get("step").and_then(|v| v.as_str()).unwrap_or(""), - "status": st.get("status").and_then(|v| v.as_str()).unwrap_or("pending"), - }) - }) - .collect() - }) - .unwrap_or_default(); - ("TodoWrite".into(), json!({ "todos": todos })) - } - "view_image" => ("Read".into(), json!({ "file_path": s("path") })), - "web_search" => ("WebSearch".into(), json!({ "query": s("query") })), - _ => ( - name.to_string(), - if args.is_object() { args.clone() } else { json!({}) }, - ), - } -} - -// ---- code-mode `exec` scripts (custom_tool_call name "exec") ---- -// -// Codex code-mode (gpt-*-sol) emits one custom tool named `exec` whose input is JavaScript -// calling `tools.*` (exec_command / write_stdin / …). The dominant shape by far is a single -// `tools.exec_command({cmd, workdir, …})` plus print plumbing (`text(r.output);` and friends) — -// semantically just a shell run, so it renders as the familiar Bash card (command + workdir). -// Anything else (write_stdin, Promise.all batches, real orchestration code) keeps the whole -// script as a `Script` card the renderer shows as highlighted JavaScript. Extraction is -// conservative: any parse doubt falls back to the Script card, never to a wrong command. - -/// First `{…}` object literal at/after `from`, brace-matched with double-quoted strings (and -/// their escapes) treated as opaque — shell commands are full of braces and quotes. -fn extract_object(s: &str, from: usize) -> Option<(usize, usize)> { - let start = from + s[from..].find('{')?; - let (mut depth, mut in_str, mut esc) = (0i32, false, false); - for (i, &b) in s.as_bytes().iter().enumerate().skip(start) { - if in_str { - if esc { - esc = false; - } else if b == b'\\' { - esc = true; - } else if b == b'"' { - in_str = false; - } - continue; - } - match b { - b'"' => in_str = true, - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - return Some((start, i)); - } - } - _ => {} - } - } - None -} - -/// Quote bare JS object keys (`{cmd: …}` → `{"cmd": …}`) outside string context so serde can -/// parse code-mode's object-literal arguments; double-quoted string contents pass verbatim. -fn quote_js_keys(s: &str) -> String { - let chars: Vec = s.chars().collect(); - let mut out = String::with_capacity(s.len() + 16); - let (mut in_str, mut esc) = (false, false); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - if in_str { - if esc { - esc = false; - } else if c == '\\' { - esc = true; - } else if c == '"' { - in_str = false; - } - out.push(c); - i += 1; - continue; - } - if c == '"' { - in_str = true; - out.push(c); - i += 1; - continue; - } - if c == '{' || c == ',' { - out.push(c); - i += 1; - while i < chars.len() && chars[i].is_whitespace() { - out.push(chars[i]); - i += 1; - } - let start = i; - while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - if i > start { - let mut j = i; - while j < chars.len() && chars[j].is_whitespace() { - j += 1; - } - let ident: String = chars[start..i].iter().collect(); - if j < chars.len() && chars[j] == ':' { - out.push('"'); - out.push_str(&ident); - out.push('"'); - } else { - out.push_str(&ident); - } - } - continue; - } - out.push(c); - i += 1; - } - out -} - -/// The `{…}` argument of a tools.* call: strict JSON first (code-mode usually emits JSON), -/// then a bare-key-quoted retry for JS object literals. -fn parse_call_args(obj: &str) -> Option { - serde_json::from_str::(obj) - .ok() - .or_else(|| serde_json::from_str::("e_js_keys(obj)).ok()) - .filter(|v| v.is_object()) -} - -/// Code-mode exec script → renderer tool card (see module comment above). -fn map_exec_script(script: &str) -> (String, Value) { - let fallback = || ("Script".to_string(), json!({ "code": script })); - // exactly one tools.* call, and it must be exec_command (a cmd string that itself mentions - // "tools." trips the count — conservative fallback, never a wrong command) - if script.matches("tools.").count() != 1 { - return fallback(); - } - let call = match script.find("tools.exec_command(") { - Some(i) => i, - None => return fallback(), - }; - // prefix must be assignment/await plumbing only: `const r = await` / `let out = await` / `await` - let prefix: Vec<&str> = script[..call].split_whitespace().collect(); - let prefix_ok = match prefix.as_slice() { - [] | ["await"] => true, - [kw, _name, "=", "await"] => matches!(*kw, "const" | "let" | "var"), - _ => false, - }; - if !prefix_ok { - return fallback(); - } - let after = call + "tools.exec_command(".len(); - let (ostart, oend) = match extract_object(script, after) { - Some(span) => span, - None => return fallback(), - }; - if !script[after..ostart].trim().is_empty() { - return fallback(); - } - let args = match parse_call_args(&script[ostart..=oend]) { - Some(a) => a, - None => return fallback(), - }; - let cmd = args - .get("cmd") - .or_else(|| args.get("command")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - if cmd.is_empty() { - return fallback(); - } - // tail must close the call, then carry only print plumbing - let rest = script[oend + 1..].trim_start(); - let rest = match rest.strip_prefix(')') { - Some(r) => r, - None => return fallback(), - }; - let rest = rest.strip_prefix(';').unwrap_or(rest); - let plumbing = rest.lines().all(|l| { - let l = l.trim(); - l.is_empty() || l.starts_with("text(") || l.starts_with("if (") || l.starts_with("//") - }); - if !plumbing { - return fallback(); - } - let mut input = json!({ "command": cmd }); - if let Some(wd) = args.get("workdir").and_then(|v| v.as_str()) { - if !wd.is_empty() { - input["description"] = json!(wd); - } - } - ("Bash".into(), input) -} - -/// Error heuristic for code-mode exec output text: the runner's own status header -/// ("Script failed…" / "Exit code: N…"). -fn exec_text_err(text: &str) -> bool { - if text.starts_with("Script failed") { - return true; - } - if let Some(rest) = text.strip_prefix("Exit code: ") { - let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - return digits.parse::().map(|c| c != 0).unwrap_or(false); - } - false -} - -/// Tool output payload → (display text, is_error). Unwraps codex's JSON-wrapped shell output -/// ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. -fn shape_output(out: &Value) -> (String, bool) { - // structured payload: { content, success? } - if out.is_object() { - let text = out - .get("content") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| serde_json::to_string_pretty(out).unwrap_or_default()); - let err = out.get("success").and_then(|v| v.as_bool()) == Some(false); - return (text, err); - } - let s = out.as_str().unwrap_or("").to_string(); - if let Ok(v) = serde_json::from_str::(&s) { - if v.is_object() { - if let Some(o) = v.get("output").and_then(|x| x.as_str()) { - let code = v - .get("metadata") - .and_then(|m| m.get("exit_code")) - .and_then(|c| c.as_i64()) - .unwrap_or(0); - return (o.to_string(), code != 0); - } - if let Some(c) = v.get("content").and_then(|x| x.as_str()) { - let err = v.get("success").and_then(|x| x.as_bool()) == Some(false); - return (c.to_string(), err); - } - } - } - // code-mode runner header (older builds wrote it as a plain string): "Exit code: N…" / - // "Script failed…" - if exec_text_err(&s) { - return (s, true); - } - // exec_command header: "…\nProcess exited with code N\n…" near the top - let head: String = s.chars().take(240).collect(); - if let Some(pos) = head.find("exited with code ") { - let digits: String = head[pos + "exited with code ".len()..] - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - if let Ok(code) = digits.parse::() { - return (s, code != 0); - } - } - (s, false) -} - -/// Normalize parsed rollout records into the renderer's message model. -pub fn normalize(recs: &[Value]) -> Norm { - let mut messages: Vec = vec![]; - let (mut tin, mut tout, mut tcr, mut turns) = (0i64, 0i64, 0i64, 0i64); - let mut model: Option = None; - let mut cwd: Option = None; - let mut session_id: Option = None; - let mut thread_id: Option = None; - let mut parent_thread_id: Option = None; - let mut forked_from_id: Option = None; - let mut is_subagent = false; - let mut agent_path: Option = None; - let mut agent_nickname: Option = None; - let mut agent_role: Option = None; - let mut agent_depth: Option = None; - let mut saw_session_meta = false; - let mut git_branch: Option = None; - let mut version: Option = None; - - for rec in recs { - let (ty, p, ts) = split_line(rec); - let with_ts = |mut m: Value| { - if let Some(t) = ts { - m["ts"] = json!(t); - } - m - }; - match ty { - "session_meta" => { - if !saw_session_meta { - saw_session_meta = true; - let identity = canonical_thread_meta(p); - thread_id = identity.thread_id; - session_id = identity.root_session_id; - parent_thread_id = identity.parent_thread_id; - forked_from_id = identity.forked_from_id; - is_subagent = identity.is_subagent; - agent_path = identity.agent_path; - agent_nickname = identity.agent_nickname; - agent_role = identity.agent_role; - agent_depth = identity.agent_depth; - } - let sid = p - .get("session_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()); - if session_id.is_none() { - session_id = sid.map(|s| s.to_string()); - } - if cwd.is_none() { - cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if version.is_none() { - version = p.get("cli_version").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if git_branch.is_none() { - git_branch = p - .get("git") - .and_then(|g| g.get("branch")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - } - "turn_context" => { - if let Some(m) = p.get("model").and_then(|v| v.as_str()) { - model = Some(m.to_string()); - } - if cwd.is_none() { - cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - } - "compacted" => { - let text = p.get("message").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); - if !text.is_empty() { - messages.push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); - } - } - "event_msg" => match p.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "token_count" => { - let u = p.get("info").and_then(|i| i.get("last_token_usage")); - if let Some(u) = u { - let input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let cached = u.get("cached_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - if input + cached + output > 0 { - let usage = json!({ - "inputTokens": (input - cached).max(0), - "outputTokens": output, - "cacheRead": cached, - "cacheCreation": 0, - }); - tin += (input - cached).max(0); - tout += output; - tcr += cached; - turns += 1; - // Per-turn usage rides the turn's last assistant message (codex emits - // one token_count per model turn). - if let Some(m) = messages - .iter_mut() - .rev() - .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant") && m.get("usage").is_none()) - { - m["usage"] = usage; - } - } - } - } - "turn_aborted" => { - messages.push(with_ts(json!({ - "role": "user", - "content": [{ "type": "text", "text": "[Request interrupted by user]" }], - }))); - } - _ => {} - }, - "response_item" => { - let it = p.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match it { - "message" => { - let role = p.get("role").and_then(|v| v.as_str()).unwrap_or(""); - let content = p.get("content").cloned().unwrap_or(Value::Null); - if role == "assistant" { - let text = joined_text(&content, &["output_text", "text"]); - if !text.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - } else if role == "user" { - let text = joined_user_text(&content); - if let Some(skill) = skill_load_block(&text) { - messages.push(with_ts(json!({ - "role": "user", - "_meta": true, - "content": [skill], - }))); - continue; - } - if is_meta_user_text(&text) { - continue; - } - let mut blocks: Vec = vec![]; - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(arr) = content.as_array() { - for b in arr { - if b.get("type").and_then(|t| t.as_str()) == Some("input_image") { - if let Some(img) = b - .get("image_url") - .and_then(|u| u.as_str()) - .and_then(image_block) - { - blocks.push(img); - } - } - } - } - if !blocks.is_empty() { - let mut message = json!({ "role": "user", "content": blocks }); - if is_agents_bootstrap(&text) { - message["_meta"] = json!(true); - } - messages.push(with_ts(message)); - } - } // system / developer turns: harness plumbing, not conversation - } - "reasoning" => { - let mut txt = joined_text(&p.get("summary").cloned().unwrap_or(Value::Null), &["summary_text", "text"]); - let extra = joined_text(&p.get("content").cloned().unwrap_or(Value::Null), &["reasoning_text", "text"]); - if !extra.trim().is_empty() { - if !txt.trim().is_empty() { - txt.push_str("\n\n"); - } - txt.push_str(&extra); - } - if !txt.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - } - "function_call" => { - let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args: Value = p - .get("arguments") - .and_then(|v| v.as_str()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| p.get("arguments").cloned().unwrap_or(json!({}))); - let (tname, input) = map_tool(name, &args); - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "local_shell_call" => { - let cmd = p - .get("action") - .and_then(|a| a.get("command")) - .cloned() - .unwrap_or(Value::Null); - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": "Bash", "input": { "command": join_argv(&cmd) } }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "custom_tool_call" => { - let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let input_s = p.get("input").and_then(|v| v.as_str()).unwrap_or(""); - let (tname, input) = if name == "apply_patch" { - ("ApplyPatch".to_string(), json!({ "patch": input_s })) - } else if name == "exec" { - map_exec_script(input_s) - } else { - (name.to_string(), json!({ "input": input_s })) - }; - let id = p - .get("call_id") - .or_else(|| p.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - "function_call_output" | "custom_tool_call_output" => { - let out = p.get("output").cloned().unwrap_or(Value::Null); - let id = p.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); - // Newer code-mode outputs are block ARRAYS — {input_text} chunks (status - // header + stdout, concatenated verbatim) plus optional {input_image} - // screenshots, which become renderer image blocks. - let (content, err) = if let Some(arr) = out.as_array() { - let text: String = arr - .iter() - .filter(|b| { - matches!( - b.get("type").and_then(|t| t.as_str()), - Some("input_text") | Some("output_text") | Some("text") - ) - }) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect(); - let images: Vec = arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")) - .filter_map(|b| b.get("image_url").and_then(|u| u.as_str()).and_then(image_block)) - .collect(); - let err = exec_text_err(&text); - if images.is_empty() { - (json!(text), err) - } else { - let mut blocks = vec![json!({ "type": "text", "text": text })]; - blocks.extend(images); - (Value::Array(blocks), err) - } - } else { - let (text, err) = shape_output(&out); - (json!(text), err) - }; - let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": content }); - if err { - tr["is_error"] = json!(true); - } - messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); - } - "web_search_call" => { - let q = p - .get("action") - .and_then(|a| a.get("query")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let id = p - .get("id") - .or_else(|| p.get("call_id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut m = json!({ - "role": "assistant", - "content": [{ "type": "tool_use", "id": id, "name": "WebSearch", "input": { "query": q } }], - }); - if let Some(md) = &model { - m["modelActual"] = json!(md); - } - messages.push(with_ts(m)); - } - _ => {} - } - } - _ => {} - } - } - - let first_ts = messages - .first() - .and_then(|m| m.get("ts")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let last_ts = messages - .last() - .and_then(|m| m.get("ts")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Norm { - messages, - totals: json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": 0, "turns": turns }), - model, - first_ts, - last_ts, - cwd, - session_id, - thread_id, - parent_thread_id, - forked_from_id, - is_subagent, - agent_path, - agent_nickname, - agent_role, - agent_depth, - git_branch, - version, - } -} - -/// (cwd, canonical thread id) from a Codex head — used to name an imported store copy. -pub fn head_ids(recs: &[Value]) -> (Option, Option) { - for rec in recs { - let (ty, p, _) = split_line(rec); - if ty == "session_meta" { - let cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); - let sid = p - // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the - // unique rollout key; using session_id makes sibling imports collide. - .get("id") - .or_else(|| p.get("thread_id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - return (cwd, sid); - } - } - (None, None) -} - -// ---- sidecar customization (shared store, ~/.ccbud/codex-meta.json, keyed by rollout stem) ---- - -fn stem_of(file: &Path) -> String { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() -} - -/// (custom title, tags, deleted) for a codex session, from the sidecar. -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::codex_file(), &stem_of(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -/// set_ccbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), -/// persisted to the sidecar instead of the rollout file (never mutate another tool's data). -pub fn set_meta(file: &str, patch: &Value) -> Value { - let stem = stem_of(Path::new(file)); - if stem.is_empty() { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::codex_file(), &stem, patch) -} - -/// Drop a session's sidecar entry (after its rollout file is deleted forever). -pub fn remove_meta(file: &str) { - crate::sidecar::remove_meta(&crate::sidecar::codex_file(), &stem_of(Path::new(file))); -} - -// ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- - -fn subagent_title(n: &Norm) -> String { - if !n.is_subagent { - return String::new(); - } - let path = n - .agent_path - .as_deref() - .unwrap_or("") - .trim_start_matches('/') - .strip_prefix("root/") - .unwrap_or_else(|| n.agent_path.as_deref().unwrap_or("").trim_start_matches('/')); - let mut parts = Vec::new(); - if let Some(nickname) = n.agent_nickname.as_deref().filter(|value| !value.trim().is_empty()) { - parts.push(nickname.trim()); - } - if !path.is_empty() { - parts.push(path); - } - if parts.is_empty() { - "Codex subagent".to_string() - } else { - parts.join(" · ") - } -} - -fn is_canonical_thread_id(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == 36 - && [8usize, 13, 18, 23].into_iter().all(|index| bytes[index] == b'-') - && bytes - .iter() - .enumerate() - .all(|(index, byte)| [8usize, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit()) -} - -/// List-row meta from already-parsed head records. `dir_id` is `__codex__` for the live tree -/// or `__imported__` for snapshots copied into the app store. -pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let n = normalize(recs); - // Live rollouts customize via the sidecar (never rewrite another tool's files); imported - // COPIES (marked by an .import.json) are our own files, where the standard in-file - // __ccbud__ (written by set_ccbud) applies. - let native = crate::history::read_import_meta(&file.to_string_lossy()).is_none(); - let (cc_title, cc_tags, cc_deleted) = if native { - sidecar_meta(file) - } else { - crate::history::read_ccbud(recs) - }; - let mut transcript_title = crate::history::first_user_text(&n.messages); - if transcript_title.is_empty() { - transcript_title = first_event_user_title(recs); - } - if transcript_title.is_empty() && meta.len() > 131072 { - transcript_title = scan_event_user_title(file); - } - let agent_title = subagent_title(&n); - let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; - let stem = stem_of(file); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - // Row ids are UI identities, so include the configured store. A live rollout and an - // imported snapshot can legitimately share the same filename/thread id. - "id": format!("codex:{}:{}", dir_id, stem), - "file": file.to_string_lossy(), - "source": "codex", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), - "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), - "parentThreadId": n.parent_thread_id.clone(), - "forkedFromId": n.forked_from_id.clone(), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": n.model, - "isSubagent": n.is_subagent, - "agentPath": n.agent_path.clone(), - "agentNickname": n.agent_nickname.clone(), - "agentRole": n.agent_role.clone(), - "agentDepth": n.agent_depth, - "imported": dir_id == "__imported__", - "deleted": cc_deleted, - "createdAt": crate::history::record_created_ms(recs, file), - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape from already-parsed records (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let n = normalize(recs); - let import_meta = crate::history::read_import_meta(file); - // Same sidecar-vs-in-file split as session_meta_from. - let (cc_title, cc_tags, cc_deleted) = if import_meta.is_none() { - sidecar_meta(path) - } else { - crate::history::read_ccbud(recs) - }; - let mut transcript_title = crate::history::first_user_text(&n.messages); - if transcript_title.is_empty() { - transcript_title = first_event_user_title(recs); - } - let agent_title = subagent_title(&n); - let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; - let stem = stem_of(path); - json!({ - "meta": { - "id": format!("codex:{}", stem), - "file": file, - "source": "codex", - "assistant": "Codex", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), - "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), - "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), - "parentThreadId": n.parent_thread_id.clone(), - "forkedFromId": n.forked_from_id.clone(), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": n.version.clone(), - "isSubagent": n.is_subagent, - "agentPath": n.agent_path.clone(), - "agentNickname": n.agent_nickname.clone(), - "agentRole": n.agent_role.clone(), - "agentDepth": n.agent_depth, - "deleted": cc_deleted, - "imported": import_meta.is_some(), - "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), - "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn line(ts: &str, ty: &str, payload: Value) -> String { - serde_json::to_string(&json!({ "timestamp": ts, "type": ty, "payload": payload })).unwrap() - } - - fn fixture() -> Vec { - let lines = vec![ - line("2026-07-04T07:13:08.965Z", "session_meta", json!({ - "session_id": "019f-abc", "id": "019f-abc", "timestamp": "2026-07-04T07:13:07.386Z", - "cwd": "/tmp/projx", "originator": "codex-tui", "cli_version": "0.142.5", - "git": { "branch": "main" } - })), - line("2026-07-04T07:13:08.967Z", "turn_context", json!({ "cwd": "/tmp/projx", "model": "gpt-5.5" })), - line("2026-07-04T07:13:08.967Z", "response_item", json!({ - "type": "message", "role": "user", - "content": [{ "type": "input_text", "text": "\n/tmp/projx\n" }] - })), - line("2026-07-04T07:13:08.969Z", "response_item", json!({ - "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "fix the bug please" }] - })), - line("2026-07-04T07:13:09.100Z", "event_msg", json!({ "type": "user_message", "message": "fix the bug please" })), - line("2026-07-04T07:13:10.000Z", "response_item", json!({ - "type": "reasoning", "summary": [{ "type": "summary_text", "text": "Looking at the repo" }], "encrypted_content": "xxx" - })), - line("2026-07-04T07:13:11.000Z", "response_item", json!({ - "type": "function_call", "name": "exec_command", - "arguments": "{\"cmd\": \"ls -la\", \"yield_time_ms\": 10000}", "call_id": "call_1" - })), - line("2026-07-04T07:13:12.000Z", "response_item", json!({ - "type": "function_call_output", "call_id": "call_1", - "output": "Chunk ID: x\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\n---\na.txt\nb.txt" - })), - line("2026-07-04T07:13:13.000Z", "response_item", json!({ - "type": "function_call", "name": "shell", - "arguments": "{\"command\": [\"bash\", \"-lc\", \"cargo test\"], \"workdir\": \"/tmp/projx\"}", "call_id": "call_2" - })), - line("2026-07-04T07:13:14.000Z", "response_item", json!({ - "type": "function_call_output", "call_id": "call_2", - "output": "{\"output\": \"error: it broke\", \"metadata\": {\"exit_code\": 101, \"duration_seconds\": 1.5}}" - })), - line("2026-07-04T07:13:15.000Z", "response_item", json!({ - "type": "function_call", "name": "update_plan", - "arguments": "{\"plan\": [{\"step\": \"read code\", \"status\": \"completed\"}, {\"step\": \"fix bug\", \"status\": \"in_progress\"}]}", - "call_id": "call_3" - })), - line("2026-07-04T07:13:16.000Z", "response_item", json!({ - "type": "custom_tool_call", "name": "apply_patch", "call_id": "call_4", - "input": "*** Begin Patch\n*** Update File: src/a.rs\n@@\n-old\n+new\n*** End Patch" - })), - line("2026-07-04T07:13:17.000Z", "response_item", json!({ - "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": "Done — fixed." }], "phase": "final_answer" - })), - line("2026-07-04T07:13:17.500Z", "event_msg", json!({ - "type": "token_count", - "info": { - "total_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, - "last_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, - "model_context_window": 258400 - } - })), - ]; - lines - .iter() - .map(|l| serde_json::from_str::(l).unwrap()) - .collect() - } - - #[test] - fn normalizes_rollout_into_renderer_model() { - let recs = fixture(); - assert!(looks_codex(&recs)); - let n = normalize(&recs); - - assert_eq!(n.session_id.as_deref(), Some("019f-abc")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/projx")); - assert_eq!(n.version.as_deref(), Some("0.142.5")); - assert_eq!(n.git_branch.as_deref(), Some("main")); - assert_eq!(n.model.as_deref(), Some("gpt-5.5")); - - // env-context user turn skipped; real prose, reasoning, 4 tool calls, 2 results, final text - let roles: Vec<&str> = n.messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); - assert_eq!(roles, vec!["user", "assistant", "assistant", "user", "assistant", "user", "assistant", "assistant", "assistant"]); - - let title = crate::history::first_user_text(&n.messages); - assert_eq!(title, "fix the bug please"); - - // exec_command → Bash card with the raw command - let tu1 = &n.messages[2]["content"][0]; - assert_eq!(tu1["type"], "tool_use"); - assert_eq!(tu1["name"], "Bash"); - assert_eq!(tu1["input"]["command"], "ls -la"); - // its ok result pairs by call id and is not an error - let tr1 = &n.messages[3]["content"][0]; - assert_eq!(tr1["tool_use_id"], "call_1"); - assert!(tr1.get("is_error").is_none()); - - // shell argv ["bash","-lc","cargo test"] unwraps; exit_code 101 marks the result as error - let tu2 = &n.messages[4]["content"][0]; - assert_eq!(tu2["input"]["command"], "cargo test"); - let tr2 = &n.messages[5]["content"][0]; - assert_eq!(tr2["is_error"], true); - assert_eq!(tr2["content"], "error: it broke"); - - // update_plan → TodoWrite todos - let tu3 = &n.messages[6]["content"][0]; - assert_eq!(tu3["name"], "TodoWrite"); - assert_eq!(tu3["input"]["todos"][1]["status"], "in_progress"); - - // apply_patch custom tool → ApplyPatch {patch} - let tu4 = &n.messages[7]["content"][0]; - assert_eq!(tu4["name"], "ApplyPatch"); - assert!(tu4["input"]["patch"].as_str().unwrap().contains("*** Update File: src/a.rs")); - - // reasoning became a thinking block - assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); - - // token_count landed on the final assistant text turn and rolled into totals - let last = n.messages.last().unwrap(); - assert_eq!(last["usage"]["inputTokens"], 300); // input − cached - assert_eq!(last["usage"]["cacheRead"], 600); - assert_eq!(n.totals["out"], 80); - assert_eq!(n.totals["turns"], 1); - - // timestamps span the emitted messages - assert_eq!(n.first_ts.as_deref(), Some("2026-07-04T07:13:08.969Z")); - assert_eq!(n.last_ts.as_deref(), Some("2026-07-04T07:13:17.000Z")); - } - - // Machine-data smoke: run explicitly with `cargo test --lib -- --ignored` on a machine that - // has real Codex sessions. Verifies every real rollout sniffs + normalizes + shapes. - #[test] - #[ignore] - fn real_codex_sessions_smoke() { - if !root_exists() { - eprintln!("no ~/.codex/sessions — skipping"); - return; - } - let mut n = 0; - let label = codex_label(); - walk_sessions(&sessions_root(), |p| { - let raw = fs::read_to_string(&p).unwrap_or_default(); - let recs = crate::history::parse_lines(&raw); - assert!(looks_codex(&recs), "not sniffed as codex: {:?}", p); - let norm = normalize(&recs); - assert!(norm.session_id.is_some() || norm.messages.is_empty(), "no session id: {:?}", p); - let sess = session_from_recs(&p.to_string_lossy(), &recs); - assert_eq!(sess["meta"]["assistant"], "Codex"); - let listed = session_meta_from(&p, &recs, &label, &label).unwrap(); - assert_eq!(listed["source"], "codex"); - n += 1; - }); - eprintln!("smoke-checked {} real codex sessions", n); - } - - #[test] - fn claude_records_do_not_sniff_as_codex() { - let recs = vec![ - json!({ "type": "user", "message": { "role": "user", "content": "hi" }, "cwd": "/x", "sessionId": "s1" }), - json!({ "type": "assistant", "message": { "role": "assistant", "content": [{ "type": "text", "text": "hello" }] } }), - json!({ "type": "summary", "summary": "greeting" }), - ]; - assert!(!looks_codex(&recs)); - } - - #[test] - fn old_envelope_less_rollout_still_parses() { - let recs = vec![ - json!({ "id": "old-1", "timestamp": "2025-05-01T00:00:00Z", "instructions": "x", "cwd": "/tmp/old" }), - json!({ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hello old codex" }] }), - json!({ "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }), - ]; - assert!(looks_codex(&recs)); - let n = normalize(&recs); - assert_eq!(n.session_id.as_deref(), Some("old-1")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/old")); - assert_eq!(n.messages.len(), 2); - assert_eq!(crate::history::first_user_text(&n.messages), "hello old codex"); - } - - #[test] - fn turn_aborted_and_web_search_render() { - let recs: Vec = vec![ - serde_json::from_str(&line("2026-01-01T00:00:00Z", "response_item", json!({ - "type": "web_search_call", "id": "ws_1", "action": { "type": "search", "query": "rust serde" } - }))).unwrap(), - serde_json::from_str(&line("2026-01-01T00:00:01Z", "event_msg", json!({ "type": "turn_aborted", "reason": "interrupted" }))).unwrap(), - ]; - let n = normalize(&recs); - assert_eq!(n.messages[0]["content"][0]["name"], "WebSearch"); - assert_eq!(n.messages[0]["content"][0]["input"]["query"], "rust serde"); - assert!(n.messages[1]["content"][0]["text"].as_str().unwrap().starts_with("[Request interrupted")); - } - - #[test] - fn maps_code_mode_exec_scripts() { - // canonical single exec_command + print plumbing → Bash card (command + workdir) - let (n, i) = map_exec_script( - "const r = await tools.exec_command({\"cmd\":\"ls -la\",\"workdir\":\"/tmp/p\",\"yield_time_ms\":10000,\"max_output_tokens\":30000});\ntext(r.output);", - ); - assert_eq!(n, "Bash"); - assert_eq!(i["command"], "ls -la"); - assert_eq!(i["description"], "/tmp/p"); - // bare JS object keys (code-mode literal) parse via the quoting retry; braces/quotes - // inside the command string stay opaque - let (n, i) = map_exec_script( - "const r = await tools.exec_command({cmd:\"awk '{print: $1}' a.txt\",workdir:\"/w\"});\ntext(JSON.stringify(r));", - ); - assert_eq!(n, "Bash"); - assert_eq!(i["command"], "awk '{print: $1}' a.txt"); - // SESSION_ID echo tail is still plumbing - let (n, _) = map_exec_script( - "const r = await tools.exec_command({\"cmd\":\"sleep 1\"});\ntext(r.output);\nif (r.session_id) text(`SESSION_ID=${r.session_id}`);", - ); - assert_eq!(n, "Bash"); - // write_stdin / multi-call orchestration keep the script verbatim - let (n, i) = map_exec_script("const r = await tools.write_stdin({\"session_id\":40352,\"chars\":\"\"});\ntext(r.output);"); - assert_eq!(n, "Script"); - assert!(i["code"].as_str().unwrap().contains("write_stdin")); - let (n, _) = map_exec_script( - "const a = await Promise.all([tools.exec_command({\"cmd\":\"x\"}), tools.exec_command({\"cmd\":\"y\"})]);\ntext(a.map(r => r.output).join());", - ); - assert_eq!(n, "Script"); - } - - #[test] - fn shapes_code_mode_block_array_outputs() { - let recs = vec![ - json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c1", - "input": "const r = await tools.exec_command({\"cmd\":\"ls\"});\ntext(r.output);" }), - json!({ "type": "custom_tool_call_output", "call_id": "c1", "output": [ - { "type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n" }, - { "type": "input_text", "text": "a.txt\n" }, - { "type": "input_image", "image_url": "data:image/png;base64,QUJD", "detail": "high" } - ] }), - json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c2", - "input": "const r = await tools.exec_command({\"cmd\":\"boom\"});\ntext(r.output);" }), - json!({ "type": "custom_tool_call_output", "call_id": "c2", "output": [ - { "type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\nerr" } - ] }), - ]; - let n = normalize(&recs); - assert_eq!(n.messages.len(), 4); - let tu = &n.messages[0]["content"][0]; - assert_eq!(tu["name"], "Bash"); - assert_eq!(tu["input"]["command"], "ls"); - // text chunks concatenate VERBATIM (no injected separators); screenshot rides along - let tr = &n.messages[1]["content"][0]; - assert_eq!(tr["tool_use_id"], "c1"); - assert_eq!(tr["content"][0]["text"], "Script completed\nWall time 0.1 seconds\nOutput:\na.txt\n"); - assert_eq!(tr["content"][1]["type"], "image"); - assert_eq!(tr["content"][1]["source"]["data"], "QUJD"); - assert!(tr.get("is_error").is_none()); - // "Script failed" header marks the result as an error; plain-text output stays a string - let tr2 = &n.messages[3]["content"][0]; - assert_eq!(tr2["content"].as_str().unwrap(), "Script failed\nWall time 0.0 seconds\nOutput:\nerr"); - assert_eq!(tr2["is_error"], true); - } -} diff --git a/src-tauri/src/codex/exec.rs b/src-tauri/src/codex/exec.rs new file mode 100644 index 0000000..465b9a2 --- /dev/null +++ b/src-tauri/src/codex/exec.rs @@ -0,0 +1,189 @@ +use serde_json::{json, Value}; + +// ---- code-mode `exec` scripts (custom_tool_call name "exec") ---- +// +// Codex code-mode (gpt-*-sol) emits one custom tool named `exec` whose input is JavaScript +// calling `tools.*` (exec_command / write_stdin / …). The dominant shape by far is a single +// `tools.exec_command({cmd, workdir, …})` plus print plumbing (`text(r.output);` and friends) — +// semantically just a shell run, so it renders as the familiar Bash card (command + workdir). +// Anything else (write_stdin, Promise.all batches, real orchestration code) keeps the whole +// script as a `Script` card the renderer shows as highlighted JavaScript. Extraction is +// conservative: any parse doubt falls back to the Script card, never to a wrong command. + +/// First `{…}` object literal at/after `from`, brace-matched with double-quoted strings (and +/// their escapes) treated as opaque — shell commands are full of braces and quotes. +fn extract_object(s: &str, from: usize) -> Option<(usize, usize)> { + let start = from + s[from..].find('{')?; + let (mut depth, mut in_str, mut esc) = (0i32, false, false); + for (i, &b) in s.as_bytes().iter().enumerate().skip(start) { + if in_str { + if esc { + esc = false; + } else if b == b'\\' { + esc = true; + } else if b == b'"' { + in_str = false; + } + continue; + } + match b { + b'"' => in_str = true, + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some((start, i)); + } + } + _ => {} + } + } + None +} + +/// Quote bare JS object keys (`{cmd: …}` → `{"cmd": …}`) outside string context so serde can +/// parse code-mode's object-literal arguments; double-quoted string contents pass verbatim. +fn quote_js_keys(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 16); + let (mut in_str, mut esc) = (false, false); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + out.push(c); + i += 1; + continue; + } + if c == '"' { + in_str = true; + out.push(c); + i += 1; + continue; + } + if c == '{' || c == ',' { + out.push(c); + i += 1; + while i < chars.len() && chars[i].is_whitespace() { + out.push(chars[i]); + i += 1; + } + let start = i; + while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + if i > start { + let mut j = i; + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + let ident: String = chars[start..i].iter().collect(); + if j < chars.len() && chars[j] == ':' { + out.push('"'); + out.push_str(&ident); + out.push('"'); + } else { + out.push_str(&ident); + } + } + continue; + } + out.push(c); + i += 1; + } + out +} + +/// The `{…}` argument of a tools.* call: strict JSON first (code-mode usually emits JSON), +/// then a bare-key-quoted retry for JS object literals. +fn parse_call_args(obj: &str) -> Option { + serde_json::from_str::(obj) + .ok() + .or_else(|| serde_json::from_str::("e_js_keys(obj)).ok()) + .filter(|v| v.is_object()) +} + +/// Code-mode exec script → renderer tool card (see module comment above). +pub(super) fn map_exec_script(script: &str) -> (String, Value) { + let fallback = || ("Script".to_string(), json!({ "code": script })); + // exactly one tools.* call, and it must be exec_command (a cmd string that itself mentions + // "tools." trips the count — conservative fallback, never a wrong command) + if script.matches("tools.").count() != 1 { + return fallback(); + } + let call = match script.find("tools.exec_command(") { + Some(i) => i, + None => return fallback(), + }; + // prefix must be assignment/await plumbing only: `const r = await` / `let out = await` / `await` + let prefix: Vec<&str> = script[..call].split_whitespace().collect(); + let prefix_ok = match prefix.as_slice() { + [] | ["await"] => true, + [kw, _name, "=", "await"] => matches!(*kw, "const" | "let" | "var"), + _ => false, + }; + if !prefix_ok { + return fallback(); + } + let after = call + "tools.exec_command(".len(); + let (ostart, oend) = match extract_object(script, after) { + Some(span) => span, + None => return fallback(), + }; + if !script[after..ostart].trim().is_empty() { + return fallback(); + } + let args = match parse_call_args(&script[ostart..=oend]) { + Some(a) => a, + None => return fallback(), + }; + let cmd = args + .get("cmd") + .or_else(|| args.get("command")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if cmd.is_empty() { + return fallback(); + } + // tail must close the call, then carry only print plumbing + let rest = script[oend + 1..].trim_start(); + let rest = match rest.strip_prefix(')') { + Some(r) => r, + None => return fallback(), + }; + let rest = rest.strip_prefix(';').unwrap_or(rest); + let plumbing = rest.lines().all(|l| { + let l = l.trim(); + l.is_empty() || l.starts_with("text(") || l.starts_with("if (") || l.starts_with("//") + }); + if !plumbing { + return fallback(); + } + let mut input = json!({ "command": cmd }); + if let Some(wd) = args.get("workdir").and_then(|v| v.as_str()) { + if !wd.is_empty() { + input["description"] = json!(wd); + } + } + ("Bash".into(), input) +} + +/// Error heuristic for code-mode exec output text: the runner's own status header +/// ("Script failed…" / "Exit code: N…"). +pub(super) fn exec_text_err(text: &str) -> bool { + if text.starts_with("Script failed") { + return true; + } + if let Some(rest) = text.strip_prefix("Exit code: ") { + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + return digits.parse::().map(|c| c != 0).unwrap_or(false); + } + false +} diff --git a/src-tauri/src/codex/items.rs b/src-tauri/src/codex/items.rs new file mode 100644 index 0000000..731ee7f --- /dev/null +++ b/src-tauri/src/codex/items.rs @@ -0,0 +1,219 @@ +// normalize()'s "response_item" match arm, moved verbatim out of normalize.rs so both files +// stay under the split's size cap. Called once per record from normalize()'s loop; `return` +// here matches the original `continue` (this match was the loop body's last statement), and +// the `with_ts` closure is duplicated verbatim from that loop. + +use crate::history::image_block; +use serde_json::{json, Value}; + +use super::exec::{exec_text_err, map_exec_script}; +use super::records::{is_agents_bootstrap, is_meta_user_text, skill_load_block}; +use super::titles::{joined_text, joined_user_text}; +use super::tools::{join_argv, map_tool, shape_output}; + +pub(super) fn on_response_item(p: &Value, ts: Option<&str>, model: &Option, messages: &mut Vec) { + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + let it = p.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match it { + "message" => { + let role = p.get("role").and_then(|v| v.as_str()).unwrap_or(""); + let content = p.get("content").cloned().unwrap_or(Value::Null); + if role == "assistant" { + let text = joined_text(&content, &["output_text", "text"]); + if !text.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + } else if role == "user" { + let text = joined_user_text(&content); + if let Some(skill) = skill_load_block(&text) { + messages.push(with_ts(json!({ + "role": "user", + "_meta": true, + "content": [skill], + }))); + return; // `continue` in the original normalize() loop + } + if is_meta_user_text(&text) { + return; // `continue` in the original normalize() loop + } + let mut blocks: Vec = vec![]; + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(arr) = content.as_array() { + for b in arr { + if b.get("type").and_then(|t| t.as_str()) == Some("input_image") { + if let Some(img) = b + .get("image_url") + .and_then(|u| u.as_str()) + .and_then(image_block) + { + blocks.push(img); + } + } + } + } + if !blocks.is_empty() { + let mut message = json!({ "role": "user", "content": blocks }); + if is_agents_bootstrap(&text) { + message["_meta"] = json!(true); + } + messages.push(with_ts(message)); + } + } // system / developer turns: harness plumbing, not conversation + } + "reasoning" => { + let mut txt = joined_text(&p.get("summary").cloned().unwrap_or(Value::Null), &["summary_text", "text"]); + let extra = joined_text(&p.get("content").cloned().unwrap_or(Value::Null), &["reasoning_text", "text"]); + if !extra.trim().is_empty() { + if !txt.trim().is_empty() { + txt.push_str("\n\n"); + } + txt.push_str(&extra); + } + if !txt.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + } + "function_call" => { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args: Value = p + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| p.get("arguments").cloned().unwrap_or(json!({}))); + let (tname, input) = map_tool(name, &args); + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "local_shell_call" => { + let cmd = p + .get("action") + .and_then(|a| a.get("command")) + .cloned() + .unwrap_or(Value::Null); + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": "Bash", "input": { "command": join_argv(&cmd) } }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "custom_tool_call" => { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let input_s = p.get("input").and_then(|v| v.as_str()).unwrap_or(""); + let (tname, input) = if name == "apply_patch" { + ("ApplyPatch".to_string(), json!({ "patch": input_s })) + } else if name == "exec" { + map_exec_script(input_s) + } else { + (name.to_string(), json!({ "input": input_s })) + }; + let id = p + .get("call_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + "function_call_output" | "custom_tool_call_output" => { + let out = p.get("output").cloned().unwrap_or(Value::Null); + let id = p.get("call_id").and_then(|v| v.as_str()).unwrap_or(""); + // Newer code-mode outputs are block ARRAYS — {input_text} chunks (status + // header + stdout, concatenated verbatim) plus optional {input_image} + // screenshots, which become renderer image blocks. + let (content, err) = if let Some(arr) = out.as_array() { + let text: String = arr + .iter() + .filter(|b| { + matches!( + b.get("type").and_then(|t| t.as_str()), + Some("input_text") | Some("output_text") | Some("text") + ) + }) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect(); + let images: Vec = arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")) + .filter_map(|b| b.get("image_url").and_then(|u| u.as_str()).and_then(image_block)) + .collect(); + let err = exec_text_err(&text); + if images.is_empty() { + (json!(text), err) + } else { + let mut blocks = vec![json!({ "type": "text", "text": text })]; + blocks.extend(images); + (Value::Array(blocks), err) + } + } else { + let (text, err) = shape_output(&out); + (json!(text), err) + }; + let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": content }); + if err { + tr["is_error"] = json!(true); + } + messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); + } + "web_search_call" => { + let q = p + .get("action") + .and_then(|a| a.get("query")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let id = p + .get("id") + .or_else(|| p.get("call_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut m = json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": "WebSearch", "input": { "query": q } }], + }); + if let Some(md) = &model { + m["modelActual"] = json!(md); + } + messages.push(with_ts(m)); + } + _ => {} + } +} diff --git a/src-tauri/src/codex/meta.rs b/src-tauri/src/codex/meta.rs new file mode 100644 index 0000000..6ac3ee8 --- /dev/null +++ b/src-tauri/src/codex/meta.rs @@ -0,0 +1,197 @@ +use crate::history::Norm; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::normalize::normalize; +use super::titles::{first_event_user_title, scan_event_user_title}; + +// ---- sidecar customization (shared store, ~/.ccbud/codex-meta.json, keyed by rollout stem) ---- + +fn stem_of(file: &Path) -> String { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() +} + +/// (custom title, tags, deleted) for a codex session, from the sidecar. +fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::codex_file(), &stem_of(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +/// set_ccbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), +/// persisted to the sidecar instead of the rollout file (never mutate another tool's data). +pub fn set_meta(file: &str, patch: &Value) -> Value { + let stem = stem_of(Path::new(file)); + if stem.is_empty() { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::codex_file(), &stem, patch) +} + +/// Drop a session's sidecar entry (after its rollout file is deleted forever). +pub fn remove_meta(file: &str) { + crate::sidecar::remove_meta(&crate::sidecar::codex_file(), &stem_of(Path::new(file))); +} + +// ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- + +fn subagent_title(n: &Norm) -> String { + if !n.is_subagent { + return String::new(); + } + let path = n + .agent_path + .as_deref() + .unwrap_or("") + .trim_start_matches('/') + .strip_prefix("root/") + .unwrap_or_else(|| n.agent_path.as_deref().unwrap_or("").trim_start_matches('/')); + let mut parts = Vec::new(); + if let Some(nickname) = n.agent_nickname.as_deref().filter(|value| !value.trim().is_empty()) { + parts.push(nickname.trim()); + } + if !path.is_empty() { + parts.push(path); + } + if parts.is_empty() { + "Codex subagent".to_string() + } else { + parts.join(" · ") + } +} + +fn is_canonical_thread_id(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8usize, 13, 18, 23].into_iter().all(|index| bytes[index] == b'-') + && bytes + .iter() + .enumerate() + .all(|(index, byte)| [8usize, 13, 18, 23].contains(&index) || byte.is_ascii_hexdigit()) +} + +/// List-row meta from already-parsed head records. `dir_id` is `__codex__` for the live tree +/// or `__imported__` for snapshots copied into the app store. +pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let n = normalize(recs); + // Live rollouts customize via the sidecar (never rewrite another tool's files); imported + // COPIES (marked by an .import.json) are our own files, where the standard in-file + // __ccbud__ (written by set_ccbud) applies. + let native = crate::history::read_import_meta(&file.to_string_lossy()).is_none(); + let (cc_title, cc_tags, cc_deleted) = if native { + sidecar_meta(file) + } else { + crate::history::read_ccbud(recs) + }; + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + if transcript_title.is_empty() && meta.len() > 131072 { + transcript_title = scan_event_user_title(file); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; + let stem = stem_of(file); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + // Row ids are UI identities, so include the configured store. A live rollout and an + // imported snapshot can legitimately share the same filename/thread id. + "id": format!("codex:{}:{}", dir_id, stem), + "file": file.to_string_lossy(), + "source": "codex", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": n.model, + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, + "imported": dir_id == "__imported__", + "deleted": cc_deleted, + "createdAt": crate::history::record_created_ms(recs, file), + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape from already-parsed records (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let n = normalize(recs); + let import_meta = crate::history::read_import_meta(file); + // Same sidecar-vs-in-file split as session_meta_from. + let (cc_title, cc_tags, cc_deleted) = if import_meta.is_none() { + sidecar_meta(path) + } else { + crate::history::read_ccbud(recs) + }; + let mut transcript_title = crate::history::first_user_text(&n.messages); + if transcript_title.is_empty() { + transcript_title = first_event_user_title(recs); + } + let agent_title = subagent_title(&n); + let auto_title = if agent_title.is_empty() { transcript_title } else { agent_title }; + let stem = stem_of(path); + json!({ + "meta": { + "id": format!("codex:{}", stem), + "file": file, + "source": "codex", + "assistant": "Codex", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "threadId": n.thread_id.clone().or_else(|| n.session_id.clone()).unwrap_or_else(|| stem.clone()), + "canonicalThreadIdValid": n.thread_id.as_deref().is_some_and(is_canonical_thread_id), + "rootSessionId": n.session_id.clone().or_else(|| n.thread_id.clone()).unwrap_or_else(|| stem.clone()), + "parentThreadId": n.parent_thread_id.clone(), + "forkedFromId": n.forked_from_id.clone(), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": n.version.clone(), + "isSubagent": n.is_subagent, + "agentPath": n.agent_path.clone(), + "agentNickname": n.agent_nickname.clone(), + "agentRole": n.agent_role.clone(), + "agentDepth": n.agent_depth, + "deleted": cc_deleted, + "imported": import_meta.is_some(), + "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), + "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs new file mode 100644 index 0000000..fcb5061 --- /dev/null +++ b/src-tauri/src/codex/mod.rs @@ -0,0 +1,45 @@ +// Codex CLI session support — reads OpenAI Codex's on-disk rollout logs +// (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME +// session/message shape the renderer consumes for Claude Code history, so the 对话 view +// (list / detail / search / live-follow / export) browses both without renderer forks. +// +// A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, +// response_item, event_msg, compacted}. Conversation content lives in response_item payloads +// (message / reasoning / function_call / function_call_output / local_shell_call / +// custom_tool_call / web_search_call); event_msg mostly duplicates that content, but token_count +// carries usage and user_message supplies a bounded title fallback for image-heavy first turns. +// Very old Codex builds wrote +// payload objects directly per line (no envelope) — handled by treating such a line as its +// own payload. +// +// Tool calls are mapped onto the tool vocabulary the renderer already draws natively: +// shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, +// web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). +// +// Title/tags/soft-delete: Codex files belong to another tool, so per-conversation +// customization never rewrites them (unlike Claude's in-file `__ccbud__`) — it lives in a +// sidecar map at `~/.ccbud/codex-meta.json`, keyed by the rollout file stem. + +#![allow(dead_code)] + +mod exec; +mod items; +mod meta; +mod normalize; +mod records; +mod roots; +mod titles; +mod tools; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use meta::{is_deleted, remove_meta, session_from_recs, session_meta_from, set_meta}; +// normalize/sessions_root are exercised by the #[cfg(test)] modules via these re-exports, +// so a non-test `cargo check` sees them as unused — allow that, don't drop the API. +#[allow(unused_imports)] +pub use normalize::normalize; +pub use records::head_ids; +#[allow(unused_imports)] +pub use roots::{codex_label, looks_codex, preferred_rollout_path, root_exists, sessions_root, walk_sessions}; diff --git a/src-tauri/src/codex/normalize.rs b/src-tauri/src/codex/normalize.rs new file mode 100644 index 0000000..67d15c8 --- /dev/null +++ b/src-tauri/src/codex/normalize.rs @@ -0,0 +1,160 @@ +// Rollout -> renderer message model (split from codex.rs). The response_item arm lives in +// items.rs (see on_response_item) to keep both files under the split's size cap. + +use crate::history::Norm; +use serde_json::{json, Value}; + +use super::items::on_response_item; +use super::records::{canonical_thread_meta, split_line}; + +/// Normalize parsed rollout records into the renderer's message model. +pub fn normalize(recs: &[Value]) -> Norm { + let mut messages: Vec = vec![]; + let (mut tin, mut tout, mut tcr, mut turns) = (0i64, 0i64, 0i64, 0i64); + let mut model: Option = None; + let mut cwd: Option = None; + let mut session_id: Option = None; + let mut thread_id: Option = None; + let mut parent_thread_id: Option = None; + let mut forked_from_id: Option = None; + let mut is_subagent = false; + let mut agent_path: Option = None; + let mut agent_nickname: Option = None; + let mut agent_role: Option = None; + let mut agent_depth: Option = None; + let mut saw_session_meta = false; + let mut git_branch: Option = None; + let mut version: Option = None; + + for rec in recs { + let (ty, p, ts) = split_line(rec); + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + match ty { + "session_meta" => { + if !saw_session_meta { + saw_session_meta = true; + let identity = canonical_thread_meta(p); + thread_id = identity.thread_id; + session_id = identity.root_session_id; + parent_thread_id = identity.parent_thread_id; + forked_from_id = identity.forked_from_id; + is_subagent = identity.is_subagent; + agent_path = identity.agent_path; + agent_nickname = identity.agent_nickname; + agent_role = identity.agent_role; + agent_depth = identity.agent_depth; + } + let sid = p + .get("session_id") + .or_else(|| p.get("id")) + .and_then(|v| v.as_str()); + if session_id.is_none() { + session_id = sid.map(|s| s.to_string()); + } + if cwd.is_none() { + cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if version.is_none() { + version = p.get("cli_version").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if git_branch.is_none() { + git_branch = p + .get("git") + .and_then(|g| g.get("branch")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "turn_context" => { + if let Some(m) = p.get("model").and_then(|v| v.as_str()) { + model = Some(m.to_string()); + } + if cwd.is_none() { + cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + } + "compacted" => { + let text = p.get("message").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + if !text.is_empty() { + messages.push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); + } + } + "event_msg" => match p.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "token_count" => { + let u = p.get("info").and_then(|i| i.get("last_token_usage")); + if let Some(u) = u { + let input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let cached = u.get("cached_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + if input + cached + output > 0 { + let usage = json!({ + "inputTokens": (input - cached).max(0), + "outputTokens": output, + "cacheRead": cached, + "cacheCreation": 0, + }); + tin += (input - cached).max(0); + tout += output; + tcr += cached; + turns += 1; + // Per-turn usage rides the turn's last assistant message (codex emits + // one token_count per model turn). + if let Some(m) = messages + .iter_mut() + .rev() + .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant") && m.get("usage").is_none()) + { + m["usage"] = usage; + } + } + } + } + "turn_aborted" => { + messages.push(with_ts(json!({ + "role": "user", + "content": [{ "type": "text", "text": "[Request interrupted by user]" }], + }))); + } + _ => {} + }, + "response_item" => on_response_item(p, ts, &model, &mut messages), + _ => {} + } + } + + let first_ts = messages + .first() + .and_then(|m| m.get("ts")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let last_ts = messages + .last() + .and_then(|m| m.get("ts")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Norm { + messages, + totals: json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": 0, "turns": turns }), + model, + first_ts, + last_ts, + cwd, + session_id, + thread_id, + parent_thread_id, + forked_from_id, + is_subagent, + agent_path, + agent_nickname, + agent_role, + agent_depth, + git_branch, + version, + } +} diff --git a/src-tauri/src/codex/records.rs b/src-tauri/src/codex/records.rs new file mode 100644 index 0000000..9ce0eaf --- /dev/null +++ b/src-tauri/src/codex/records.rs @@ -0,0 +1,174 @@ +// Rollout record classification: line envelope split, canonical thread identity, meta/skill +// user-turn detection, and head ids (split from codex.rs). + +use serde_json::{json, Value}; + +/// (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. +pub(super) fn split_line(rec: &Value) -> (&str, &Value, Option<&str>) { + let ts = rec.get("timestamp").and_then(|v| v.as_str()); + let t = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(p) = rec.get("payload") { + return (t, p, ts); + } + match t { + "message" | "function_call" | "function_call_output" | "reasoning" | "local_shell_call" + | "custom_tool_call" | "custom_tool_call_output" | "web_search_call" => ("response_item", rec, ts), + // old first line: bare SessionMeta {id, timestamp, instructions, cwd?, git?} + "" if rec.get("id").is_some() && rec.get("timestamp").is_some() => ("session_meta", rec, ts), + _ => (t, rec, ts), + } +} + +#[derive(Default)] +pub(super) struct CanonicalThreadMeta { + pub(super) thread_id: Option, + pub(super) root_session_id: Option, + pub(super) parent_thread_id: Option, + pub(super) forked_from_id: Option, + pub(super) is_subagent: bool, + pub(super) agent_path: Option, + pub(super) agent_nickname: Option, + pub(super) agent_role: Option, + pub(super) agent_depth: Option, +} + +// The first SessionMeta is canonical for the physical rollout. Subagent/fork rollouts can copy +// ancestor SessionMeta records behind it, and every thread in that tree intentionally shares the +// same session_id. The unique thread key is the first meta's id. +pub(super) fn canonical_thread_meta(payload: &Value) -> CanonicalThreadMeta { + let subagent = payload + .get("source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + .or_else(|| { + payload + .get("thread_source") + .and_then(|source| source.get("subagent").or_else(|| source.get("sub_agent"))) + }); + let detail = subagent.and_then(|source| { + ["thread_spawn", "review", "compact", "other"] + .iter() + .find_map(|key| source.get(*key).filter(|value| value.is_object())) + .or_else(|| source.as_object().and_then(|object| object.values().find(|value| value.is_object()))) + }); + let string = |value: Option<&Value>| { + value + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }; + let thread_id = string( + payload + .get("id") + .or_else(|| payload.get("thread_id")), + ); + let root_session_id = string(payload.get("session_id")).or_else(|| thread_id.clone()); + let parent_thread_id = string( + payload + .get("parent_thread_id") + .or_else(|| detail.and_then(|value| value.get("parent_thread_id"))), + ); + let is_subagent = subagent.is_some() + || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") + || payload + .get("agent_path") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || payload + .get("agent_nickname") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + || (parent_thread_id.is_some() && thread_id != root_session_id); + CanonicalThreadMeta { + thread_id, + root_session_id, + parent_thread_id, + forked_from_id: string(payload.get("forked_from_id")), + is_subagent, + // Current Codex stores the canonical Agent identity on SessionMeta itself. Older + // rollouts only carried it inside source.subagent., so keep that as a fallback. + agent_path: string( + payload + .get("agent_path") + .or_else(|| detail.and_then(|value| value.get("agent_path"))), + ), + agent_nickname: string( + payload + .get("agent_nickname") + .or_else(|| detail.and_then(|value| value.get("agent_nickname"))), + ), + agent_role: string( + payload + .get("agent_role") + .or_else(|| payload.get("agent_type")) + .or_else(|| detail.and_then(|value| value.get("agent_role"))) + .or_else(|| detail.and_then(|value| value.get("agent_type"))), + ), + agent_depth: detail.and_then(|value| value.get("depth")).and_then(|value| value.as_i64()), + } +} + +/// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't +/// human prose — hidden from the timeline, exactly like Claude's isMeta records. +pub(super) fn is_meta_user_text(t: &str) -> bool { + let t = t.trim_start(); + ["", "", " bool { + let source = t.trim_start(); + let Some(heading) = source.strip_prefix('#') else { return false; }; + let heading = heading.trim_start().to_ascii_lowercase(); + heading.starts_with("agents.md instructions for ") + && heading.contains("") +} + +// Codex serializes a loaded Skill as a synthetic user turn. Keep the snapshot embedded in the +// rollout rather than reading the current SKILL.md from disk: historical sessions must show the +// exact instructions that were loaded at the time. Anchoring the whole envelope leaves quoted +// markup in normal user prose untouched. +pub(super) fn skill_load_block(t: &str) -> Option { + static SKILL_ENVELOPE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = SKILL_ENVELOPE_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*\s*(.*?)\s*(.*?)(.*)\s*$", + ) + .unwrap() + }); + let captures = re.captures(t)?; + let name = captures.get(1)?.as_str().trim(); + let path = captures.get(2)?.as_str().trim(); + if name.is_empty() || path.is_empty() { + return None; + } + let snapshot = captures.get(3)?.as_str(); + Some(json!({ + "type": "skill_load", + "name": name, + "path": path, + "snapshot": snapshot, + })) +} + +/// (cwd, canonical thread id) from a Codex head — used to name an imported store copy. +pub fn head_ids(recs: &[Value]) -> (Option, Option) { + for rec in recs { + let (ty, p, _) = split_line(rec); + if ty == "session_meta" { + let cwd = p.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let sid = p + // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the + // unique rollout key; using session_id makes sibling imports collide. + .get("id") + .or_else(|| p.get("thread_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return (cwd, sid); + } + } + (None, None) +} diff --git a/src-tauri/src/codex/roots.rs b/src-tauri/src/codex/roots.rs new file mode 100644 index 0000000..8452062 --- /dev/null +++ b/src-tauri/src/codex/roots.rs @@ -0,0 +1,148 @@ +// Session roots/paths/walk + rollout format sniffing (split from codex.rs). + +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time +/// startup migration that adds it to `historyDirs`. Honors CODEX_HOME like the codex CLI. +pub fn codex_label() -> String { + let root = sessions_root(); + let dir = root.parent().unwrap_or(&root); + crate::store::collapse_home(&dir.to_string_lossy()) +} + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Codex's DEFAULT sessions tree. Honors CODEX_HOME the way the codex CLI does. Only the +/// auto-add migration keys off this — browsing walks `/sessions` of every configured dir. +pub fn sessions_root() -> PathBuf { + match std::env::var("CODEX_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("sessions"), + _ => home().join(".codex").join("sessions"), + } +} + +pub fn root_exists() -> bool { + sessions_root().is_dir() +} + +fn codex_home_for_rollout(file: &Path) -> Option { + file.ancestors() + .find(|dir| { + matches!( + dir.file_name().and_then(|name| name.to_str()), + Some("sessions") | Some("archived_sessions") + ) + }) + .and_then(Path::parent) + .map(Path::to_path_buf) +} + +fn resolve_sqlite_home_path(raw: &str, codex_home: &Path) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if raw == "~" { + return Some(home()); + } + if let Some(rest) = raw.strip_prefix("~/") { + return Some(home().join(rest)); + } + let path = PathBuf::from(raw); + Some(if path.is_absolute() { path } else { codex_home.join(path) }) +} + +fn configured_sqlite_home(codex_home: &Path) -> Option { + let raw = fs::read_to_string(codex_home.join("config.toml")).ok()?; + let doc = raw.parse::().ok()?; + resolve_sqlite_home_path(doc.get("sqlite_home")?.as_str()?, codex_home) +} + +/// Codex treats the completed state DB's rollout_path as authoritative for a canonical thread id. +/// This is intentionally queried only when ccbud has found duplicate physical candidates, so the +/// normal list walk never opens SQLite per row. A missing/stale/incomplete DB simply means callers +/// fall back to validated metadata + mtime, just as Codex does during scan-and-repair. +pub fn preferred_rollout_path(file: &Path, thread_id: &str) -> Option { + let codex_home = codex_home_for_rollout(file)?; + let sqlite_home = configured_sqlite_home(&codex_home) + .or_else(|| { + std::env::var("CODEX_SQLITE_HOME") + .ok() + .and_then(|value| resolve_sqlite_home_path(&value, &codex_home)) + }) + .unwrap_or(codex_home); + let db = sqlite_home.join("state_5.sqlite"); + if !db.is_file() { + return None; + } + let conn = Connection::open_with_flags( + db, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let status = conn + .query_row( + "SELECT status FROM backfill_state WHERE id = 1", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + if status != "complete" { + return None; + } + let rollout = conn + .query_row( + "SELECT rollout_path FROM threads WHERE id = ?1 AND archived = 0", + [thread_id], + |row| row.get::<_, String>(0), + ) + .optional() + .ok()??; + let rollout = PathBuf::from(rollout); + rollout.is_file().then_some(rollout) +} + +/// Walk every rollout .jsonl under a sessions tree (date-sharded YYYY/MM/DD, but walked +/// generically so a layout change doesn't lose sessions). Depth-capped against cycles. +pub fn walk_sessions(root: &Path, mut cb: F) { + fn walk(dir: &Path, depth: u32, cb: &mut F) { + if depth > 6 { + return; + } + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_dir() { + walk(&p, depth + 1, cb); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p); + } + } + } + walk(root, 0, &mut cb); +} + +/// Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies +/// imported into the app store, where the path no longer says so). Claude Code records never +/// use these type tags, and old-format bare Codex items lack Claude's `.message` wrapper. +pub fn looks_codex(recs: &[Value]) -> bool { + recs.iter().take(8).any(|r| { + match r.get("type").and_then(|v| v.as_str()) { + Some("session_meta") | Some("turn_context") | Some("event_msg") | Some("compacted") => true, + Some("response_item") => r.get("payload").is_some(), + // old envelope-less rollout: response items at the top level + Some("message") | Some("function_call") | Some("function_call_output") + | Some("reasoning") | Some("local_shell_call") => r.get("message").is_none(), + _ => r.get("record_type").is_some(), + } + }) +} diff --git a/src-tauri/src/codex/tests.rs b/src-tauri/src/codex/tests.rs new file mode 100644 index 0000000..05ef593 --- /dev/null +++ b/src-tauri/src/codex/tests.rs @@ -0,0 +1,171 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs; + +fn line(ts: &str, ty: &str, payload: Value) -> String { + serde_json::to_string(&json!({ "timestamp": ts, "type": ty, "payload": payload })).unwrap() +} + +fn fixture() -> Vec { + let lines = vec![ + line("2026-07-04T07:13:08.965Z", "session_meta", json!({ + "session_id": "019f-abc", "id": "019f-abc", "timestamp": "2026-07-04T07:13:07.386Z", + "cwd": "/tmp/projx", "originator": "codex-tui", "cli_version": "0.142.5", + "git": { "branch": "main" } + })), + line("2026-07-04T07:13:08.967Z", "turn_context", json!({ "cwd": "/tmp/projx", "model": "gpt-5.5" })), + line("2026-07-04T07:13:08.967Z", "response_item", json!({ + "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "\n/tmp/projx\n" }] + })), + line("2026-07-04T07:13:08.969Z", "response_item", json!({ + "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "fix the bug please" }] + })), + line("2026-07-04T07:13:09.100Z", "event_msg", json!({ "type": "user_message", "message": "fix the bug please" })), + line("2026-07-04T07:13:10.000Z", "response_item", json!({ + "type": "reasoning", "summary": [{ "type": "summary_text", "text": "Looking at the repo" }], "encrypted_content": "xxx" + })), + line("2026-07-04T07:13:11.000Z", "response_item", json!({ + "type": "function_call", "name": "exec_command", + "arguments": "{\"cmd\": \"ls -la\", \"yield_time_ms\": 10000}", "call_id": "call_1" + })), + line("2026-07-04T07:13:12.000Z", "response_item", json!({ + "type": "function_call_output", "call_id": "call_1", + "output": "Chunk ID: x\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\n---\na.txt\nb.txt" + })), + line("2026-07-04T07:13:13.000Z", "response_item", json!({ + "type": "function_call", "name": "shell", + "arguments": "{\"command\": [\"bash\", \"-lc\", \"cargo test\"], \"workdir\": \"/tmp/projx\"}", "call_id": "call_2" + })), + line("2026-07-04T07:13:14.000Z", "response_item", json!({ + "type": "function_call_output", "call_id": "call_2", + "output": "{\"output\": \"error: it broke\", \"metadata\": {\"exit_code\": 101, \"duration_seconds\": 1.5}}" + })), + line("2026-07-04T07:13:15.000Z", "response_item", json!({ + "type": "function_call", "name": "update_plan", + "arguments": "{\"plan\": [{\"step\": \"read code\", \"status\": \"completed\"}, {\"step\": \"fix bug\", \"status\": \"in_progress\"}]}", + "call_id": "call_3" + })), + line("2026-07-04T07:13:16.000Z", "response_item", json!({ + "type": "custom_tool_call", "name": "apply_patch", "call_id": "call_4", + "input": "*** Begin Patch\n*** Update File: src/a.rs\n@@\n-old\n+new\n*** End Patch" + })), + line("2026-07-04T07:13:17.000Z", "response_item", json!({ + "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Done — fixed." }], "phase": "final_answer" + })), + line("2026-07-04T07:13:17.500Z", "event_msg", json!({ + "type": "token_count", + "info": { + "total_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, + "last_token_usage": { "input_tokens": 900, "cached_input_tokens": 600, "output_tokens": 80, "total_tokens": 980 }, + "model_context_window": 258400 + } + })), + ]; + lines + .iter() + .map(|l| serde_json::from_str::(l).unwrap()) + .collect() +} + +#[test] +fn normalizes_rollout_into_renderer_model() { + let recs = fixture(); + assert!(looks_codex(&recs)); + let n = normalize(&recs); + + assert_eq!(n.session_id.as_deref(), Some("019f-abc")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/projx")); + assert_eq!(n.version.as_deref(), Some("0.142.5")); + assert_eq!(n.git_branch.as_deref(), Some("main")); + assert_eq!(n.model.as_deref(), Some("gpt-5.5")); + + // env-context user turn skipped; real prose, reasoning, 4 tool calls, 2 results, final text + let roles: Vec<&str> = n.messages.iter().map(|m| m["role"].as_str().unwrap()).collect(); + assert_eq!(roles, vec!["user", "assistant", "assistant", "user", "assistant", "user", "assistant", "assistant", "assistant"]); + + let title = crate::history::first_user_text(&n.messages); + assert_eq!(title, "fix the bug please"); + + // exec_command → Bash card with the raw command + let tu1 = &n.messages[2]["content"][0]; + assert_eq!(tu1["type"], "tool_use"); + assert_eq!(tu1["name"], "Bash"); + assert_eq!(tu1["input"]["command"], "ls -la"); + // its ok result pairs by call id and is not an error + let tr1 = &n.messages[3]["content"][0]; + assert_eq!(tr1["tool_use_id"], "call_1"); + assert!(tr1.get("is_error").is_none()); + + // shell argv ["bash","-lc","cargo test"] unwraps; exit_code 101 marks the result as error + let tu2 = &n.messages[4]["content"][0]; + assert_eq!(tu2["input"]["command"], "cargo test"); + let tr2 = &n.messages[5]["content"][0]; + assert_eq!(tr2["is_error"], true); + assert_eq!(tr2["content"], "error: it broke"); + + // update_plan → TodoWrite todos + let tu3 = &n.messages[6]["content"][0]; + assert_eq!(tu3["name"], "TodoWrite"); + assert_eq!(tu3["input"]["todos"][1]["status"], "in_progress"); + + // apply_patch custom tool → ApplyPatch {patch} + let tu4 = &n.messages[7]["content"][0]; + assert_eq!(tu4["name"], "ApplyPatch"); + assert!(tu4["input"]["patch"].as_str().unwrap().contains("*** Update File: src/a.rs")); + + // reasoning became a thinking block + assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); + + // token_count landed on the final assistant text turn and rolled into totals + let last = n.messages.last().unwrap(); + assert_eq!(last["usage"]["inputTokens"], 300); // input − cached + assert_eq!(last["usage"]["cacheRead"], 600); + assert_eq!(n.totals["out"], 80); + assert_eq!(n.totals["turns"], 1); + + // timestamps span the emitted messages + assert_eq!(n.first_ts.as_deref(), Some("2026-07-04T07:13:08.969Z")); + assert_eq!(n.last_ts.as_deref(), Some("2026-07-04T07:13:17.000Z")); +} + +// Machine-data smoke: run explicitly with `cargo test --lib -- --ignored` on a machine that +// has real Codex sessions. Verifies every real rollout sniffs + normalizes + shapes. +#[test] +#[ignore] +fn real_codex_sessions_smoke() { + if !root_exists() { + eprintln!("no ~/.codex/sessions — skipping"); + return; + } + let mut n = 0; + let label = codex_label(); + walk_sessions(&sessions_root(), |p| { + let raw = fs::read_to_string(&p).unwrap_or_default(); + let recs = crate::history::parse_lines(&raw); + assert!(looks_codex(&recs), "not sniffed as codex: {:?}", p); + let norm = normalize(&recs); + assert!(norm.session_id.is_some() || norm.messages.is_empty(), "no session id: {:?}", p); + let sess = session_from_recs(&p.to_string_lossy(), &recs); + assert_eq!(sess["meta"]["assistant"], "Codex"); + let listed = session_meta_from(&p, &recs, &label, &label).unwrap(); + assert_eq!(listed["source"], "codex"); + n += 1; + }); + eprintln!("smoke-checked {} real codex sessions", n); +} + +#[test] +fn turn_aborted_and_web_search_render() { + let recs: Vec = vec![ + serde_json::from_str(&line("2026-01-01T00:00:00Z", "response_item", json!({ + "type": "web_search_call", "id": "ws_1", "action": { "type": "search", "query": "rust serde" } + }))).unwrap(), + serde_json::from_str(&line("2026-01-01T00:00:01Z", "event_msg", json!({ "type": "turn_aborted", "reason": "interrupted" }))).unwrap(), + ]; + let n = normalize(&recs); + assert_eq!(n.messages[0]["content"][0]["name"], "WebSearch"); + assert_eq!(n.messages[0]["content"][0]["input"]["query"], "rust serde"); + assert!(n.messages[1]["content"][0]["text"].as_str().unwrap().starts_with("[Request interrupted")); +} diff --git a/src-tauri/src/codex/tests_more.rs b/src-tauri/src/codex/tests_more.rs new file mode 100644 index 0000000..4d3d036 --- /dev/null +++ b/src-tauri/src/codex/tests_more.rs @@ -0,0 +1,93 @@ +use super::*; +use super::exec::map_exec_script; +use serde_json::json; + +#[test] +fn claude_records_do_not_sniff_as_codex() { + let recs = vec![ + json!({ "type": "user", "message": { "role": "user", "content": "hi" }, "cwd": "/x", "sessionId": "s1" }), + json!({ "type": "assistant", "message": { "role": "assistant", "content": [{ "type": "text", "text": "hello" }] } }), + json!({ "type": "summary", "summary": "greeting" }), + ]; + assert!(!looks_codex(&recs)); +} + +#[test] +fn old_envelope_less_rollout_still_parses() { + let recs = vec![ + json!({ "id": "old-1", "timestamp": "2025-05-01T00:00:00Z", "instructions": "x", "cwd": "/tmp/old" }), + json!({ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hello old codex" }] }), + json!({ "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }), + ]; + assert!(looks_codex(&recs)); + let n = normalize(&recs); + assert_eq!(n.session_id.as_deref(), Some("old-1")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/old")); + assert_eq!(n.messages.len(), 2); + assert_eq!(crate::history::first_user_text(&n.messages), "hello old codex"); +} + +#[test] +fn maps_code_mode_exec_scripts() { + // canonical single exec_command + print plumbing → Bash card (command + workdir) + let (n, i) = map_exec_script( + "const r = await tools.exec_command({\"cmd\":\"ls -la\",\"workdir\":\"/tmp/p\",\"yield_time_ms\":10000,\"max_output_tokens\":30000});\ntext(r.output);", + ); + assert_eq!(n, "Bash"); + assert_eq!(i["command"], "ls -la"); + assert_eq!(i["description"], "/tmp/p"); + // bare JS object keys (code-mode literal) parse via the quoting retry; braces/quotes + // inside the command string stay opaque + let (n, i) = map_exec_script( + "const r = await tools.exec_command({cmd:\"awk '{print: $1}' a.txt\",workdir:\"/w\"});\ntext(JSON.stringify(r));", + ); + assert_eq!(n, "Bash"); + assert_eq!(i["command"], "awk '{print: $1}' a.txt"); + // SESSION_ID echo tail is still plumbing + let (n, _) = map_exec_script( + "const r = await tools.exec_command({\"cmd\":\"sleep 1\"});\ntext(r.output);\nif (r.session_id) text(`SESSION_ID=${r.session_id}`);", + ); + assert_eq!(n, "Bash"); + // write_stdin / multi-call orchestration keep the script verbatim + let (n, i) = map_exec_script("const r = await tools.write_stdin({\"session_id\":40352,\"chars\":\"\"});\ntext(r.output);"); + assert_eq!(n, "Script"); + assert!(i["code"].as_str().unwrap().contains("write_stdin")); + let (n, _) = map_exec_script( + "const a = await Promise.all([tools.exec_command({\"cmd\":\"x\"}), tools.exec_command({\"cmd\":\"y\"})]);\ntext(a.map(r => r.output).join());", + ); + assert_eq!(n, "Script"); +} + +#[test] +fn shapes_code_mode_block_array_outputs() { + let recs = vec![ + json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c1", + "input": "const r = await tools.exec_command({\"cmd\":\"ls\"});\ntext(r.output);" }), + json!({ "type": "custom_tool_call_output", "call_id": "c1", "output": [ + { "type": "input_text", "text": "Script completed\nWall time 0.1 seconds\nOutput:\n" }, + { "type": "input_text", "text": "a.txt\n" }, + { "type": "input_image", "image_url": "data:image/png;base64,QUJD", "detail": "high" } + ] }), + json!({ "type": "custom_tool_call", "name": "exec", "call_id": "c2", + "input": "const r = await tools.exec_command({\"cmd\":\"boom\"});\ntext(r.output);" }), + json!({ "type": "custom_tool_call_output", "call_id": "c2", "output": [ + { "type": "input_text", "text": "Script failed\nWall time 0.0 seconds\nOutput:\nerr" } + ] }), + ]; + let n = normalize(&recs); + assert_eq!(n.messages.len(), 4); + let tu = &n.messages[0]["content"][0]; + assert_eq!(tu["name"], "Bash"); + assert_eq!(tu["input"]["command"], "ls"); + // text chunks concatenate VERBATIM (no injected separators); screenshot rides along + let tr = &n.messages[1]["content"][0]; + assert_eq!(tr["tool_use_id"], "c1"); + assert_eq!(tr["content"][0]["text"], "Script completed\nWall time 0.1 seconds\nOutput:\na.txt\n"); + assert_eq!(tr["content"][1]["type"], "image"); + assert_eq!(tr["content"][1]["source"]["data"], "QUJD"); + assert!(tr.get("is_error").is_none()); + // "Script failed" header marks the result as an error; plain-text output stays a string + let tr2 = &n.messages[3]["content"][0]; + assert_eq!(tr2["content"].as_str().unwrap(), "Script failed\nWall time 0.0 seconds\nOutput:\nerr"); + assert_eq!(tr2["is_error"], true); +} diff --git a/src-tauri/src/codex/titles.rs b/src-tauri/src/codex/titles.rs new file mode 100644 index 0000000..fef8a4f --- /dev/null +++ b/src-tauri/src/codex/titles.rs @@ -0,0 +1,173 @@ +// User-text joining and title extraction from rollout content (split from codex.rs). + +use serde_json::{json, Value}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use super::records::split_line; + +pub(super) fn joined_text(content: &Value, kinds: &[&str]) -> String { + let arr = match content.as_array() { + Some(a) => a, + None => return content.as_str().unwrap_or("").to_string(), + }; + arr.iter() + .filter(|b| kinds.contains(&b.get("type").and_then(|t| t.as_str()).unwrap_or(""))) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") +} + +// Codex surrounds each real input_image with text-only transport tags. Replace the opening tag +// with its safe display name (`[Image #1]`) and drop the closing tag; image_block still carries +// the actual bitmap to the renderer. +fn image_transport_label(text: &str) -> Option { + let source = text.trim(); + let lower = source.to_ascii_lowercase(); + if !lower.starts_with("') { + return None; + } + let boundary = source.as_bytes().get(6).copied(); + if !matches!(boundary, Some(b'>')) && !boundary.map(|b| b.is_ascii_whitespace()).unwrap_or(false) { + return None; + } + + let mut label = None; + if let Some(pos) = lower.find("name") { + let rest = source[pos + 4..].trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim_start(); + label = if let Some(quote) = value.chars().next().filter(|c| *c == '"' || *c == '\'') { + value[quote.len_utf8()..] + .find(quote) + .map(|end| value[quote.len_utf8()..quote.len_utf8() + end].to_string()) + } else if value.starts_with('[') { + value.find(']').map(|end| value[..=end].to_string()) + } else { + Some(value.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or("").to_string()) + }; + } + } + Some(label.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[Image]".to_string())) +} + +pub(super) fn joined_user_text(content: &Value) -> String { + let arr = match content.as_array() { + Some(a) => a, + None => return content.as_str().unwrap_or("").to_string(), + }; + let has_image = arr + .iter() + .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("input_image")); + arr.iter() + .filter(|b| matches!(b.get("type").and_then(|t| t.as_str()), Some("input_text") | Some("text"))) + .filter_map(|b| { + let text = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if has_image { + if text.trim().eq_ignore_ascii_case("") { + return None; + } + if let Some(label) = image_transport_label(text) { + return Some(label); + } + } + if text.is_empty() { None } else { Some(text.to_string()) } + }) + .collect::>() + .join("\n") +} + +fn event_user_display_text(payload: &Value) -> String { + let message = payload.get("message").and_then(|v| v.as_str()).unwrap_or("").trim(); + let image_count = payload.get("images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0) + + payload.get("local_images").and_then(|v| v.as_array()).map(Vec::len).unwrap_or(0); + let labels = (1..=image_count) + .map(|i| format!("[Image #{}]", i)) + .collect::>() + .join(" "); + format!("{}{}{}", labels, if !labels.is_empty() && !message.is_empty() { " " } else { "" }, message) + .trim() + .to_string() +} + +fn event_user_title_from_record(rec: &Value) -> String { + let (ty, payload, _) = split_line(rec); + if ty != "event_msg" || payload.get("type").and_then(|v| v.as_str()) != Some("user_message") { + return String::new(); + } + let text = event_user_display_text(payload); + if text.is_empty() { + String::new() + } else { + crate::history::first_user_text(&[json!({ + "role": "user", + "content": [{ "type": "text", "text": text }], + })]) + } +} + +pub(super) fn first_event_user_title(recs: &[Value]) -> String { + recs.iter() + .map(event_user_title_from_record) + .find(|title| !title.is_empty()) + .unwrap_or_default() +} + +fn append_scan_segment(line: &mut Vec, dropping: &mut bool, segment: &[u8], max_line: usize) { + if *dropping || segment.is_empty() { + return; + } + if line.len().saturating_add(segment.len()) > max_line { + line.clear(); + *dropping = true; + } else { + line.extend_from_slice(segment); + } +} + +fn event_user_title_from_line(line: &[u8]) -> String { + let line = line.strip_suffix(b"\r").unwrap_or(line); + serde_json::from_slice::(line) + .map(|rec| event_user_title_from_record(&rec)) + .unwrap_or_default() +} + +// List metadata normally parses only the first 128 KiB. If an image-first response_item is a +// larger single JSON line, stream past it and read the following compact user_message event. +pub(super) fn scan_event_user_title(file: &Path) -> String { + const MAX_SCAN: usize = 64 * 1024 * 1024; + const MAX_LINE: usize = 256 * 1024; + let input = match fs::File::open(file) { + Ok(file) => file, + Err(_) => return String::new(), + }; + let mut reader = BufReader::new(input); + let mut line: Vec = vec![]; + let mut dropping = false; + let mut scanned = 0usize; + while scanned < MAX_SCAN { + let available = match reader.fill_buf() { + Ok(buf) if !buf.is_empty() => buf, + Ok(_) | Err(_) => break, + }; + let take = available.len().min(MAX_SCAN - scanned); + let mut start = 0usize; + for i in 0..take { + if available[i] == b'\n' { + append_scan_segment(&mut line, &mut dropping, &available[start..i], MAX_LINE); + let title = if dropping { String::new() } else { event_user_title_from_line(&line) }; + line.clear(); + dropping = false; + if !title.is_empty() { + return title; + } + start = i + 1; + } + } + append_scan_segment(&mut line, &mut dropping, &available[start..take], MAX_LINE); + reader.consume(take); + scanned = scanned.saturating_add(take); + } + if dropping { String::new() } else { event_user_title_from_line(&line) } +} diff --git a/src-tauri/src/codex/tools.rs b/src-tauri/src/codex/tools.rs new file mode 100644 index 0000000..face610 --- /dev/null +++ b/src-tauri/src/codex/tools.rs @@ -0,0 +1,130 @@ +// Codex tool call -> renderer tool card mapping, and tool output shaping (split from codex.rs). + +use serde_json::{json, Value}; + +use super::exec::exec_text_err; + +/// argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. +pub(super) fn join_argv(cmd: &Value) -> String { + if let Some(s) = cmd.as_str() { + return s.to_string(); + } + let arr = match cmd.as_array() { + Some(a) => a, + None => return String::new(), + }; + let parts: Vec = arr.iter().map(|x| x.as_str().unwrap_or_default().to_string()).collect(); + if parts.len() == 3 + && ["bash", "sh", "zsh", "dash"].contains(&parts[0].as_str()) + && ["-lc", "-c"].contains(&parts[1].as_str()) + { + return parts[2].clone(); + } + parts + .iter() + .map(|p| { + if p.is_empty() || p.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') { + format!("{:?}", p) // debug-quote args with spaces/quotes + } else { + p.clone() + } + }) + .collect::>() + .join(" ") +} + +/// Codex tool name + parsed arguments → (renderer tool name, renderer input). +pub(super) fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + match name { + "shell" | "local_shell" | "container.exec" => { + let mut input = json!({ "command": join_argv(args.get("command").unwrap_or(&Value::Null)) }); + let desc = if !s("justification").is_empty() { s("justification") } else { s("workdir") }; + if !desc.is_empty() { + input["description"] = json!(desc); + } + ("Bash".into(), input) + } + "shell_command" => ("Bash".into(), json!({ "command": s("command") })), + "exec_command" => { + let cmd = if !s("cmd").is_empty() { s("cmd") } else { s("command") }; + ("Bash".into(), json!({ "command": cmd })) + } + "apply_patch" => { + let patch = if !s("input").is_empty() { s("input") } else { s("patch") }; + ("ApplyPatch".into(), json!({ "patch": patch })) + } + "update_plan" => { + let todos: Vec = args + .get("plan") + .and_then(|p| p.as_array()) + .map(|a| { + a.iter() + .map(|st| { + json!({ + "content": st.get("step").and_then(|v| v.as_str()).unwrap_or(""), + "status": st.get("status").and_then(|v| v.as_str()).unwrap_or("pending"), + }) + }) + .collect() + }) + .unwrap_or_default(); + ("TodoWrite".into(), json!({ "todos": todos })) + } + "view_image" => ("Read".into(), json!({ "file_path": s("path") })), + "web_search" => ("WebSearch".into(), json!({ "query": s("query") })), + _ => ( + name.to_string(), + if args.is_object() { args.clone() } else { json!({}) }, + ), + } +} + +/// Tool output payload → (display text, is_error). Unwraps codex's JSON-wrapped shell output +/// ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. +pub(super) fn shape_output(out: &Value) -> (String, bool) { + // structured payload: { content, success? } + if out.is_object() { + let text = out + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| serde_json::to_string_pretty(out).unwrap_or_default()); + let err = out.get("success").and_then(|v| v.as_bool()) == Some(false); + return (text, err); + } + let s = out.as_str().unwrap_or("").to_string(); + if let Ok(v) = serde_json::from_str::(&s) { + if v.is_object() { + if let Some(o) = v.get("output").and_then(|x| x.as_str()) { + let code = v + .get("metadata") + .and_then(|m| m.get("exit_code")) + .and_then(|c| c.as_i64()) + .unwrap_or(0); + return (o.to_string(), code != 0); + } + if let Some(c) = v.get("content").and_then(|x| x.as_str()) { + let err = v.get("success").and_then(|x| x.as_bool()) == Some(false); + return (c.to_string(), err); + } + } + } + // code-mode runner header (older builds wrote it as a plain string): "Exit code: N…" / + // "Script failed…" + if exec_text_err(&s) { + return (s, true); + } + // exec_command header: "…\nProcess exited with code N\n…" near the top + let head: String = s.chars().take(240).collect(); + if let Some(pos) = head.find("exited with code ") { + let digits: String = head[pos + "exited with code ".len()..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(code) = digits.parse::() { + return (s, code != 0); + } + } + (s, false) +} diff --git a/src-tauri/src/codexconnect.rs b/src-tauri/src/codexconnect.rs deleted file mode 100644 index bb66119..0000000 --- a/src-tauri/src/codexconnect.rs +++ /dev/null @@ -1,232 +0,0 @@ -// Codex CLI integration — point Codex at the local gateway by injecting a custom model provider -// into ~/.codex/config.toml (CODEX_HOME-aware). Mirrors claude.rs's connect/disconnect+backup, but -// for Codex's TOML config: we add a `[model_providers.ccbud]` block (base_url → gateway, a static -// dev bearer token, requires_openai_auth=false so Codex doesn't demand an sk- prefix) and switch -// `model_provider`/`model` to it. The user's prior model/model_provider are backed up into -// config.codexBackup once; Disconnect restores them and removes our block. Editing is done with -// toml_edit so the user's other settings, comments, and formatting survive untouched. -// -// wire_api = "responses": Codex speaks the OpenAI Responses API to the gateway (Codex has -// deprecated wire_api = "chat" and only supports "responses"), and the gateway translates to -// whatever protocol the ACTIVE provider uses (responses passthrough, responses→chat, or -// responses→messages for an Anthropic provider). Config-path override for tests: -// CCBUD_CODEX_CONFIG. - -#![allow(dead_code)] - -use crate::store; -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; -use toml_edit::{value, DocumentMut, Item, Table}; - -const PROVIDER_ID: &str = "ccbud"; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -pub fn config_path() -> PathBuf { - if let Ok(p) = std::env::var("CCBUD_CODEX_CONFIG") { - if !p.is_empty() { - return PathBuf::from(p); - } - } - match std::env::var("CODEX_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("config.toml"), - _ => home().join(".codex").join("config.toml"), - } -} - -/// Whether Codex is installed enough to connect (its config dir or config file exists). We don't -/// require the file to pre-exist — connect creates it — but we do want ~/.codex to be present so we -/// don't spuriously offer Codex to users who don't have it. -pub fn is_available() -> bool { - let p = config_path(); - p.exists() || p.parent().map(|d| d.is_dir()).unwrap_or(false) -} - -fn read_doc() -> DocumentMut { - fs::read_to_string(config_path()) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or_default() -} - -fn write_doc(doc: &DocumentMut) -> std::io::Result<()> { - let p = config_path(); - if let Some(dir) = p.parent() { - let _ = fs::create_dir_all(dir); - } - let tmp = p.with_extension("ccbud.tmp"); - fs::write(&tmp, doc.to_string())?; - fs::rename(&tmp, &p) -} - -fn gateway_base(port: u16) -> String { - format!("http://localhost:{}/v1", port) -} - -pub fn is_connected(port: u16) -> bool { - let doc = read_doc(); - doc.get("model_providers") - .and_then(|mp| mp.as_table()) - .and_then(|t| t.get(PROVIDER_ID)) - .and_then(|p| p.as_table()) - .and_then(|t| t.get("base_url")) - .and_then(|b| b.as_str()) - .map(|b| b == gateway_base(port)) - .unwrap_or(false) -} - -/// Connect Codex to the gateway. `model` is the model Codex will request (routed by the gateway); -/// `token` is the bearer written inline (a local placeholder unless the gateway enforces a token). -pub fn connect(port: u16, token: &str, model: &str) { - let mut doc = read_doc(); - - // Back up the user's prior model/model_provider exactly once (before we overwrite them). - let cfg = store::read_config(); - if cfg.get("codexBackup").map(|v| v.is_null()).unwrap_or(true) { - let prior_model = doc.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); - let prior_provider = doc.get("model_provider").and_then(|v| v.as_str()).map(|s| s.to_string()); - let prior_effort = doc.get("model_reasoning_effort").and_then(|v| v.as_str()).map(|s| s.to_string()); - let backup = json!({ - "model": prior_model.map(Value::String).unwrap_or(Value::Null), - "model_provider": prior_provider.map(Value::String).unwrap_or(Value::Null), - "model_reasoning_effort": prior_effort.map(Value::String).unwrap_or(Value::Null), - }); - let mut next = cfg.clone(); - next["codexBackup"] = backup; - store::write_config(next); - } - - // Point Codex at our provider. - doc["model_provider"] = value(PROVIDER_ID); - if !model.is_empty() { - doc["model"] = value(model); - } - // Default the thinking level to ultra; the gateway/plugin clamps it to what the - // active provider actually supports (e.g. grok caps at "high"). - doc["model_reasoning_effort"] = value("ultra"); - - // Ensure [model_providers] exists as a real table, then set our block. - if !doc.contains_key("model_providers") { - doc["model_providers"] = Item::Table(Table::new()); - } - let mut block = Table::new(); - block.insert("name", value("CC Buddy")); - block.insert("base_url", value(gateway_base(port))); - block.insert("wire_api", value("responses")); - block.insert("requires_openai_auth", value(false)); - block.insert("experimental_bearer_token", value(token)); - if let Some(mp) = doc["model_providers"].as_table_mut() { - mp.insert(PROVIDER_ID, Item::Table(block)); - } - - let _ = write_doc(&doc); -} - -/// Disconnect Codex: restore the backed-up model/model_provider and remove our provider block. -pub fn disconnect() { - let cfg = store::read_config(); - let backup = cfg.get("codexBackup").cloned().unwrap_or(Value::Null); - let mut doc = read_doc(); - - // Remove our provider block. - if let Some(mp) = doc.get_mut("model_providers").and_then(|v| v.as_table_mut()) { - mp.remove(PROVIDER_ID); - // Drop the whole table if it's now empty so we don't leave `[model_providers]` dangling. - if mp.is_empty() { - doc.as_table_mut().remove("model_providers"); - } - } - - if backup.is_object() { - match backup.get("model_provider").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model_provider"] = value(s), - _ => { - doc.as_table_mut().remove("model_provider"); - } - } - match backup.get("model").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model"] = value(s), - _ => { - doc.as_table_mut().remove("model"); - } - } - match backup.get("model_reasoning_effort").cloned().unwrap_or(Value::Null) { - Value::String(s) => doc["model_reasoning_effort"] = value(s), - _ => { - doc.as_table_mut().remove("model_reasoning_effort"); - } - } - let mut next = cfg.clone(); - next["codexBackup"] = Value::Null; - store::write_config(next); - } else { - // No backup (connected out-of-band): just drop the pointer we would have set. - if doc.get("model_provider").and_then(|v| v.as_str()) == Some(PROVIDER_ID) { - doc.as_table_mut().remove("model_provider"); - } - } - - let _ = write_doc(&doc); -} - -#[cfg(test)] -mod tests { - use super::*; - - // One test (CCBUD_HOME / CCBUD_CODEX_CONFIG are process-global env, so a single sequential test - // avoids racing other tests on them). - #[test] - fn connect_disconnect_round_trip() { - let dir = std::env::temp_dir().join(format!("ccbud-codexconn-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - let _ = fs::create_dir_all(&dir); - std::env::set_var("CCBUD_CODEX_CONFIG", dir.join("config.toml")); - std::env::set_var("CCBUD_HOME", dir.join("ccbud-home")); - - // --- case 1: pre-existing config with a comment + unrelated setting must survive --- - fs::write(config_path(), "# my codex config\nmodel = \"gpt-5\"\nmodel_provider = \"openai\"\napproval_policy = \"on-request\"\n").unwrap(); - connect(4321, "ccbud-local", "z-ai/glm-5.2"); - assert!(is_connected(4321)); - let raw = fs::read_to_string(config_path()).unwrap(); - assert!(raw.contains("# my codex config"), "user comment preserved"); - assert!(raw.contains("approval_policy"), "unrelated setting preserved"); - assert!(raw.contains("[model_providers.ccbud]")); - assert!(raw.contains("name = \"CC Buddy\"")); - assert!(raw.contains("base_url = \"http://localhost:4321/v1\"")); - assert!(raw.contains("wire_api = \"responses\""), "codex only supports the responses wire API"); - assert!(raw.contains("requires_openai_auth = false")); - assert!(raw.contains("experimental_bearer_token = \"ccbud-local\"")); - assert!(raw.contains("model_provider = \"ccbud\"")); - assert!(raw.contains("model = \"z-ai/glm-5.2\"")); - assert!(raw.contains("model_reasoning_effort = \"ultra\""), "thinking level defaulted to ultra"); - - disconnect(); - assert!(!is_connected(4321)); - let raw = fs::read_to_string(config_path()).unwrap(); - assert!(!raw.contains("ccbud"), "our block + pointer gone: {}", raw); - assert!(raw.contains("model = \"gpt-5\""), "prior model restored"); - assert!(!raw.contains("model_reasoning_effort"), "effort removed on disconnect (none prior)"); - assert!(raw.contains("model_provider = \"openai\""), "prior provider restored"); - assert!(raw.contains("approval_policy"), "unrelated setting still there"); - - // --- case 2: no config file at all → connect creates one, disconnect leaves no ccbud --- - let _ = fs::remove_file(config_path()); - { - // clear the backup from case 1 so case 2 records its own (none) - let mut c = store::read_config(); - c["codexBackup"] = Value::Null; - store::write_config(c); - } - connect(8788, "tok", "m1"); - assert!(is_connected(8788)); - disconnect(); - let raw = fs::read_to_string(config_path()).unwrap_or_default(); - assert!(!raw.contains("ccbud")); - - let _ = fs::remove_dir_all(&dir); - } -} diff --git a/src-tauri/src/codexconnect/config.rs b/src-tauri/src/codexconnect/config.rs new file mode 100644 index 0000000..60a18e0 --- /dev/null +++ b/src-tauri/src/codexconnect/config.rs @@ -0,0 +1,54 @@ +// Where Codex's config.toml lives (CODEX_HOME / CCBUD_CODEX_CONFIG aware) and how we read and +// write it. toml_edit keeps the user's other settings, comments and formatting untouched; writes +// go through a tmp+rename so a crash mid-write never leaves a torn config. + +use std::fs; +use std::path::PathBuf; +use toml_edit::DocumentMut; + +pub(super) const PROVIDER_ID: &str = "ccbud"; + +pub(super) fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("CCBUD_CODEX_CONFIG") { + if !p.is_empty() { + return PathBuf::from(p); + } + } + match std::env::var("CODEX_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h).join("config.toml"), + _ => home().join(".codex").join("config.toml"), + } +} + +/// Whether Codex is installed enough to connect (its config dir or config file exists). We don't +/// require the file to pre-exist — connect creates it — but we do want ~/.codex to be present so we +/// don't spuriously offer Codex to users who don't have it. +pub fn is_available() -> bool { + let p = config_path(); + p.exists() || p.parent().map(|d| d.is_dir()).unwrap_or(false) +} + +pub(super) fn read_doc() -> DocumentMut { + fs::read_to_string(config_path()) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_default() +} + +pub(super) fn write_doc(doc: &DocumentMut) -> std::io::Result<()> { + let p = config_path(); + if let Some(dir) = p.parent() { + let _ = fs::create_dir_all(dir); + } + let tmp = p.with_extension("ccbud.tmp"); + fs::write(&tmp, doc.to_string())?; + fs::rename(&tmp, &p) +} + +pub(super) fn gateway_base(port: u16) -> String { + format!("http://localhost:{}/v1", port) +} diff --git a/src-tauri/src/codexconnect/connect.rs b/src-tauri/src/codexconnect/connect.rs new file mode 100644 index 0000000..015a462 --- /dev/null +++ b/src-tauri/src/codexconnect/connect.rs @@ -0,0 +1,113 @@ +// Connect / disconnect: inject (and later remove) the `[model_providers.ccbud]` block and the +// model/model_provider pointers, backing the user's prior values up into config.codexBackup once. + +use super::config::{gateway_base, read_doc, write_doc, PROVIDER_ID}; +use crate::store; +use serde_json::{json, Value}; +use toml_edit::{value, Item, Table}; + +pub fn is_connected(port: u16) -> bool { + let doc = read_doc(); + doc.get("model_providers") + .and_then(|mp| mp.as_table()) + .and_then(|t| t.get(PROVIDER_ID)) + .and_then(|p| p.as_table()) + .and_then(|t| t.get("base_url")) + .and_then(|b| b.as_str()) + .map(|b| b == gateway_base(port)) + .unwrap_or(false) +} + +/// Connect Codex to the gateway. `model` is the model Codex will request (routed by the gateway); +/// `token` is the bearer written inline (a local placeholder unless the gateway enforces a token). +pub fn connect(port: u16, token: &str, model: &str) { + let mut doc = read_doc(); + + // Back up the user's prior model/model_provider exactly once (before we overwrite them). + let cfg = store::read_config(); + if cfg.get("codexBackup").map(|v| v.is_null()).unwrap_or(true) { + let prior_model = doc.get("model").and_then(|v| v.as_str()).map(|s| s.to_string()); + let prior_provider = doc.get("model_provider").and_then(|v| v.as_str()).map(|s| s.to_string()); + let prior_effort = doc.get("model_reasoning_effort").and_then(|v| v.as_str()).map(|s| s.to_string()); + let backup = json!({ + "model": prior_model.map(Value::String).unwrap_or(Value::Null), + "model_provider": prior_provider.map(Value::String).unwrap_or(Value::Null), + "model_reasoning_effort": prior_effort.map(Value::String).unwrap_or(Value::Null), + }); + let mut next = cfg.clone(); + next["codexBackup"] = backup; + store::write_config(next); + } + + // Point Codex at our provider. + doc["model_provider"] = value(PROVIDER_ID); + if !model.is_empty() { + doc["model"] = value(model); + } + // Default the thinking level to ultra; the gateway/plugin clamps it to what the + // active provider actually supports (e.g. grok caps at "high"). + doc["model_reasoning_effort"] = value("ultra"); + + // Ensure [model_providers] exists as a real table, then set our block. + if !doc.contains_key("model_providers") { + doc["model_providers"] = Item::Table(Table::new()); + } + let mut block = Table::new(); + block.insert("name", value("CC Buddy")); + block.insert("base_url", value(gateway_base(port))); + block.insert("wire_api", value("responses")); + block.insert("requires_openai_auth", value(false)); + block.insert("experimental_bearer_token", value(token)); + if let Some(mp) = doc["model_providers"].as_table_mut() { + mp.insert(PROVIDER_ID, Item::Table(block)); + } + + let _ = write_doc(&doc); +} + +/// Disconnect Codex: restore the backed-up model/model_provider and remove our provider block. +pub fn disconnect() { + let cfg = store::read_config(); + let backup = cfg.get("codexBackup").cloned().unwrap_or(Value::Null); + let mut doc = read_doc(); + + // Remove our provider block. + if let Some(mp) = doc.get_mut("model_providers").and_then(|v| v.as_table_mut()) { + mp.remove(PROVIDER_ID); + // Drop the whole table if it's now empty so we don't leave `[model_providers]` dangling. + if mp.is_empty() { + doc.as_table_mut().remove("model_providers"); + } + } + + if backup.is_object() { + match backup.get("model_provider").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model_provider"] = value(s), + _ => { + doc.as_table_mut().remove("model_provider"); + } + } + match backup.get("model").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model"] = value(s), + _ => { + doc.as_table_mut().remove("model"); + } + } + match backup.get("model_reasoning_effort").cloned().unwrap_or(Value::Null) { + Value::String(s) => doc["model_reasoning_effort"] = value(s), + _ => { + doc.as_table_mut().remove("model_reasoning_effort"); + } + } + let mut next = cfg.clone(); + next["codexBackup"] = Value::Null; + store::write_config(next); + } else { + // No backup (connected out-of-band): just drop the pointer we would have set. + if doc.get("model_provider").and_then(|v| v.as_str()) == Some(PROVIDER_ID) { + doc.as_table_mut().remove("model_provider"); + } + } + + let _ = write_doc(&doc); +} diff --git a/src-tauri/src/codexconnect/mod.rs b/src-tauri/src/codexconnect/mod.rs new file mode 100644 index 0000000..65f03e0 --- /dev/null +++ b/src-tauri/src/codexconnect/mod.rs @@ -0,0 +1,22 @@ +// Codex CLI integration — point Codex at the local gateway by injecting a custom model provider +// into ~/.codex/config.toml (CODEX_HOME-aware). Mirrors claude.rs's connect/disconnect+backup, but +// for Codex's TOML config: we add a `[model_providers.ccbud]` block (base_url → gateway, a static +// dev bearer token, requires_openai_auth=false so Codex doesn't demand an sk- prefix) and switch +// `model_provider`/`model` to it. The user's prior model/model_provider are backed up into +// config.codexBackup once; Disconnect restores them and removes our block. Editing is done with +// toml_edit so the user's other settings, comments, and formatting survive untouched. +// +// wire_api = "responses": Codex speaks the OpenAI Responses API to the gateway (Codex has +// deprecated wire_api = "chat" and only supports "responses"), and the gateway translates to +// whatever protocol the ACTIVE provider uses (responses passthrough, responses→chat, or +// responses→messages for an Anthropic provider). Config-path override for tests: +// CCBUD_CODEX_CONFIG. +#![allow(dead_code)] + +mod config; +mod connect; +#[cfg(test)] +mod tests; + +pub use config::is_available; +pub use connect::{connect, disconnect, is_connected}; diff --git a/src-tauri/src/codexconnect/tests.rs b/src-tauri/src/codexconnect/tests.rs new file mode 100644 index 0000000..f4c6001 --- /dev/null +++ b/src-tauri/src/codexconnect/tests.rs @@ -0,0 +1,58 @@ +use super::config::config_path; +use super::{connect, disconnect, is_connected}; +use crate::store; +use serde_json::Value; +use std::fs; + +// One test (CCBUD_HOME / CCBUD_CODEX_CONFIG are process-global env, so a single sequential test +// avoids racing other tests on them). +#[test] +fn connect_disconnect_round_trip() { + let dir = std::env::temp_dir().join(format!("ccbud-codexconn-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let _ = fs::create_dir_all(&dir); + std::env::set_var("CCBUD_CODEX_CONFIG", dir.join("config.toml")); + std::env::set_var("CCBUD_HOME", dir.join("ccbud-home")); + + // --- case 1: pre-existing config with a comment + unrelated setting must survive --- + fs::write(config_path(), "# my codex config\nmodel = \"gpt-5\"\nmodel_provider = \"openai\"\napproval_policy = \"on-request\"\n").unwrap(); + connect(4321, "ccbud-local", "z-ai/glm-5.2"); + assert!(is_connected(4321)); + let raw = fs::read_to_string(config_path()).unwrap(); + assert!(raw.contains("# my codex config"), "user comment preserved"); + assert!(raw.contains("approval_policy"), "unrelated setting preserved"); + assert!(raw.contains("[model_providers.ccbud]")); + assert!(raw.contains("name = \"CC Buddy\"")); + assert!(raw.contains("base_url = \"http://localhost:4321/v1\"")); + assert!(raw.contains("wire_api = \"responses\""), "codex only supports the responses wire API"); + assert!(raw.contains("requires_openai_auth = false")); + assert!(raw.contains("experimental_bearer_token = \"ccbud-local\"")); + assert!(raw.contains("model_provider = \"ccbud\"")); + assert!(raw.contains("model = \"z-ai/glm-5.2\"")); + assert!(raw.contains("model_reasoning_effort = \"ultra\""), "thinking level defaulted to ultra"); + + disconnect(); + assert!(!is_connected(4321)); + let raw = fs::read_to_string(config_path()).unwrap(); + assert!(!raw.contains("ccbud"), "our block + pointer gone: {}", raw); + assert!(raw.contains("model = \"gpt-5\""), "prior model restored"); + assert!(!raw.contains("model_reasoning_effort"), "effort removed on disconnect (none prior)"); + assert!(raw.contains("model_provider = \"openai\""), "prior provider restored"); + assert!(raw.contains("approval_policy"), "unrelated setting still there"); + + // --- case 2: no config file at all → connect creates one, disconnect leaves no ccbud --- + let _ = fs::remove_file(config_path()); + { + // clear the backup from case 1 so case 2 records its own (none) + let mut c = store::read_config(); + c["codexBackup"] = Value::Null; + store::write_config(c); + } + connect(8788, "tok", "m1"); + assert!(is_connected(8788)); + disconnect(); + let raw = fs::read_to_string(config_path()).unwrap_or_default(); + assert!(!raw.contains("ccbud")); + + let _ = fs::remove_dir_all(&dir); +} diff --git a/src-tauri/src/commands/autoupdate.rs b/src-tauri/src/commands/autoupdate.rs new file mode 100644 index 0000000..4dfe6e5 --- /dev/null +++ b/src-tauri/src/commands/autoupdate.rs @@ -0,0 +1,153 @@ +// Daily auto-update flow (first time the app becomes visible each day), moved verbatim from +// lib.rs. The AUTO_UPDATE_* bookkeeping statics travel with it. + +use serde_json::{json, Value}; + +use crate::popover::now_ms; +use crate::store; +use crate::tray::config_lang; + +use super::update::{ + run_update_check, run_update_download, FlagGuard, UPDATE_LATEST, UPDATE_STAGED, +}; + +// Daily auto-check bookkeeping: the day (local YYYY-MM-DD) whose auto check already completed +// (in-memory mirror of the on-disk stamp), an in-flight guard, and the last attempt time so a +// failed attempt (offline) is retried on a later visibility change instead of on every focus. +static AUTO_UPDATE_DONE_DAY: std::sync::Mutex> = std::sync::Mutex::new(None); +static AUTO_UPDATE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static AUTO_UPDATE_LAST_TRY_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +const AUTO_UPDATE_RETRY_MS: i64 = 10 * 60 * 1000; + +// ---- daily auto update (first time the app becomes visible each day) ---- +// The stamp lives in its own tiny file (NOT config.json) so the daily writer never races the +// renderer's whole-config round-trips through config_save. +fn auto_update_stamp_file() -> std::path::PathBuf { + store::ccbud_home().join("update-check.json") +} +fn today_local() -> String { + chrono::Local::now().format("%Y-%m-%d").to_string() +} +fn last_auto_update_day() -> String { + std::fs::read_to_string(auto_update_stamp_file()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("lastAutoCheckDay").and_then(|d| d.as_str()).map(|s| s.to_string())) + .unwrap_or_default() +} +fn mark_auto_update_day(day: &str) { + if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { + *g = Some(day.to_string()); + } + let _ = std::fs::create_dir_all(store::ccbud_home()); + let _ = std::fs::write( + auto_update_stamp_file(), + serde_json::to_vec(&json!({ "lastAutoCheckDay": day })).unwrap_or_default(), + ); +} + +// Native restart prompt after an auto-downloaded update (localized like tray_labels — the main +// window may be hidden when the popover triggered the check, so this can't live in the renderer). +struct UpdatePromptLabels { + title: &'static str, + body: &'static str, // {v} → new version + restart: &'static str, + later: &'static str, +} +fn update_prompt_labels(lang: &str) -> UpdatePromptLabels { + match lang { + "zh" | "zh-CN" => UpdatePromptLabels { title: "更新已就绪", body: "新版本 {v} 已自动下载完成。是否立即重启以应用新版本?", restart: "立即重启", later: "稍后" }, + "zh-TW" => UpdatePromptLabels { title: "更新已就緒", body: "新版本 {v} 已自動下載完成。要立即重新啟動以套用新版本嗎?", restart: "立即重啟", later: "稍後" }, + "ja" => UpdatePromptLabels { title: "アップデートの準備ができました", body: "新しいバージョン {v} のダウンロードが完了しました。今すぐ再起動して適用しますか?", restart: "今すぐ再起動", later: "後で" }, + "ko" => UpdatePromptLabels { title: "업데이트 준비 완료", body: "새 버전 {v} 다운로드가 완료되었습니다. 지금 다시 시작하여 적용할까요?", restart: "지금 다시 시작", later: "나중에" }, + _ => UpdatePromptLabels { title: "Update ready", body: "Version {v} has been downloaded. Restart now to switch to the new version?", restart: "Restart now", later: "Later" }, + } +} +async fn prompt_restart_to_apply(app: &tauri::AppHandle) { + let version = UPDATE_LATEST + .lock() + .ok() + .and_then(|g| g.as_ref().map(|(v, _)| v.clone())) + .unwrap_or_default(); + let l = update_prompt_labels(&config_lang(&store::read_config())); + // On Linux this shells out to zenity; without it rfd logs an error and returns Cancel, + // degrading to the staged update applying on the next launch (About pane shows "restart"). + let res = rfd::AsyncMessageDialog::new() + .set_level(rfd::MessageLevel::Info) + .set_title(l.title) + .set_description(l.body.replace("{v}", &version)) + .set_buttons(rfd::MessageButtons::OkCancelCustom(l.restart.to_string(), l.later.to_string())) + .show() + .await; + if matches!(&res, rfd::MessageDialogResult::Custom(s) if s == l.restart) { + app.restart(); + } +} + +/// Called from every "app became visible" site (main window focus, popover show, launch). +/// The first such moment each day — with autoUpdate.check on — runs one update check; when an +/// update exists and autoUpdate.autoDownload is on it's downloaded, then the user is asked +/// whether to restart into the new version (declining leaves it staged for the next launch). +/// The day is stamped only after a flow that reached the network succeeds, so an offline +/// launch doesn't burn the day's only attempt — the next visibility (≥10 min later) retries. +pub(crate) fn auto_update_on_visible(app: &tauri::AppHandle) { + let today = today_local(); + if AUTO_UPDATE_DONE_DAY + .lock() + .map(|g| g.as_deref() == Some(today.as_str())) + .unwrap_or(false) + { + return; + } + if last_auto_update_day() == today { + // Stamped by a previous run of this process instance or a crashed one — mirror it. + if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { + *g = Some(today); + } + return; + } + if now_ms() - AUTO_UPDATE_LAST_TRY_MS.load(std::sync::atomic::Ordering::Relaxed) < AUTO_UPDATE_RETRY_MS { + return; + } + if AUTO_UPDATE_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + let running = FlagGuard(&AUTO_UPDATE_RUNNING); + let au = store::read_config().get("autoUpdate").cloned().unwrap_or_else(|| json!({})); + if !au.get("check").and_then(|v| v.as_bool()).unwrap_or(true) { + return; // `running` drops here and clears the flag + } + AUTO_UPDATE_LAST_TRY_MS.store(now_ms(), std::sync::atomic::Ordering::Relaxed); + let auto_dl = au.get("autoDownload").and_then(|v| v.as_bool()).unwrap_or(true); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _running = running; // held until the task ends (cleared even on panic/unwind) + match run_update_check(&app).await { + Ok(None) => mark_auto_update_day(&today), + Ok(Some(_)) => { + let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); + if staged { + // Downloaded on an earlier day but never restarted — just re-ask. + mark_auto_update_day(&today); + prompt_restart_to_apply(&app).await; + } else if !auto_dl { + mark_auto_update_day(&today); // surfaced in the About pane only + } else { + match run_update_download(&app).await { + Ok(_) => { + mark_auto_update_day(&today); + if UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false) { + prompt_restart_to_apply(&app).await; + } + } + // A manual download is already in flight — the user took over today's + // update (the About pane drives the rest), so the day is done. + Err(e) if e == "busy" => mark_auto_update_day(&today), + Err(_) => {} // download failed → day left unstamped so a later visibility retries + } + } + } + Err(_) => {} // check failed (offline?) → retry on a later visibility + } + }); +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..0547757 --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,157 @@ +// Config + provider CRUD commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::tray::update_tray_title; +use crate::{claude, codexconnect, gateway, store, usage}; + +use super::connect::codex_model; +use super::gateway::full_status; +use super::plugins::PluginState; + +// ---- config / providers (real, store.rs) ---- +#[tauri::command] +pub(crate) fn config_get() -> Value { + store::read_config() +} +/// Last gateway start error (e.g. a bad port the user typed). Surfaced via server:status so the +/// renderer can show the failure banner. Mirrors main.js lastStartError. +pub(super) static LAST_START_ERROR: std::sync::Mutex> = std::sync::Mutex::new(None); +#[tauri::command] +pub(crate) async fn config_save( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + cfg: Value, +) -> Result { + let prev = store::read_config(); + let prev_port = prev.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let next_port = cfg + .get("port") + .and_then(|v| v.as_u64()) + .map(|p| p as u16) + .unwrap_or(prev_port); + let was_connected = claude::is_connected(prev_port); + let codex_was_connected = codexconnect::is_connected(prev_port); + let prev_dirs = prev.get("historyDirs").cloned(); + + // If the gateway is running and the port changed, bind the NEW port BEFORE committing so a bad + // port can never lock the user out — roll back to the old port and report on failure. + if next_port != prev_port && gw.current_port().await.is_some() { + gw.stop().await; + if let Err(e) = gw.start(next_port).await { + let _ = gw.start(prev_port).await; + let msg = format!("端口 {} 启动失败:{}", next_port, e); + *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); + gw.emit("gateway:status", full_status(&gw).await); + return Err(msg); + } + *LAST_START_ERROR.lock().unwrap() = None; + } + + let saved = store::write_config(cfg); + use tauri_plugin_autostart::ManagerExt; + let want = saved.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false); + let mgr = app.autolaunch(); + let _ = if want { mgr.enable() } else { mgr.disable() }; + + // Keep each connected CLI's config in sync if connected (port/token may have changed). + if was_connected || codex_was_connected { + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(&saved); + if was_connected { + claude::connect(port, &token); + } + if codex_was_connected { + codexconnect::connect(port, &token, &codex_model(&saved)); + } + } + + // History dirs changed → invalidate + re-warm the usage cache and notify the renderer. + if saved.get("historyDirs").cloned() != prev_dirs { + usage::invalidate_cache(); + let cfg2 = saved.clone(); + std::thread::spawn(move || usage::warm_cache(&cfg2, "all")); + let _ = app.emit("history:changed", json!({ "files": [] })); + } + + update_tray_title(&app); + gw.emit("gateway:status", full_status(&gw).await); + Ok(saved) +} +#[tauri::command] +pub(crate) fn provider_upsert(p: Value) -> Value { + let mut cfg = store::read_config(); + let mut provider = p; + let pid = provider.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); + { + let provs = cfg["providers"].as_array_mut().unwrap(); + match pid { + Some(id) if !id.is_empty() => { + if let Some(i) = provs + .iter() + .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(id.as_str())) + { + provs[i] = provider; + } else { + provs.push(provider); + } + } + _ => { + let id = store::gen_id(); + provider + .as_object_mut() + .unwrap() + .insert("id".into(), json!(id.clone())); + provs.push(provider); + if cfg["activeProviderId"].is_null() { + cfg["activeProviderId"] = json!(id); + } + } + } + } + store::write_config(cfg) +} +#[tauri::command] +pub(crate) fn provider_delete(id: String) -> Value { + let mut cfg = store::read_config(); + let kept: Vec = cfg["providers"] + .as_array() + .map(|a| { + a.iter() + .filter(|p| p.get("id").and_then(|v| v.as_str()) != Some(id.as_str())) + .cloned() + .collect() + }) + .unwrap_or_default(); + cfg["providers"] = json!(kept); + if cfg["activeProviderId"].as_str() == Some(id.as_str()) { + cfg["activeProviderId"] = cfg["providers"] + .as_array() + .and_then(|a| a.first()) + .and_then(|p| p.get("id").cloned()) + .unwrap_or(Value::Null); + } + store::write_config(cfg) +} +#[tauri::command] +pub(crate) fn provider_set_active(pm: PluginState<'_>, id: String) -> Result { + let cfg = store::read_config(); + // A plugin-backed service can only be activated while its plugin is running — + // otherwise the gateway would forward to a dead port. The UI localizes this code. + if let Some(p) = cfg + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))) + { + if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { + let plugin_id = p.get("pluginId").and_then(|v| v.as_str()).unwrap_or(""); + if !pm.is_running(plugin_id) { + return Err("pluginNotRunning".into()); + } + } + } + let mut cfg = store::read_config(); + cfg["activeProviderId"] = json!(id); + Ok(store::write_config(cfg)) +} diff --git a/src-tauri/src/commands/connect.rs b/src-tauri/src/commands/connect.rs new file mode 100644 index 0000000..8ff474d --- /dev/null +++ b/src-tauri/src/commands/connect.rs @@ -0,0 +1,195 @@ +// Coding-CLI connect/disconnect commands and the connectTargets helpers, moved verbatim +// from lib.rs. + +use serde_json::{json, Value}; + +use crate::tray::refresh_tray_menu; +use crate::{claude, codexconnect, gateway, store}; + +use super::config::LAST_START_ERROR; +use super::gateway::full_status; + +// ---- coding CLI connect / replay ---- +/// The literal selected CLIs from config `connectTargets` (subset of {claude, codex}, deduped). +/// Empty is a valid state ("nothing connected") — the hero Connect button substitutes a default. +fn connect_targets(cfg: &Value) -> Vec { + let mut out: Vec = vec![]; + if let Some(a) = cfg.get("connectTargets").and_then(|v| v.as_array()) { + for v in a { + if let Some(s) = v.as_str() { + if (s == "claude" || s == "codex") && !out.iter().any(|x| x == s) { + out.push(s.to_string()); + } + } + } + } + out +} + +/// Plan the safe subset of connections to repair on startup. Older releases could persist the +/// then-default `["claude"]` without the user ever connecting, so selection alone is insufficient: +/// a target's compatibility backup is the proof that CC Buddy previously took ownership of it. +pub(crate) fn startup_reconcile_targets(cfg: &Value) -> Vec { + connect_targets(cfg) + .into_iter() + .filter(|target| { + let backup_key = if target == "claude" { + "claudeBackup" + } else { + "codexBackup" + }; + cfg.get(backup_key).map(Value::is_object).unwrap_or(false) + }) + .collect() +} + +/// Repair only previously managed, still-selected targets. This intentionally has no disconnect +/// branch: startup must not restore/consume an unselected target's compatibility backup. Since each +/// connect call is gated on an existing object backup, it also cannot create a first-time backup. +pub(crate) fn reconcile_connections_on_startup(cfg: &Value) { + let selected = startup_reconcile_targets(cfg); + if selected.is_empty() { + return; + } + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(cfg); + if selected.iter().any(|target| target == "claude") { + claude::connect(port, &token); + } + if selected.iter().any(|target| target == "codex") { + codexconnect::connect(port, &token, &codex_model(cfg)); + } +} + +/// The legacy one-click Connect command still has a useful default even though startup does not: +/// when no target is selected, choose Claude and persist that now-explicit selection. +pub(crate) fn ensure_hero_connect_target(cfg: &mut Value) -> bool { + if connect_targets(cfg).is_empty() { + cfg["connectTargets"] = json!(["claude"]); + true + } else { + false + } +} + +/// The model written into Codex's config. `gpt-5.4` is a stable model identity understood by the +/// current CLI and enables its normal function/custom tool registry for custom providers. The +/// synthetic `gpt-5.6-sol-pro` identity previously used here selected code-mode metadata and made +/// Codex send an empty Responses `tools` array, so the gateway could never drive an agent turn. +pub(super) fn codex_model(_cfg: &Value) -> String { + "gpt-5.4".to_string() +} + +/// Make each CLI's config file match the selected `connectTargets`: write the selected ones to +/// point at the gateway, restore the rest. PURELY a config-file operation — the gateway service +/// itself is an independent switch (`gatewayEnabled`), never started or stopped from here. +fn apply_connections(cfg: &Value) { + let selected = connect_targets(cfg); + let claude_on = selected.iter().any(|t| t == "claude"); + let codex_on = selected.iter().any(|t| t == "codex"); + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let token = claude::current_token(cfg); + if claude_on { + claude::connect(port, &token); + } else { + claude::disconnect(); + } + if codex_on { + codexconnect::connect(port, &token, &codex_model(cfg)); + } else { + codexconnect::disconnect(); + } +} + +#[tauri::command] +pub(crate) async fn claude_connect( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + let mut cfg = store::read_config(); + let n = cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + if n == 0 { + return Ok(json!({ "ok": false, "reason": "noProvider" })); + } + // Hero "一键接入" with nothing selected connects Claude Code by default (and persists it, so the + // toggle reflects it). + if ensure_hero_connect_target(&mut cfg) { + cfg = store::write_config(cfg); + } + apply_connections(&cfg); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} +#[tauri::command] +pub(crate) async fn claude_disconnect( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + // Master off: restore BOTH CLIs' config files (idempotent). The gateway service keeps its own + // switch — removing the CLI wiring doesn't stop it. + claude::disconnect(); + codexconnect::disconnect(); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} + +/// Independent gateway-service switch: persist `gatewayEnabled` and start/stop the localhost +/// server. CLI config files are untouched — connect/disconnect is a separate, config-only action. +#[tauri::command] +pub(crate) async fn gateway_set_enabled( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + on: bool, +) -> Result { + let mut cfg = store::read_config(); + cfg["gatewayEnabled"] = json!(on); + let saved = store::write_config(cfg); + if on { + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + if let Err(e) = gw.start(port).await { + let msg = format!("port {} failed: {}", port, e); + *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); + gw.emit("gateway:status", full_status(&gw).await); + refresh_tray_menu(&app); + return Ok(json!({ "ok": false, "reason": "portFailed", "message": msg })); + } + *LAST_START_ERROR.lock().unwrap() = None; + } else { + gw.stop().await; + } + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} + +/// Live per-CLI switch: flip one target on/off, persist the selection, and immediately write or +/// restore that CLI's config file. Config-only — the gateway service has its own switch. +#[tauri::command] +pub(crate) async fn set_connect_target( + app: tauri::AppHandle, + gw: tauri::State<'_, std::sync::Arc>, + target: String, + on: bool, +) -> Result { + let mut cfg = store::read_config(); + if on && cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.is_empty()).unwrap_or(true) { + return Ok(json!({ "ok": false, "reason": "noProvider" })); + } + let mut targets = connect_targets(&cfg); + targets.retain(|t| t != &target); + if on && (target == "claude" || target == "codex") { + targets.push(target.clone()); + } + cfg["connectTargets"] = json!(targets); + let saved = store::write_config(cfg); + apply_connections(&saved); + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + refresh_tray_menu(&app); + Ok(json!({ "ok": true })) +} diff --git a/src-tauri/src/commands/fmt_tests.rs b/src-tauri/src/commands/fmt_tests.rs new file mode 100644 index 0000000..1ab23a5 --- /dev/null +++ b/src-tauri/src/commands/fmt_tests.rs @@ -0,0 +1,64 @@ +use super::{ensure_hero_connect_target, format_tokens, startup_reconcile_targets}; +use serde_json::json; + +#[test] +fn startup_reconciliation_requires_the_targets_own_backup() { + assert!(startup_reconcile_targets(&json!({})).is_empty()); + assert!(startup_reconcile_targets(&json!({ + "connectTargets": ["claude"], + "claudeBackup": null + })) + .is_empty()); + assert!(startup_reconcile_targets(&json!({ + "connectTargets": ["codex"], + "codexBackup": "not-a-backup" + })) + .is_empty()); + + assert_eq!( + startup_reconcile_targets(&json!({ + "connectTargets": ["claude", "codex"], + "claudeBackup": { "model": null, "env": {} }, + "codexBackup": null + })), + vec!["claude"] + ); + assert_eq!( + startup_reconcile_targets(&json!({ + "connectTargets": ["claude", "codex"], + "claudeBackup": null, + "codexBackup": { + "model": "gpt-5", + "model_provider": "openai", + "model_reasoning_effort": null + } + })), + vec!["codex"] + ); +} + +#[test] +fn hero_connect_defaults_to_claude_without_overriding_a_selection() { + let mut fresh = json!({ "connectTargets": [] }); + assert!(ensure_hero_connect_target(&mut fresh)); + assert_eq!(fresh["connectTargets"], json!(["claude"])); + + let mut selected = json!({ "connectTargets": ["codex"] }); + assert!(!ensure_hero_connect_target(&mut selected)); + assert_eq!(selected["connectTargets"], json!(["codex"])); +} + +#[test] +fn matches_js_format_tokens() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(1000), "1K"); + assert_eq!(format_tokens(1234), "1.2K"); + assert_eq!(format_tokens(9999), "10K"); + assert_eq!(format_tokens(12_345), "12K"); + assert_eq!(format_tokens(1_000_000), "1M"); + assert_eq!(format_tokens(4_900_000), "4.9M"); + assert_eq!(format_tokens(12_000_000), "12M"); + assert_eq!(format_tokens(1_000_000_000), "1B"); + assert_eq!(format_tokens(4_892_112_447), "4.9B"); +} diff --git a/src-tauri/src/commands/gateway.rs b/src-tauri/src/commands/gateway.rs new file mode 100644 index 0000000..50710a8 --- /dev/null +++ b/src-tauri/src/commands/gateway.rs @@ -0,0 +1,105 @@ +// Gateway status / usage / monitor / log commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::{claude, codexconnect, gateway, store, usage}; + +use super::config::LAST_START_ERROR; + +// ---- server / usage / monitor / logs ---- +pub(crate) async fn full_status(gw: &std::sync::Arc) -> Value { + let mut s = gw.status().await; + let port = gw + .current_port() + .await + .unwrap_or_else(|| store::read_config().get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16); + if let Some(o) = s.as_object_mut() { + let claude_on = claude::is_connected(port); + let codex_on = codexconnect::is_connected(port); + // `connected` = any CLI wired to the gateway (drives the tray "已接入" indicator). + o.insert("connected".into(), json!(claude_on || codex_on)); + o.insert("connectedClaude".into(), json!(claude_on)); + o.insert("connectedCodex".into(), json!(codex_on)); + o.insert("codexAvailable".into(), json!(codexconnect::is_available())); + o.insert( + "gatewayEnabled".into(), + json!(store::read_config().get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true)), + ); + o.insert( + "lastStartError".into(), + LAST_START_ERROR + .lock() + .ok() + .and_then(|g| g.clone()) + .map(Value::String) + .unwrap_or(Value::Null), + ); + o.insert("claudePath".into(), json!(claude::settings_path().to_string_lossy())); + } + s +} +#[tauri::command] +pub(crate) async fn server_status( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + Ok(full_status(&gw).await) +} +#[tauri::command] +pub(crate) fn usage_get(range: Option) -> Value { + let t = std::time::Instant::now(); + let cfg = store::read_config(); + // Usage surfaces (popover heatmap/stats, hero) always aggregate EVERY configured dir — the + // conversations-page directory switcher must not silently filter the calendar down to one CLI. + let r = usage::usage_get(&cfg, "all", range.as_deref().unwrap_or("7d")); + eprintln!( + "[TIMING] usage_get(range={}) {}ms", + range.as_deref().unwrap_or("7d"), + t.elapsed().as_millis() + ); + r +} + +/// Compact token count (mirror of usage.js `formatTokens`): 1234→"1.2K", 4.9e9→"4.9B". +pub(crate) fn format_tokens(n: i64) -> String { + let n = n.max(0); + if n < 1000 { + return n.to_string(); + } + let strip = |s: String| s.strip_suffix(".0").map(|p| p.to_string()).unwrap_or(s); + if n < 1_000_000 { + let v = n as f64 / 1e3; + let s = if n < 10_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; + return format!("{}K", strip(s)); + } + if n < 1_000_000_000 { + let v = n as f64 / 1e6; + let s = if n < 10_000_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; + return format!("{}M", strip(s)); + } + let v = n as f64 / 1e9; + format!("{}B", strip(format!("{:.1}", v))) +} +#[tauri::command] +pub(crate) async fn monitor_get( + gw: tauri::State<'_, std::sync::Arc>, + id: Value, +) -> Result { + let idn = id.as_i64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).unwrap_or(-1); + Ok(gw.monitor_get(idn).await) +} +#[tauri::command] +pub(crate) async fn monitor_clear( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + gw.monitor_clear().await; + Ok(json!(true)) +} +#[tauri::command] +pub(crate) fn logs_get(gw: tauri::State<'_, std::sync::Arc>) -> Value { + gw.logs_snapshot() +} +#[tauri::command] +pub(crate) fn logs_clear(gw: tauri::State<'_, std::sync::Arc>) -> Value { + gw.logs_clear(); + Value::Null +} diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..8bdf2b2 --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -0,0 +1,122 @@ +// Conversation-history commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::{history, store}; + +// ---- conversation history ---- +#[tauri::command] +pub(crate) fn history_projects() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!(history::list_projects(&cfg, &active)) +} +#[tauri::command] +pub(crate) fn history_list() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!(history::list_sessions(&cfg, &active, 400)) +} +#[tauri::command] +pub(crate) fn history_get(file: String) -> Value { + history::get_session(&file) +} +#[tauri::command] +pub(crate) async fn history_search(query: String) -> Result { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + // Content scan is read/parse heavy — keep it off the IPC thread so the UI stays responsive. + tauri::async_runtime::spawn_blocking(move || json!(history::search_sessions(&cfg, &active, &query, 120))) + .await + .map_err(|e| e.to_string()) +} +#[tauri::command] +pub(crate) fn history_dirs() -> Value { + let cfg = store::read_config(); + let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + json!({ "dirs": history::dir_stats(&cfg), "active": active }) +} +#[tauri::command] +pub(crate) async fn history_pick_dir() -> Result { + let folder = rfd::AsyncFileDialog::new().set_title("选择工作目录").pick_folder().await; + match folder { + // Return the picked path (home-collapsed to `~/…`) and let the renderer persist it + // via saveConfig, matching the renderer contract. + Some(f) => { + let mut picked = f.path().to_path_buf(); + // If the user drilled into a data subdir (projects/ = Claude, sessions/ = Codex), + // store its parent (the work dir) so both trees are probed correctly. + let name = picked.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()); + if matches!(name.as_deref(), Some("projects") | Some("sessions")) + && !picked.join(name.as_deref().unwrap()).is_dir() + { + if let Some(parent) = picked.parent() { + picked = parent.to_path_buf(); + } + } + let path = store::collapse_home(&picked.to_string_lossy()); + Ok(json!({ "ok": true, "path": path })) + } + None => Ok(json!({ "ok": false, "canceled": true })), + } +} +#[tauri::command] +pub(crate) fn history_set_active(app: tauri::AppHandle, id: String) -> Value { + let mut cfg = store::read_config(); + cfg["historyActive"] = json!(if id.is_empty() { "all".to_string() } else { id }); + let saved = store::write_config(cfg); + let _ = app.emit( + "history:changed", + json!({ "files": [], "active": saved.get("historyActive").cloned().unwrap_or(json!("all")) }), + ); + saved +} +#[tauri::command] +pub(crate) async fn history_import(app: tauri::AppHandle) -> Result { + match rfd::AsyncFileDialog::new().add_filter("对话记录 (.jsonl / .zip)", &["jsonl", "zip"]).set_title("导入对话记录").pick_files().await { + Some(files) => { + let paths: Vec = files.iter().map(|f| f.path().to_string_lossy().to_string()).collect(); + let r = history::import_paths(&paths); + let _ = app.emit("history:changed", json!({ "files": [] })); + Ok(r) + } + None => Ok(json!({ "canceled": true })), + } +} +#[tauri::command] +pub(crate) fn history_import_paths(app: tauri::AppHandle, paths: Value) -> Value { + let list: Vec = paths + .as_array() + .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + let r = history::import_paths(&list); + let _ = app.emit("history:changed", json!({ "files": [] })); + r +} +#[tauri::command] +pub(crate) fn history_remove_import(app: tauri::AppHandle, file: String) -> Value { + let r = history::remove_import(&file); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + r +} +#[tauri::command] +pub(crate) fn history_set_meta(app: tauri::AppHandle, file: String, patch: Value) -> Value { + let cfg = store::read_config(); + let r = history::set_ccbud(&file, &patch, &cfg); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [file] })); + } + r +} +#[tauri::command] +pub(crate) fn history_delete_forever(app: tauri::AppHandle, file: String) -> Value { + let cfg = store::read_config(); + let r = history::delete_session_file(&file, &cfg); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + r +} diff --git a/src-tauri/src/commands/history_export.rs b/src-tauri/src/commands/history_export.rs new file mode 100644 index 0000000..ea3876a --- /dev/null +++ b/src-tauri/src/commands/history_export.rs @@ -0,0 +1,101 @@ +// Conversation export commands (raw .jsonl/.db/.zip and the standalone HTML viewer), moved +// verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::{exporthtml, history}; + +use super::util::open_path_native; + +#[tauri::command] +pub(crate) async fn history_export_raw(file: String) -> Result { + let base = exporthtml::export_base_name(&file); + // Antigravity sessions are SQLite DBs (not text) — export the raw bytes as .db so the + // original conversation remains intact. Other foreign sources and Claude/Codex stay + // verbatim text (.jsonl); sessions with subagents keep the existing zip bundle. + let path = std::path::Path::new(&file); + if matches!( + history::foreign_kind(path), + Some(history::Foreign::Antigravity) + ) { + let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; + return match rfd::AsyncFileDialog::new() + .add_filter("SQLite", &["db"]) + .set_file_name(format!("{}.db", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) + } + None => Ok(json!({ "canceled": true })), + }; + } + // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); + // a plain session stays a verbatim .jsonl. import_paths accepts either. + if history::session_has_subagents(&file) { + let bytes = history::export_bundle(&file).map_err(|e| e.to_string())?; + match rfd::AsyncFileDialog::new() + .add_filter("ZIP", &["zip"]) + .set_file_name(format!("{}.zip", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": true })) + } + None => Ok(json!({ "canceled": true })), + } + } else { + let data = history::raw_session_bytes(&file).map_err(|e| e.to_string())?; + match rfd::AsyncFileDialog::new() + .add_filter("JSONL", &["jsonl"]) + .set_file_name(format!("{}.jsonl", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, data).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) + } + None => Ok(json!({ "canceled": true })), + } + } +} +#[tauri::command] +pub(crate) async fn history_export_html(payload: Value) -> Result { + let file = payload + .get("file") + .and_then(|v| v.as_str()) + .or_else(|| payload.as_str()) + .ok_or("no file")? + .to_string(); + // Build the export data once, then reuse it for both the HTML body and the filename. + let data = exporthtml::build_data(&file); + // An unreadable main transcript surfaces as a command error (renderer reports it) instead of + // silently saving an empty viewer page. + if let Some(error) = data.get("error") { + return Err(error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("session read failed") + .to_string()); + } + let html = exporthtml::html_from_data(&data); + let base = exporthtml::export_base_name_from_data(&data); + match rfd::AsyncFileDialog::new().set_file_name(format!("{}.html", base)).save_file().await { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, html).map_err(|e| e.to_string())?; + // Open the freshly-exported viewer in the user's default browser (issue #7). + open_path_native(&p); + Ok(json!({ "canceled": false, "path": p.to_string_lossy() })) + } + None => Ok(json!({ "canceled": true })), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..4af43da --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,65 @@ +// The Tauri IPC surface, split by topic. +// +// Every #[tauri::command] the renderer invokes lives in one of the modules below, moved verbatim +// out of lib.rs. Each command is `pub(crate)` and re-exported here so lib.rs can keep listing +// them unqualified in `tauri::generate_handler![…]` via `use commands::*;`. Shared statics +// (LAST_START_ERROR, UPDATE_*, AUTO_UPDATE_*) live with their topic module. + +mod autoupdate; +mod config; +mod connect; +mod gateway; +mod history; +mod history_export; +mod plugins; +mod provider_test; +mod replay; +mod selfcheck; +mod selfcheck_js; +mod update; +mod util; +mod window; +#[cfg(test)] +mod fmt_tests; + +pub(crate) use autoupdate::auto_update_on_visible; +pub(crate) use config::{ + config_get, config_save, provider_delete, provider_set_active, provider_upsert, +}; +pub(crate) use connect::{ + claude_connect, claude_disconnect, gateway_set_enabled, reconcile_connections_on_startup, + set_connect_target, +}; +// Exercised by the #[cfg(test)] module via these re-exports, so a non-test `cargo check` sees +// them as unused — allow that, don't drop the API. +#[allow(unused_imports)] +pub(crate) use connect::{ensure_hero_connect_target, startup_reconcile_targets}; +pub(crate) use gateway::{ + format_tokens, full_status, logs_clear, logs_get, monitor_clear, monitor_get, server_status, + usage_get, +}; +pub(crate) use history::{ + history_delete_forever, history_dirs, history_get, history_import, history_import_paths, + history_list, history_pick_dir, history_projects, history_remove_import, history_search, + history_set_active, history_set_meta, +}; +pub(crate) use history_export::{history_export_html, history_export_raw}; +pub(crate) use plugins::{ + plugin_action, plugin_action_load, plugin_check_update, plugin_install, plugin_install_git, + plugin_list, plugin_open_dir, plugin_set_enabled, plugin_status, plugin_uninstall, + plugin_update, +}; +pub(crate) use provider_test::provider_test; +pub(crate) use replay::{chatgpt_replay, desktop_replay}; +pub(crate) use selfcheck::{ + selfcheck_export, selfcheck_gateway, selfcheck_history, selfcheck_import, selfcheck_popover, + selfcheck_report, selfcheck_routing, +}; +pub(crate) use selfcheck_js::{POPOVER_SELFCHECK_JS, SELFCHECK_JS}; +pub(crate) use update::{ + update_apply, update_check, update_download, update_set_auto, update_state, +}; +pub(crate) use util::{util_copy, util_open_external}; +pub(crate) use window::{ + app_open_main, app_quit, set_dock_visible, window_settings_mode, window_view_min_width, +}; diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000..e7d5380 --- /dev/null +++ b/src-tauri/src/commands/plugins.rs @@ -0,0 +1,105 @@ +// Plugin (sidecar coding-agent backend) commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::plugin; + +// ---- plugins (sidecar coding-agent backends, see plugin.rs) ---- +pub(super) type PluginState<'a> = tauri::State<'a, std::sync::Arc>; + +/// List discovered plugins with running + auth status. +#[tauri::command] +pub(crate) async fn plugin_list(pm: PluginState<'_>) -> Result { + Ok(pm.list().await) +} +/// Single plugin status snapshot. +#[tauri::command] +pub(crate) async fn plugin_status(pm: PluginState<'_>, id: String) -> Result { + Ok(pm.status(&id).await) +} +/// Enable (spawn + health-gate + register provider) or disable (stop the process; the service stays until uninstalled) a plugin. +#[tauri::command] +pub(crate) async fn plugin_set_enabled(pm: PluginState<'_>, id: String, enabled: bool) -> Result { + if enabled { + pm.start(&id).await?; + } else { + pm.stop(&id)?; + } + Ok(pm.status(&id).await) +} +/// Run a plugin-declared UI action: forward form `values` to its control plane. +#[tauri::command] +pub(crate) async fn plugin_action(pm: PluginState<'_>, id: String, action: String, values: Value) -> Result { + pm.action(&id, &action, values).await +} +/// Prefill a plugin action form with the plugin's current values. +#[tauri::command] +pub(crate) async fn plugin_action_load(pm: PluginState<'_>, id: String, action: String) -> Result { + pm.action_load(&id, &action).await +} +/// Add a plugin: pick a local folder containing plugin.json and install it. +/// `title` is the localized folder-picker title (supplied by the renderer). +#[tauri::command] +pub(crate) async fn plugin_install(pm: PluginState<'_>, title: Option) -> Result { + let title = title.filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "Select the plugin folder".into()); + let picked = rfd::AsyncFileDialog::new() + .set_title(&title) + .pick_folder() + .await; + let dir = match picked { + Some(f) => f.path().to_path_buf(), + None => return Ok(json!({ "canceled": true })), + }; + let id = pm.install(&dir)?; + Ok(json!({ "ok": true, "id": id })) +} +/// Remove a plugin (the renderer confirms first): stop it, drop its service, delete its files. +#[tauri::command] +pub(crate) async fn plugin_uninstall(pm: PluginState<'_>, id: String) -> Result { + // The confirmation is shown by the renderer (localized confirmDialog) before + // this is called, so we just do the work here. + pm.uninstall(&id)?; + Ok(json!({ "ok": true })) +} +/// Open the plugins folder in the OS file browser. +#[tauri::command] +pub(crate) fn plugin_open_dir() -> bool { + let dir = plugin::plugins_root(); + let _ = std::fs::create_dir_all(&dir); + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&dir).spawn().is_ok() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer").arg(&dir).spawn().is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::process::Command::new("xdg-open").arg(&dir).spawn().is_ok() + } +} +/// Install a plugin from a git repository (clone + build + install). Runs the +/// blocking git/build work off the async runtime. +#[tauri::command] +pub(crate) async fn plugin_install_git(pm: PluginState<'_>, url: String) -> Result { + let mgr = pm.inner().clone(); + let id = tokio::task::spawn_blocking(move || mgr.install_from_git(&url)) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "ok": true, "id": id })) +} +/// Check whether a plugin's git source has a newer version. +#[tauri::command] +pub(crate) async fn plugin_check_update(pm: PluginState<'_>, id: String) -> Result { + Ok(pm.check_update(&id).await) +} +/// Update a plugin from its recorded git source (re-clone + build + replace). +#[tauri::command] +pub(crate) async fn plugin_update(pm: PluginState<'_>, id: String) -> Result { + let mgr = pm.inner().clone(); + let id = tokio::task::spawn_blocking(move || mgr.update(&id)) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "ok": true, "id": id })) +} diff --git a/src-tauri/src/commands/provider_test.rs b/src-tauri/src/commands/provider_test.rs new file mode 100644 index 0000000..7683380 --- /dev/null +++ b/src-tauri/src/commands/provider_test.rs @@ -0,0 +1,142 @@ +// Live provider connection test, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::store; + +async fn send_provider_probe( + client: &reqwest::Client, + url: &str, + wire: crate::protocol::Wire, + token: &str, + body: &Value, +) -> Result { + let mut request = client + .post(url) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", token)); + if wire == crate::protocol::Wire::Anthropic { + request = request.header("anthropic-version", "2023-06-01"); + } + request.json(body).send().await +} + +/// Live connection test: POST a tiny ping to the provider, shaped for its declared wire protocol +/// (Anthropic /messages, OpenAI /chat/completions, or /responses), and report ok/error/timeout. +/// The renderer localizes the result message. +#[tauri::command] +pub(crate) async fn provider_test(app: tauri::AppHandle, p: Value) -> Value { + let base = p.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim(); + if base.is_empty() { + return json!({ "ok": false, "reason": "baseUrlEmpty" }); + } + if !(base.starts_with("http://") || base.starts_with("https://")) { + return json!({ "ok": false, "reason": "baseUrlInvalid" }); + } + // Test against the provider's DECLARED protocol endpoint — an openai-chat provider must be + // pinged at /chat/completions with a Chat body, not the Anthropic /v1/messages default. + let wire = crate::protocol::Wire::from_provider(p.get("protocol").and_then(|v| v.as_str())); + let url = wire.upstream_url(base); + let model = p + .get("defaultModel") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| { + p.get("models") + .and_then(|m| m.as_array()) + .and_then(|a| a.first()) + .and_then(|m| m.get("upstream")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or("claude-3-5-haiku-20241022") + .to_string(); + let token = p.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let insecure = store::read_config() + .get("insecureSkipVerify") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // Protocol-shaped ping body. + let body = match wire { + crate::protocol::Wire::OpenAiResponses => json!({ "model": model, "max_output_tokens": 16, "input": "ping" }), + crate::protocol::Wire::OpenAiChat => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), + crate::protocol::Wire::Anthropic => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), + }; + let client = match reqwest::Client::builder() + .danger_accept_invalid_certs(insecure) + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => return json!({ "ok": false, "message": e.to_string() }), + }; + // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips + // providers that reject having the two auth headers present at once. + let first = send_provider_probe(&client, &url, wire, &token, &body).await; + match first { + Ok(mut r) => { + let mut migrated_base_url: Option = None; + if crate::protocol::should_try_v1_fallback(r.status().as_u16()) { + if let Some(fallback_url) = wire.v1_fallback_url(base) { + if let Ok(candidate) = send_provider_probe(&client, &fallback_url, wire, &token, &body).await { + if candidate.status().is_success() { + r = candidate; + migrated_base_url = Some(format!("{}/v1", base.trim_end_matches('/'))); + } + } + } + } + let status = r.status().as_u16(); + let text = r.text().await.unwrap_or_default(); + let parsed: Option = serde_json::from_str(&text).ok(); + let http_ok = (200..300).contains(&status); + // A well-shaped reply for the tested protocol: Anthropic `type:message`, Chat `choices`, + // Responses `output`/`id`. + let shape_ok = parsed.as_ref().map(|j| match wire { + crate::protocol::Wire::Anthropic => j.get("type").and_then(|v| v.as_str()) == Some("message"), + crate::protocol::Wire::OpenAiChat => j.get("choices").map(|c| c.is_array()).unwrap_or(false), + crate::protocol::Wire::OpenAiResponses => j.get("output").is_some() || j.get("id").is_some(), + }).unwrap_or(false); + if http_ok && shape_ok { + let m = parsed + .as_ref() + .and_then(|j| j.get("model")) + .and_then(|v| v.as_str()) + .unwrap_or(&model); + if let Some(next_base) = migrated_base_url.as_deref() { + if let Some(id) = p.get("id").and_then(Value::as_str) { + if let Some(saved) = store::migrate_provider_base_url_to_v1(id, base) { + let _ = app.emit("config:changed", saved); + return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); + } + } else { + return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); + } + } + return json!({ "ok": true, "status": status, "model": m }); + } + let msg = parsed + .as_ref() + .and_then(|j| j.get("error")) + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + if !text.is_empty() { + text.chars().take(200).collect() + } else { + format!("HTTP {}", status) + } + }); + json!({ "ok": false, "status": status, "message": msg }) + } + Err(e) => { + if e.is_timeout() { + json!({ "ok": false, "reason": "timeout" }) + } else { + json!({ "ok": false, "message": e.to_string() }) + } + } + } +} diff --git a/src-tauri/src/commands/replay.rs b/src-tauri/src/commands/replay.rs new file mode 100644 index 0000000..4afd917 --- /dev/null +++ b/src-tauri/src/commands/replay.rs @@ -0,0 +1,89 @@ +// Desktop / ChatGPT deep-link replay commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; + +use crate::history; + +fn pct(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), + _ => format!("%{:02X}", b), + }) + .collect() +} +#[tauri::command] +pub(crate) fn desktop_replay(file: String, prompt: Option) -> Value { + if file.is_empty() { + return json!({ "ok": false, "reason": "noFile" }); + } + if !cfg!(target_os = "macos") { + return json!({ "ok": false, "reason": "unsupported" }); + } + // The full review prompt comes from the renderer's i18n (desktop.replayPrompt) so it stays + // localized; fall back to a minimal default only if the renderer didn't supply one. + let prompt = prompt + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "请基于这些对话记录在 Claude 桌面版里继续。".to_string()); + // Attach the main session AND every subagent transcript (they live in a separate subagents/ dir), + // each as its own `file=` — the Cowork deep link honors repeated `file=` — so the analysis covers + // subagent runs, not just the main thread. + let mut url = format!("claude://cowork/new?q={}&file={}", pct(&prompt), pct(&file)); + for sub in history::subagent_transcript_paths(&file) { + url.push_str("&file="); + url.push_str(&pct(&sub)); + } + #[cfg(target_os = "macos")] + { + let ok = std::process::Command::new("/usr/bin/open").arg(&url).spawn().is_ok(); + json!({ "ok": ok }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = url; + json!({ "ok": false, "reason": "unsupported" }) + } +} +#[tauri::command] +pub(crate) fn chatgpt_replay(file: String, prompt: Option) -> Value { + if file.is_empty() { + return json!({ "ok": false, "reason": "noFile" }); + } + if !cfg!(target_os = "macos") { + return json!({ "ok": false, "reason": "unsupported" }); + } + // The ChatGPT desktop app (Codex era) keeps the codex:// scheme: codex://new takes + // `prompt` (initial composer text) and `path` (workspace dir). It has no file-attach + // param, so the workspace is pointed at the transcripts' directory and the prompt + // lists the absolute JSONL paths — main session plus every subagent (they live under + // `//subagents/`, inside the same workspace) — for the task to read. + let prompt = prompt + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "请读取下列 Coding CLI 会话的 JSONL 记录并帮我复盘。".to_string()); + let mut text = prompt; + text.push_str("\n\nTranscripts:\n"); + text.push_str(&file); + for sub in history::subagent_transcript_paths(&file) { + text.push('\n'); + text.push_str(&sub); + } + let mut url = format!("codex://new?prompt={}", pct(&text)); + if let Some(dir) = std::path::Path::new(&file).parent() { + url.push_str("&path="); + url.push_str(&pct(&dir.to_string_lossy())); + } + #[cfg(target_os = "macos")] + { + // `open` exits non-zero when nothing handles the scheme → app not installed. + match std::process::Command::new("/usr/bin/open").arg(&url).status() { + Ok(s) if s.success() => json!({ "ok": true }), + Ok(_) => json!({ "ok": false, "reason": "notInstalled" }), + Err(_) => json!({ "ok": false, "reason": "failed" }), + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = url; + json!({ "ok": false, "reason": "unsupported" }) + } +} diff --git a/src-tauri/src/commands/selfcheck.rs b/src-tauri/src/commands/selfcheck.rs new file mode 100644 index 0000000..39d1ef4 --- /dev/null +++ b/src-tauri/src/commands/selfcheck.rs @@ -0,0 +1,138 @@ +// Debug self-check commands, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Manager; + +use crate::{exporthtml, gateway, history, store}; + +// ---- debug self-check (gated by CCBUD_SELFCHECK env; injected via on_page_load) ---- +#[tauri::command] +pub(crate) fn selfcheck_report(report: Value) { + let line = serde_json::to_string(&report).unwrap_or_default(); + eprintln!("[SELFCHECK] {}", line); + // Also append to a file when CCBUD_SELFCHECK_OUT is set — a GUI-session run + // (open .app via launchd) has no terminal-attached stderr to read. + if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(f, "{}", line); + } + } +} +#[tauri::command] +pub(crate) fn selfcheck_routing() -> Value { + gateway::routing_selftest() +} +#[tauri::command] +pub(crate) fn selfcheck_history() -> Value { + history::history_selftest(&store::ccbud_home()) +} +#[tauri::command] +pub(crate) fn selfcheck_import() -> Value { + history::import_selftest(&store::ccbud_home()) +} +#[tauri::command] +pub(crate) fn selfcheck_export() -> Value { + let base = store::ccbud_home(); + let _ = history::history_selftest(&base); + let file = base.join("test-claude").join("projects").join("-test-cwd").join("sess1.jsonl"); + let html = exporthtml::build_export_html(&file.to_string_lossy()); + json!({ + "len": html.len(), + "hasConv": html.contains("__CONV__"), + "hasContent": html.contains("hello world from selfcheck"), + "hasSkin": html.contains(""), + "embedded": html.len() > 180000, + "validHtml": html.starts_with(""), + }) +} +#[tauri::command] +pub(crate) fn selfcheck_popover(app: tauri::AppHandle) -> Value { + let pop = match app.get_webview_window("popover") { + Some(p) => p, + None => return json!({ "err": "no popover window" }), + }; + let mon = match pop.current_monitor() { + Ok(Some(m)) => m, + _ => return json!({ "err": "no monitor" }), + }; + let scale = mon.scale_factor(); + let pw = (424.0 * scale) as i32; + let sx = mon.position().x; + let sy = mon.position().y; + let sw = mon.size().width as i32; + let sh = mon.size().height as i32; + // Simulate a tray icon at the top-right of the menu bar, run the same placement + // math as the real tray click, then read back where the window actually lands. + let tray_cx = sx + sw - (12.0 * scale) as i32; + let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); + let y = sy + (26.0 * scale) as i32; + // macOS window ops must run on the main thread — the real tray callback already + // does; here we hop onto it explicitly and read back inside the same closure so + // the probe sees the post-move geometry without a cross-thread timing race. + let (tx, rx) = std::sync::mpsc::channel(); + let pop2 = pop.clone(); + let _ = app.run_on_main_thread(move || { + let _ = pop2.show(); + let _ = pop2.set_position(tauri::PhysicalPosition::new(x, y)); + let pos = pop2.outer_position().ok().map(|p| (p.x, p.y)); + let size = pop2.outer_size().ok().map(|s| (s.width as i32, s.height as i32)); + let _ = pop2.hide(); + let _ = tx.send((pos, size)); + }); + let (pos, size) = rx + .recv_timeout(std::time::Duration::from_millis(1500)) + .unwrap_or((None, None)); + let in_screen = match (pos, size) { + (Some((px, py)), Some((sw2, sh2))) => { + px >= sx && py >= sy && (px + sw2) <= (sx + sw + 2) && (py + sh2) <= (sy + sh + 2) + } + _ => false, + }; + json!({ + "scale": scale, + "monitor": [sx, sy, sw, sh], + "computed": [x, y], + "popPos": pos.map(|(a, b)| json!([a, b])), + "popSize": size.map(|(a, b)| json!([a, b])), + "inScreen": in_screen, + }) +} +#[tauri::command] +pub(crate) async fn selfcheck_gateway( + gw: tauri::State<'_, std::sync::Arc>, +) -> Result { + // Mutates config (writes a mock provider) — only ever allowed in a throwaway self-check run. + if std::env::var("CCBUD_SELFCHECK").is_err() { + return Err("selfcheck disabled".into()); + } + let port = gw.current_port().await.unwrap_or(0); + let mut r = gateway::gateway_selftest(port).await; + let sse_ex = gw.monitor_recent().await; // last recorded by gateway_selftest = the SSE exchange + // Exercise HEAD / (mock 404 → gateway fallback 200 → recorded) to verify monitor detail + ms. + let head_status = reqwest::Client::new() + .head(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .map(|x| x.status().as_u16()) + .unwrap_or(0); + tokio::time::sleep(std::time::Duration::from_millis(60)).await; + let head_ex = gw.monitor_recent().await; // now the HEAD exchange + if let Some(o) = r.as_object_mut() { + let req_ok = sse_ex.get("reqBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); + let res_ok = sse_ex.get("resBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); + let redacted = sse_ex.get("reqHeaders").map(|h| h.to_string().contains("已隐藏")).unwrap_or(false); + o.insert("monitorReqBody".into(), json!(req_ok)); + o.insert("monitorResBody".into(), json!(res_ok)); + o.insert("monitorRedacted".into(), json!(redacted)); + o.insert("recordHasMs".into(), json!(sse_ex.get("ms").map(|v| v.is_number()).unwrap_or(false))); + o.insert("headStatus".into(), json!(head_status)); + o.insert( + "headMonitored".into(), + json!(head_ex.get("method").and_then(|m| m.as_str()) == Some("HEAD") + && head_ex.get("reqHeaders").map(|h| h.is_object()).unwrap_or(false) + && head_ex.get("ms").map(|v| v.is_number()).unwrap_or(false)), + ); + } + Ok(r) +} diff --git a/src-tauri/src/commands/selfcheck_js.rs b/src-tauri/src/commands/selfcheck_js.rs new file mode 100644 index 0000000..37b042a --- /dev/null +++ b/src-tauri/src/commands/selfcheck_js.rs @@ -0,0 +1,77 @@ +// The self-check probes injected into the main and popover webviews by run()'s on_page_load +// hook (gated by CCBUD_SELFCHECK). String literals only, moved verbatim from lib.rs. + +pub(crate) const SELFCHECK_JS: &str = r#" +(function(){ + if (window.__ccbud_sc) return; window.__ccbud_sc = 1; + window.__ccbud_errors = []; + window.addEventListener('error', function(e){ try{window.__ccbud_errors.push(String((e&&e.message)||(e&&e.error)||e));}catch(_){} }, true); + window.addEventListener('unhandledrejection', function(e){ try{window.__ccbud_errors.push('promise:'+String((e.reason&&e.reason.message)||e.reason));}catch(_){} }); + function rep(o){ try{ window.__TAURI__.core.invoke('selfcheck_report',{report:o}); }catch(_){} } + setTimeout(async function(){ + var o={}; + try{ + o.hasCcbud=!!window.ccbud; + o.hasTauri=!!(window.__TAURI__&&window.__TAURI__.core); + o.bodyLen=(document.body&&document.body.innerHTML.length)||0; + o.navItems=document.querySelectorAll('.nav-item,[data-view],[data-nav]').length; + o.colorMix=!!(window.CSS&&CSS.supports&&CSS.supports('color','color-mix(in srgb,red,blue)')); + o.highlight=!!(window.CSS&&CSS.highlights); + // store round-trip — self-check runs point CCBUD_HOME at a throwaway dir + try{ + var before=await window.ccbud.getConfig(); + o.provBefore=((before&&before.providers)||[]).length; + var saved=await window.ccbud.upsertProvider({name:'SelfTest',baseUrl:'https://x.test',authToken:'tok',defaultModel:'m1',smallFastModel:'m1',extra:'shouldDrop'}); + o.provAfter=((saved&&saved.providers)||[]).length; + o.savedName=saved&&saved.providers&&saved.providers[0]&&saved.providers[0].name; + o.savedHasId=!!(saved&&saved.providers&&saved.providers[0]&&saved.providers[0].id); + o.savedActiveMatches=!!(saved&&saved.activeProviderId&&saved.providers[0]&&saved.activeProviderId===saved.providers[0].id); + o.droppedExtra=!(saved&&saved.providers&&saved.providers[0]&&('extra' in saved.providers[0])); + var reread=await window.ccbud.getConfig(); + o.rereadProv=((reread&&reread.providers)||[]).length; + }catch(e){ o.storeErr=String(e); } + try{ o.routing=await window.__TAURI__.core.invoke('selfcheck_routing'); }catch(e){ o.routingErr=String(e); } + try{ o.server=await window.ccbud.serverStatus(); }catch(e){ o.serverErr=String(e); } + try{ o.gateway=await window.__TAURI__.core.invoke('selfcheck_gateway'); }catch(e){ o.gatewayErr=String(e); } + try{ + o.histDirs=(await window.ccbud.historyDirs()).dirs.length; + var hl=await window.ccbud.historyList(); + o.histCount=(hl||[]).length; + o.histSample=hl&&hl[0]?{title:String(hl[0].title||'').slice(0,40),project:hl[0].project,hasCwd:!!hl[0].cwd,hasFile:!!hl[0].file}:null; + if(hl&&hl[0]){ var ss=await window.ccbud.historyGet(hl[0].file); o.histMsgs=ss&&ss.messages?ss.messages.length:-1; o.histTotals=ss&&ss.meta?ss.meta.totals:null; } + }catch(e){ o.histErr=String(e); } + try{ var ug=await window.ccbud.usageGet('all'); o.usage={tokens:ug.tokens,requests:ug.requests,fav:ug.favoriteModel,heatmap:(ug.heatmap||[]).length,byModel:(ug.byModel||[]).length,activeDays:ug.activeDays}; }catch(e){ o.usageErr=String(e); } + try{ var cc=await window.ccbud.connect(); var s1=await window.ccbud.serverStatus(); var dd=await window.ccbud.disconnect(); var s2=await window.ccbud.serverStatus(); o.claude={connOk:cc&&cc.ok,connected:s1.connected,discOk:dd&&dd.ok,afterDisc:s2.connected}; }catch(e){ o.claudeErr=String(e); } + try{ o.copyOk=await window.ccbud.copy('selfcheck-clip'); }catch(e){ o.copyErr=String(e); } + try{ o.histMeta=await window.__TAURI__.core.invoke('selfcheck_history'); }catch(e){ o.histMetaErr=String(e); } + try{ o.export=await window.__TAURI__.core.invoke('selfcheck_export'); }catch(e){ o.exportErr=String(e); } + try{ o.import=await window.__TAURI__.core.invoke('selfcheck_import'); }catch(e){ o.importErr=String(e); } + try{ var us=await window.ccbud.updateState(); var sa=await window.ccbud.updateSetAuto({check:false}); o.update={current:us.current,status:us.status,setAutoCheck:sa.check}; }catch(e){ o.updateErr=String(e); } + try{ o.drag={regions:document.querySelectorAll('.drag-region').length,wired:document.querySelectorAll('[data-tauri-drag-region]').length}; }catch(e){ o.dragErr=String(e); } + try{ var cs=getComputedStyle(document.body); o.userSelect=cs.webkitUserSelect||cs.userSelect; }catch(e){} + try{ var ep=document.getElementById('endpoint'); var eb=document.getElementById('exportBlock'); o.epSel=ep?getComputedStyle(ep).webkitUserSelect:'-'; o.ebSel=eb?getComputedStyle(eb).webkitUserSelect:'-'; }catch(e){} + try{ o.popoverPos=await window.__TAURI__.core.invoke('selfcheck_popover'); }catch(e){ o.popoverPosErr=String(e); } + o.errors=window.__ccbud_errors.slice(0,20); + }catch(e){o.fatal=String((e&&e.stack)||e);} + rep(o); + },2200); +})(); +"#; + +pub(crate) const POPOVER_SELFCHECK_JS: &str = r#" +(function(){ + setTimeout(async function(){ + var o={win:"popover"}; + try{ o.hasCcbud=!!window.ccbud; var u=await window.ccbud.usageGet("all"); o.usageTokens=u?u.tokens:"null"; o.heatmapLen=u&&u.heatmap?u.heatmap.length:-1; o.heatmapFilled=u&&u.heatmap?u.heatmap.filter(function(c){return c.level>0;}).length:-1; }catch(e){ o.usageErr=String(e); } + try{ var st=document.getElementById("sTokens"); o.sTokensText=st?st.textContent:"noel"; var hm=document.getElementById("heatmap"); o.heatCells=hm?hm.children.length:-1; }catch(e){} + try{ + o.innerW=window.innerWidth; o.innerH=window.innerHeight; o.scrollH=document.body.scrollHeight; + var st2=document.getElementById("sTokens"); if(st2){var r=st2.getBoundingClientRect(); o.sTokTop=Math.round(r.top); o.sTokVisible=(r.top>=0&&r.bottom<=window.innerHeight);} + var hm2=document.getElementById("heatmap"); if(hm2){var hr=hm2.getBoundingClientRect(); o.hmTop=Math.round(hr.top); o.hmBottom=Math.round(hr.bottom);} + o.bodyBg=getComputedStyle(document.body).backgroundColor; + var root=document.querySelector(".pop-body-root"); o.rootBg=root?getComputedStyle(root).backgroundColor:"noel"; + }catch(e){ o.visErr=String(e); } + try{ window.__TAURI__.core.invoke("selfcheck_report",{report:o}); }catch(_){} + }, 1500); +})(); +"#; diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..67696e0 --- /dev/null +++ b/src-tauri/src/commands/update.rs @@ -0,0 +1,132 @@ +// In-app update commands and their shared state, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Emitter; + +use crate::store; + +// ---- in-app updates ---- +// In-app update state, mapped to the shape the renderer's about/update pane expects +// (runningVersion / latestVersion / mode / pending). Tauri's updater is in-app full → mode "hot". +pub(super) static UPDATE_LATEST: std::sync::Mutex)>> = + std::sync::Mutex::new(None); +pub(super) static UPDATE_CHECKED: std::sync::Mutex = std::sync::Mutex::new(false); +pub(super) static UPDATE_STAGED: std::sync::Mutex = std::sync::Mutex::new(false); +// A download is in flight (manual or auto) — second caller gets "busy" instead of a duplicate. +pub(super) static UPDATE_DOWNLOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Clears an in-flight flag on drop, so a panic/unwind or an early return can never leave +/// UPDATE_DOWNLOADING / AUTO_UPDATE_RUNNING stuck true for the rest of the process. +pub(super) struct FlagGuard(pub(super) &'static std::sync::atomic::AtomicBool); +impl Drop for FlagGuard { + fn drop(&mut self) { + self.0.store(false, std::sync::atomic::Ordering::SeqCst); + } +} + +pub(super) fn build_update_state(app: &tauri::AppHandle) -> Value { + let cfg = store::read_config(); + let current = app.package_info().version.to_string(); + let latest = UPDATE_LATEST.lock().ok().and_then(|g| g.clone()); + let checked = UPDATE_CHECKED.lock().map(|g| *g).unwrap_or(false); + let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); + let (latest_v, notes, mode) = match (&latest, checked) { + (Some((v, n)), _) => (json!(v), n.clone().map(Value::String).unwrap_or(Value::Null), "hot"), + (None, true) => (Value::Null, Value::Null, "none"), + (None, false) => (Value::Null, Value::Null, "unknown"), + }; + json!({ + "ok": true, + "runningVersion": current, + "shellVersion": current, + "latestVersion": latest_v, + "mode": mode, + "notes": notes, + "pending": if staged { + json!({ "staged": true, "version": latest.as_ref().map(|(v, _)| v.clone()) }) + } else { + Value::Null + }, + "installMethod": "tauri", + "autoUpdate": cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })), + }) +} +#[tauri::command] +pub(crate) fn update_state(app: tauri::AppHandle) -> Value { + build_update_state(&app) +} +/// Hit the updater endpoint and sync UPDATE_CHECKED/UPDATE_LATEST + the renderer's +/// update:state. Shared by the manual update_check command and the daily auto check. +pub(super) async fn run_update_check(app: &tauri::AppHandle) -> Result, String> { + use tauri_plugin_updater::UpdaterExt; + *UPDATE_CHECKED.lock().unwrap() = true; + let result = match app.updater() { + Ok(updater) => updater.check().await, + Err(e) => Err(e), + }; + match result { + Ok(found) => { + *UPDATE_LATEST.lock().unwrap() = + found.as_ref().map(|u| (u.version.clone(), u.body.clone())); + let _ = app.emit("update:state", build_update_state(app)); + Ok(found) + } + Err(e) => Err(e.to_string()), + } +} +#[tauri::command] +pub(crate) async fn update_check(app: tauri::AppHandle) -> Result { + match run_update_check(&app).await { + Ok(_) => Ok(build_update_state(&app)), + Err(e) => Ok(json!({ + "ok": false, + "error": e, + "runningVersion": app.package_info().version.to_string(), + })), + } +} +/// Download + stage the available update (restart applies it). Shared by the manual +/// update_download command and the daily auto flow; UPDATE_DOWNLOADING dedupes the two. +pub(super) async fn run_update_download(app: &tauri::AppHandle) -> Result { + use tauri_plugin_updater::UpdaterExt; + if UPDATE_DOWNLOADING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return Err("busy".to_string()); + } + let _busy = FlagGuard(&UPDATE_DOWNLOADING); + let updater = app.updater().map_err(|e| e.to_string())?; + match updater.check().await.map_err(|e| e.to_string())? { + Some(u) => { + u.download_and_install(|_chunk, _total| {}, || {}).await.map_err(|e| e.to_string())?; + *UPDATE_STAGED.lock().unwrap() = true; + let st = build_update_state(app); + let _ = app.emit("update:staged", st.clone()); + let _ = app.emit("update:state", st.clone()); + Ok(st) + } + None => Ok(json!({ "ok": true, "mode": "none" })), + } +} +#[tauri::command] +pub(crate) async fn update_download(app: tauri::AppHandle) -> Result { + run_update_download(&app).await +} +#[tauri::command] +pub(crate) fn update_apply(app: tauri::AppHandle) -> Value { + app.restart(); +} +#[tauri::command] +pub(crate) fn update_set_auto(patch: Value) -> Value { + let mut cfg = store::read_config(); + let mut au = cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })); + if let Some(o) = au.as_object_mut() { + if let Some(c) = patch.get("check") { + o.insert("check".into(), c.clone()); + } + if let Some(d) = patch.get("autoDownload") { + o.insert("autoDownload".into(), d.clone()); + } + } + cfg["autoUpdate"] = au.clone(); + store::write_config(cfg); + au +} diff --git a/src-tauri/src/commands/util.rs b/src-tauri/src/commands/util.rs new file mode 100644 index 0000000..7222e07 --- /dev/null +++ b/src-tauri/src/commands/util.rs @@ -0,0 +1,41 @@ +// ---- utilities ---- +#[tauri::command] +pub(crate) fn util_copy(text: String) -> bool { + match arboard::Clipboard::new() { + Ok(mut cb) => cb.set_text(text).is_ok(), + Err(_) => false, + } +} +#[tauri::command] +pub(crate) fn util_open_external(url: String) -> bool { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return false; + } + let spawned = { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&url).spawn() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd").args(["/C", "start", "", &url]).spawn() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open").arg(&url).spawn() + } + }; + spawned.is_ok() +} + +// Open a local file with the OS default handler. Used to pop the freshly-exported HTML viewer in +// the user's browser so they don't have to hunt for it in the filesystem. Best-effort: a spawn +// failure must not fail the export. +pub(super) fn open_path_native(path: &std::path::Path) { + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(path).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("cmd").args(["/C", "start", ""]).arg(path).spawn(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); +} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs new file mode 100644 index 0000000..e6a622c --- /dev/null +++ b/src-tauri/src/commands/window.rs @@ -0,0 +1,47 @@ +// Window / app lifecycle commands, moved verbatim from lib.rs. + +use serde_json::Value; +use tauri::Manager; + +// ---- window / app lifecycle ---- +/// macOS Dock icon follows the main window: Regular (Dock shown) while a window is open, +/// Accessory (menu-bar only) when it's closed. The popover floats over fullscreen apps via its +/// NSPanel regardless of this policy, so showing the Dock icon with the main window is safe. +pub(crate) fn set_dock_visible(app: &tauri::AppHandle, visible: bool) { + #[cfg(target_os = "macos")] + { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + let _ = app2.set_activation_policy(policy); + }); + } +} +#[tauri::command] +pub(crate) fn app_open_main(app: tauri::AppHandle) -> Value { + if let Some(win) = app.get_webview_window("main") { + set_dock_visible(&app, true); + let _ = win.show(); + let _ = win.unminimize(); + let _ = win.set_focus(); + } + Value::Null +} +#[tauri::command] +pub(crate) fn app_quit(app: tauri::AppHandle) -> Value { + app.exit(0); + Value::Null +} +#[tauri::command] pub(crate) fn window_settings_mode(on: bool) -> Value { Value::Null } +#[tauri::command] +pub(crate) fn window_view_min_width(app: tauri::AppHandle, w: i64) -> Value { + if let Some(win) = app.get_webview_window("main") { + let min_w = std::cmp::max(600, if w > 0 { w } else { 900 }) as f64; + let _ = win.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new(min_w, 600.0)))); + } + Value::Null +} diff --git a/src-tauri/src/copilot.rs b/src-tauri/src/copilot.rs deleted file mode 100644 index e45151e..0000000 --- a/src-tauri/src/copilot.rs +++ /dev/null @@ -1,437 +0,0 @@ -// GitHub Copilot CLI session support — reads Copilot's on-disk session event logs and -// normalizes them into the SAME session/message shape the renderer consumes (history::Norm). -// -// Two layouts under `~/.copilot/session-state/`: -// new (≥1.0): /events.jsonl + sibling workspace.yaml (id/cwd/name/branch/timestamps, -// flat "key: value" lines — parsed without a YAML dependency) -// old: .jsonl flat files (same event schema; early builds carry no cwd at all, -// so those sessions group under the unknown-project bucket) -// -// An event line is `{type, data, id, timestamp, parentId}`. Conversation content: -// session.start → cwd/session id/version (data.context.cwd on newer builds) -// session.model_change → model (data.newModel) -// user.message → user text (data.content) -// assistant.message → assistant text + tool_use blocks (data.content, -// data.toolRequests[{toolCallId,name,arguments}], data.model) -// tool.execution_complete → tool_result (data.toolCallId, data.success, data.result.content) -// Everything else (session.info, system.*, turn markers, tool.execution_start) is harness -// plumbing and skipped — tool arguments already ride the assistant.message request. -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `copilot:` — the files belong to another tool and are never rewritten. - -#![allow(dead_code)] - -use crate::history::Norm; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Copilot's config dir as a history-dir entry string (`~/.copilot`). -pub fn default_root() -> PathBuf { - home().join(".copilot") -} - -pub fn copilot_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -pub fn root_exists() -> bool { - default_root().join("session-state").is_dir() -} - -/// Walk every session log under a `session-state/` tree: flat `.jsonl` (old) and -/// `/events.jsonl` (new). Dirs without an events.jsonl (created-but-unused sessions, -/// checkpoint-only remnants) hold no conversation and are skipped. -pub fn walk(state_dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(state_dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p); - } else if p.is_dir() { - let events = p.join("events.jsonl"); - if events.is_file() { - cb(events); - } - } - } -} - -/// Container-shape test for detail/edit routing: a .jsonl directly in `session-state/`, or an -/// `events.jsonl` whose grandparent is `session-state/`. -pub fn looks_copilot_path(file: &Path) -> bool { - let parent_named = |p: &Path, name: &str| { - p.file_name().and_then(|n| n.to_str()).map(|n| n == name).unwrap_or(false) - }; - match file.file_name().and_then(|n| n.to_str()) { - Some("events.jsonl") => file - .parent() - .and_then(|d| d.parent()) - .map(|gp| parent_named(gp, "session-state")) - .unwrap_or(false), - Some(n) if n.ends_with(".jsonl") => { - file.parent().map(|d| parent_named(d, "session-state")).unwrap_or(false) - } - _ => false, - } -} - -/// The session uuid — the flat file's stem, or the events.jsonl dir name. -fn session_uuid(file: &Path) -> String { - if file.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { - file.parent() - .and_then(|d| d.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default() - } else { - file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() - } -} - -fn sidecar_key(file: &Path) -> String { - format!("copilot:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "copilot:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// Sibling workspace.yaml of an events.jsonl, parsed as flat `key: value` lines (the file is -/// machine-written and flat; no YAML dependency needed). None for old flat sessions. -fn workspace_yaml(file: &Path) -> Option> { - if file.file_name().and_then(|n| n.to_str()) != Some("events.jsonl") { - return None; - } - let text = fs::read_to_string(file.parent()?.join("workspace.yaml")).ok()?; - let mut map = serde_json::Map::new(); - for line in text.lines() { - if let Some((k, v)) = line.split_once(':') { - let (k, v) = (k.trim(), v.trim()); - if !k.is_empty() && !k.starts_with('#') && !v.is_empty() { - map.insert(k.to_string(), json!(v.trim_matches('"').trim_matches('\''))); - } - } - } - Some(map) -} - -/// Copilot tool name + arguments (already an object) → (renderer tool name, renderer input). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; - match name { - "bash" => { - let mut input = json!({ "command": s("command") }); - if !s("description").is_empty() { - input["description"] = json!(s("description")); - } - ("Bash".into(), input) - } - "view" => ("Read".into(), json!({ "file_path": s("path") })), - "edit" | "str_replace" => ( - "Edit".into(), - json!({ "file_path": s("path"), "old_string": s("old_str"), "new_string": s("new_str") }), - ), - "create" => ("Write".into(), json!({ "file_path": s("path"), "content": s("file_text") })), - "rg" => { - let mut input = json!({ "pattern": s("pattern") }); - if let Some(p) = args.get("paths") { - input["path"] = if p.is_array() { - json!(p.as_array().unwrap().iter().filter_map(|x| x.as_str()).collect::>().join(" ")) - } else { - p.clone() - }; - } - if !s("glob").is_empty() { - input["glob"] = json!(s("glob")); - } - ("Grep".into(), input) - } - "glob" => ("Glob".into(), json!({ "pattern": s("pattern"), "path": s("paths") })), - "apply_patch" => ("ApplyPatch".into(), json!({ "patch": s("str") })), - _ => (name.to_string(), keep(args)), - } -} - -/// Normalize parsed event records into the renderer's message model. -pub fn normalize(recs: &[Value]) -> Norm { - let mut n = Norm::default(); - for rec in recs { - let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let data = rec.get("data").cloned().unwrap_or(Value::Null); - let ts = rec.get("timestamp").and_then(|v| v.as_str()); - let with_ts = |mut m: Value| { - if let Some(t) = ts { - m["ts"] = json!(t); - } - m - }; - match ty { - "session.start" => { - if n.session_id.is_none() { - n.session_id = data.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if n.version.is_none() { - n.version = data.get("copilotVersion").and_then(|v| v.as_str()).map(|s| s.to_string()); - } - if n.cwd.is_none() { - n.cwd = data - .get("context") - .and_then(|c| c.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - if n.git_branch.is_none() { - n.git_branch = data - .get("context") - .and_then(|c| c.get("branch")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } - } - "session.model_change" => { - if let Some(m) = data.get("newModel").and_then(|v| v.as_str()) { - n.model = Some(m.to_string()); - } - } - "user.message" => { - let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - n.messages - .push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); - } - } - "assistant.message" => { - if let Some(m) = data.get("model").and_then(|v| v.as_str()) { - n.model = Some(m.to_string()); - } - let mut blocks: Vec = vec![]; - let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(calls) = data.get("toolRequests").and_then(|c| c.as_array()) { - for call in calls { - let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args = call.get("arguments").cloned().unwrap_or(json!({})); - let (tname, input) = map_tool(name, &args); - let id = call.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); - blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); - } - } - if !blocks.is_empty() { - let mut m = json!({ "role": "assistant", "content": blocks }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(with_ts(m)); - } - } - "tool.execution_complete" => { - let id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); - let text = data - .get("result") - .and_then(|r| r.get("content")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": text }); - if data.get("success").and_then(|v| v.as_bool()) == Some(false) { - tr["is_error"] = json!(true); - } - n.messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); - } - _ => {} // session.info / system.* / turn markers / execution_start: harness plumbing - } - } - n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); - n -} - -/// List-row meta: workspace.yaml when present (new layout — has copilot's own session name), -/// else the event head (old flat layout). -pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let ws = workspace_yaml(file); - let n = normalize(recs); - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let ws_str = |k: &str| { - ws.as_ref() - .and_then(|m| m.get(k)) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) - }; - let auto_title = ws_str("name").unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let cwd = ws_str("cwd").or_else(|| n.cwd.clone()); - let created = ws_str("created_at") - .or_else(|| n.first_ts.clone()) - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.timestamp_millis() as f64) - .unwrap_or_else(|| crate::history::created_ms(file)); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - "id": format!("copilot:{}", uuid), - "file": file.to_string_lossy(), - "source": "copilot", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": ws_str("branch").or_else(|| n.git_branch.clone()).map(Value::from).unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": n.model, - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let n = normalize(recs); - let ws = workspace_yaml(path); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let ws_name = ws - .as_ref() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()); - let auto_title = ws_name.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - let cwd = ws - .as_ref() - .and_then(|m| m.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| n.cwd.clone()); - json!({ - "meta": { - "id": format!("copilot:{}", uuid), - "file": file, - "source": "copilot", - "assistant": "Copilot", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": n.version.clone(), - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn ev(ts: &str, ty: &str, data: Value) -> Value { - json!({ "type": ty, "data": data, "id": "e", "timestamp": ts, "parentId": null }) - } - - fn recs() -> Vec { - vec![ - ev("2026-07-12T07:26:54.363Z", "session.start", json!({ - "sessionId": "d34d-1111", "copilotVersion": "1.0.70", - "context": { "cwd": "/tmp/shhh", "gitRoot": "/tmp/shhh", "branch": "main" } - })), - ev("2026-07-12T07:27:00.685Z", "session.model_change", json!({ "newModel": "gpt-5.6" })), - ev("2026-07-12T07:27:05.000Z", "system.message", json!({ "role": "system", "content": "You are Copilot" })), - ev("2026-07-12T07:27:14.000Z", "user.message", json!({ "content": "修沙盒问题", "attachments": [] })), - ev("2026-07-12T07:27:15.000Z", "assistant.message", json!({ - "messageId": "m1", "model": "gpt-5.6", "content": "我先搜一下。", - "toolRequests": [ - { "toolCallId": "call_A", "name": "rg", "arguments": { "pattern": "sandbox", "paths": ".", "glob": "*.plist" } }, - { "toolCallId": "call_B", "name": "bash", "arguments": { "command": "ls", "description": "List", "mode": "sync", "sessionId": "main" } } - ] - })), - ev("2026-07-12T07:27:16.000Z", "tool.execution_start", json!({ "toolCallId": "call_A", "toolName": "rg" })), - ev("2026-07-12T07:27:17.000Z", "tool.execution_complete", json!({ - "toolCallId": "call_A", "success": true, "result": { "content": "a.plist: sandbox" } - })), - ev("2026-07-12T07:27:18.000Z", "tool.execution_complete", json!({ - "toolCallId": "call_B", "success": false, "result": { "content": "boom" } - })), - ] - } - - #[test] - fn normalizes_events() { - let n = normalize(&recs()); - assert_eq!(n.messages.len(), 4); // user, assistant(+2 tools), 2 results - assert_eq!(n.messages[0]["content"][0]["text"], "修沙盒问题"); - let a = &n.messages[1]; - assert_eq!(a["content"][0]["text"], "我先搜一下。"); - assert_eq!(a["content"][1]["name"], "Grep"); - assert_eq!(a["content"][1]["input"]["pattern"], "sandbox"); - assert_eq!(a["content"][2]["name"], "Bash"); - assert_eq!(n.messages[2]["content"][0]["tool_use_id"], "call_A"); - assert_eq!(n.messages[3]["content"][0]["is_error"], true); - assert_eq!(n.model.as_deref(), Some("gpt-5.6")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/shhh")); - assert_eq!(n.session_id.as_deref(), Some("d34d-1111")); - assert_eq!(n.first_ts.as_deref(), Some("2026-07-12T07:27:14.000Z")); - } - - #[test] - fn detects_paths_and_uuids() { - let new = Path::new("/x/.copilot/session-state/abcd-1/events.jsonl"); - let old = Path::new("/x/.copilot/session-state/abcd-2.jsonl"); - assert!(looks_copilot_path(new)); - assert!(looks_copilot_path(old)); - assert!(!looks_copilot_path(Path::new("/x/projects/-tmp/abcd.jsonl"))); - assert_eq!(session_uuid(new), "abcd-1"); - assert_eq!(session_uuid(old), "abcd-2"); - } -} diff --git a/src-tauri/src/copilot/meta.rs b/src-tauri/src/copilot/meta.rs new file mode 100644 index 0000000..dfa23b2 --- /dev/null +++ b/src-tauri/src/copilot/meta.rs @@ -0,0 +1,57 @@ +// Per-session customization (title / tags / soft delete) via the shared foreign-CLI sidecar, +// plus the sibling workspace.yaml that carries cwd/branch/timestamps for the newer layout. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// The session uuid — the flat file's stem, or the events.jsonl dir name. +pub(super) fn session_uuid(file: &Path) -> String { + if file.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() + } +} + +pub(super) fn sidecar_key(file: &Path) -> String { + format!("copilot:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "copilot:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling workspace.yaml of an events.jsonl, parsed as flat `key: value` lines (the file is +/// machine-written and flat; no YAML dependency needed). None for old flat sessions. +pub(super) fn workspace_yaml(file: &Path) -> Option> { + if file.file_name().and_then(|n| n.to_str()) != Some("events.jsonl") { + return None; + } + let text = fs::read_to_string(file.parent()?.join("workspace.yaml")).ok()?; + let mut map = serde_json::Map::new(); + for line in text.lines() { + if let Some((k, v)) = line.split_once(':') { + let (k, v) = (k.trim(), v.trim()); + if !k.is_empty() && !k.starts_with('#') && !v.is_empty() { + map.insert(k.to_string(), json!(v.trim_matches('"').trim_matches('\''))); + } + } + } + Some(map) +} diff --git a/src-tauri/src/copilot/mod.rs b/src-tauri/src/copilot/mod.rs new file mode 100644 index 0000000..b57b165 --- /dev/null +++ b/src-tauri/src/copilot/mod.rs @@ -0,0 +1,35 @@ +// GitHub Copilot CLI session support — reads Copilot's on-disk session event logs and +// normalizes them into the SAME session/message shape the renderer consumes (history::Norm). +// +// Two layouts under `~/.copilot/session-state/`: +// new (≥1.0): /events.jsonl + sibling workspace.yaml (id/cwd/name/branch/timestamps, +// flat "key: value" lines — parsed without a YAML dependency) +// old: .jsonl flat files (same event schema; early builds carry no cwd at all, +// so those sessions group under the unknown-project bucket) +// +// An event line is `{type, data, id, timestamp, parentId}`. Conversation content: +// session.start → cwd/session id/version (data.context.cwd on newer builds) +// session.model_change → model (data.newModel) +// user.message → user text (data.content) +// assistant.message → assistant text + tool_use blocks (data.content, +// data.toolRequests[{toolCallId,name,arguments}], data.model) +// tool.execution_complete → tool_result (data.toolCallId, data.success, data.result.content) +// Everything else (session.info, system.*, turn markers, tool.execution_start) is harness +// plumbing and skipped — tool arguments already ride the assistant.message request. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `copilot:` — the files belong to another tool and are never rewritten. + +#![allow(dead_code)] + +mod meta; +mod normalize; +mod roots; +mod session; +#[cfg(test)] +mod tests; + +pub use meta::{is_deleted, set_meta}; +pub use normalize::normalize; +pub use roots::{copilot_label, looks_copilot_path, root_exists, walk}; +pub use session::{session_from_recs, session_meta_from}; diff --git a/src-tauri/src/copilot/normalize.rs b/src-tauri/src/copilot/normalize.rs new file mode 100644 index 0000000..d8a6e94 --- /dev/null +++ b/src-tauri/src/copilot/normalize.rs @@ -0,0 +1,139 @@ +// Event log → history::Norm: tool-name mapping onto the renderer's native vocabulary, then +// the per-line walk that builds the message timeline. + +use crate::history::Norm; +use serde_json::{json, Value}; + +/// Copilot tool name + arguments (already an object) → (renderer tool name, renderer input). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "bash" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "view" => ("Read".into(), json!({ "file_path": s("path") })), + "edit" | "str_replace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_str"), "new_string": s("new_str") }), + ), + "create" => ("Write".into(), json!({ "file_path": s("path"), "content": s("file_text") })), + "rg" => { + let mut input = json!({ "pattern": s("pattern") }); + if let Some(p) = args.get("paths") { + input["path"] = if p.is_array() { + json!(p.as_array().unwrap().iter().filter_map(|x| x.as_str()).collect::>().join(" ")) + } else { + p.clone() + }; + } + if !s("glob").is_empty() { + input["glob"] = json!(s("glob")); + } + ("Grep".into(), input) + } + "glob" => ("Glob".into(), json!({ "pattern": s("pattern"), "path": s("paths") })), + "apply_patch" => ("ApplyPatch".into(), json!({ "patch": s("str") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed event records into the renderer's message model. +pub fn normalize(recs: &[Value]) -> Norm { + let mut n = Norm::default(); + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let data = rec.get("data").cloned().unwrap_or(Value::Null); + let ts = rec.get("timestamp").and_then(|v| v.as_str()); + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + match ty { + "session.start" => { + if n.session_id.is_none() { + n.session_id = data.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.version.is_none() { + n.version = data.get("copilotVersion").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.cwd.is_none() { + n.cwd = data + .get("context") + .and_then(|c| c.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if n.git_branch.is_none() { + n.git_branch = data + .get("context") + .and_then(|c| c.get("branch")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "session.model_change" => { + if let Some(m) = data.get("newModel").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + } + "user.message" => { + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + n.messages + .push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); + } + } + "assistant.message" => { + if let Some(m) = data.get("model").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + let mut blocks: Vec = vec![]; + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = data.get("toolRequests").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args = call.get("arguments").cloned().unwrap_or(json!({})); + let (tname, input) = map_tool(name, &args); + let id = call.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(with_ts(m)); + } + } + "tool.execution_complete" => { + let id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + let text = data + .get("result") + .and_then(|r| r.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": text }); + if data.get("success").and_then(|v| v.as_bool()) == Some(false) { + tr["is_error"] = json!(true); + } + n.messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); + } + _ => {} // session.info / system.* / turn markers / execution_start: harness plumbing + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} diff --git a/src-tauri/src/copilot/roots.rs b/src-tauri/src/copilot/roots.rs new file mode 100644 index 0000000..32b3585 --- /dev/null +++ b/src-tauri/src/copilot/roots.rs @@ -0,0 +1,62 @@ +// Where Copilot keeps its sessions and how to walk them: both on-disk layouts (the newer +// `/events.jsonl` directories and the older flat `.jsonl` files). + +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Copilot's config dir as a history-dir entry string (`~/.copilot`). +pub fn default_root() -> PathBuf { + home().join(".copilot") +} + +pub fn copilot_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("session-state").is_dir() +} + +/// Walk every session log under a `session-state/` tree: flat `.jsonl` (old) and +/// `/events.jsonl` (new). Dirs without an events.jsonl (created-but-unused sessions, +/// checkpoint-only remnants) hold no conversation and are skipped. +pub fn walk(state_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(state_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p); + } else if p.is_dir() { + let events = p.join("events.jsonl"); + if events.is_file() { + cb(events); + } + } + } +} + +/// Container-shape test for detail/edit routing: a .jsonl directly in `session-state/`, or an +/// `events.jsonl` whose grandparent is `session-state/`. +pub fn looks_copilot_path(file: &Path) -> bool { + let parent_named = |p: &Path, name: &str| { + p.file_name().and_then(|n| n.to_str()).map(|n| n == name).unwrap_or(false) + }; + match file.file_name().and_then(|n| n.to_str()) { + Some("events.jsonl") => file + .parent() + .and_then(|d| d.parent()) + .map(|gp| parent_named(gp, "session-state")) + .unwrap_or(false), + Some(n) if n.ends_with(".jsonl") => { + file.parent().map(|d| parent_named(d, "session-state")).unwrap_or(false) + } + _ => false, + } +} diff --git a/src-tauri/src/copilot/session.rs b/src-tauri/src/copilot/session.rs new file mode 100644 index 0000000..6a62c22 --- /dev/null +++ b/src-tauri/src/copilot/session.rs @@ -0,0 +1,110 @@ +// Session list rows and the full session payload the renderer's 对话 view consumes. + +use super::meta::{session_uuid, sidecar_meta, workspace_yaml}; +use super::normalize::normalize; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// List-row meta: workspace.yaml when present (new layout — has copilot's own session name), +/// else the event head (old flat layout). +pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let ws = workspace_yaml(file); + let n = normalize(recs); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let ws_str = |k: &str| { + ws.as_ref() + .and_then(|m| m.get(k)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + }; + let auto_title = ws_str("name").unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let cwd = ws_str("cwd").or_else(|| n.cwd.clone()); + let created = ws_str("created_at") + .or_else(|| n.first_ts.clone()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.timestamp_millis() as f64) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("copilot:{}", uuid), + "file": file.to_string_lossy(), + "source": "copilot", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": ws_str("branch").or_else(|| n.git_branch.clone()).map(Value::from).unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": n.model, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let n = normalize(recs); + let ws = workspace_yaml(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let ws_name = ws + .as_ref() + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + let auto_title = ws_name.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + let cwd = ws + .as_ref() + .and_then(|m| m.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| n.cwd.clone()); + json!({ + "meta": { + "id": format!("copilot:{}", uuid), + "file": file, + "source": "copilot", + "assistant": "Copilot", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": n.version.clone(), + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/copilot/tests.rs b/src-tauri/src/copilot/tests.rs new file mode 100644 index 0000000..1a39bb7 --- /dev/null +++ b/src-tauri/src/copilot/tests.rs @@ -0,0 +1,64 @@ +use super::meta::session_uuid; +use super::normalize::normalize; +use super::roots::looks_copilot_path; +use serde_json::{json, Value}; +use std::path::Path; + +fn ev(ts: &str, ty: &str, data: Value) -> Value { + json!({ "type": ty, "data": data, "id": "e", "timestamp": ts, "parentId": null }) +} + +fn recs() -> Vec { + vec![ + ev("2026-07-12T07:26:54.363Z", "session.start", json!({ + "sessionId": "d34d-1111", "copilotVersion": "1.0.70", + "context": { "cwd": "/tmp/shhh", "gitRoot": "/tmp/shhh", "branch": "main" } + })), + ev("2026-07-12T07:27:00.685Z", "session.model_change", json!({ "newModel": "gpt-5.6" })), + ev("2026-07-12T07:27:05.000Z", "system.message", json!({ "role": "system", "content": "You are Copilot" })), + ev("2026-07-12T07:27:14.000Z", "user.message", json!({ "content": "修沙盒问题", "attachments": [] })), + ev("2026-07-12T07:27:15.000Z", "assistant.message", json!({ + "messageId": "m1", "model": "gpt-5.6", "content": "我先搜一下。", + "toolRequests": [ + { "toolCallId": "call_A", "name": "rg", "arguments": { "pattern": "sandbox", "paths": ".", "glob": "*.plist" } }, + { "toolCallId": "call_B", "name": "bash", "arguments": { "command": "ls", "description": "List", "mode": "sync", "sessionId": "main" } } + ] + })), + ev("2026-07-12T07:27:16.000Z", "tool.execution_start", json!({ "toolCallId": "call_A", "toolName": "rg" })), + ev("2026-07-12T07:27:17.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_A", "success": true, "result": { "content": "a.plist: sandbox" } + })), + ev("2026-07-12T07:27:18.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_B", "success": false, "result": { "content": "boom" } + })), + ] +} + +#[test] +fn normalizes_events() { + let n = normalize(&recs()); + assert_eq!(n.messages.len(), 4); // user, assistant(+2 tools), 2 results + assert_eq!(n.messages[0]["content"][0]["text"], "修沙盒问题"); + let a = &n.messages[1]; + assert_eq!(a["content"][0]["text"], "我先搜一下。"); + assert_eq!(a["content"][1]["name"], "Grep"); + assert_eq!(a["content"][1]["input"]["pattern"], "sandbox"); + assert_eq!(a["content"][2]["name"], "Bash"); + assert_eq!(n.messages[2]["content"][0]["tool_use_id"], "call_A"); + assert_eq!(n.messages[3]["content"][0]["is_error"], true); + assert_eq!(n.model.as_deref(), Some("gpt-5.6")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/shhh")); + assert_eq!(n.session_id.as_deref(), Some("d34d-1111")); + assert_eq!(n.first_ts.as_deref(), Some("2026-07-12T07:27:14.000Z")); +} + +#[test] +fn detects_paths_and_uuids() { + let new = Path::new("/x/.copilot/session-state/abcd-1/events.jsonl"); + let old = Path::new("/x/.copilot/session-state/abcd-2.jsonl"); + assert!(looks_copilot_path(new)); + assert!(looks_copilot_path(old)); + assert!(!looks_copilot_path(Path::new("/x/projects/-tmp/abcd.jsonl"))); + assert_eq!(session_uuid(new), "abcd-1"); + assert_eq!(session_uuid(old), "abcd-2"); +} diff --git a/src-tauri/src/exporthtml.rs b/src-tauri/src/exporthtml.rs deleted file mode 100644 index 5f8144d..0000000 --- a/src-tauri/src/exporthtml.rs +++ /dev/null @@ -1,952 +0,0 @@ -// Standalone conversation export → a single self-contained .html viewer. Rust port of exportHtml.js. -// -// Embeds the conversation as JSON plus a Claude-design skin (light/dark) + a client runtime -// (render + theme + search + expandable tools/subagents), with marked + highlight.js vendored. -// Heavy content fields are capped so the embedded JSON stays bounded. - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::fs; -use std::path::Path; - -const SKIN: &str = include_str!("../../src/main/export-assets/skin.css"); -const RUNTIME: &str = include_str!("../../src/main/export-assets/runtime.js"); -const MARKED: &str = include_str!("../../src/renderer/vendor/marked.umd.js"); -const HLJS: &str = include_str!("../../src/renderer/vendor/highlight.min.js"); -const HLJS_CSS: &str = include_str!("../../src/renderer/vendor/hljs-dark.css"); - -const CAP_TEXT: usize = 24000; -const CAP_THINKING: usize = 16000; -const CAP_RESULT: usize = 24000; -const CAP_PROMPT: usize = 9000; -const CAP_CONTENT: usize = 14000; -const CAP_SKILL_SNAPSHOT: usize = 131072; - -fn cap(s: &str, n: usize) -> String { - if s.chars().count() > n { - let truncated: String = s.chars().take(n).collect(); - let dropped = s.chars().count() - n; - format!("{}\n…[truncated {} chars]", truncated, dropped) - } else { - s.to_string() - } -} - -fn parse_jsonl_result(file: &Path) -> std::io::Result> { - let qoder = crate::qoder::looks_qoder_path(file); - let raw = if qoder { crate::qoder::read_text(file) } else { fs::read_to_string(file) }?; - let records: Vec = raw - .split('\n') - .map(|l| l.trim()) - .filter(|l| !l.is_empty()) - .filter_map(|l| serde_json::from_str::(l).ok()) - .collect(); - Ok(if qoder { - crate::qoder::normalize_records(&records) - } else { - records - }) -} - -/// Skip-on-error variant for subagent sidecars — one broken agent file must not sink the export. -/// The MAIN transcript goes through parse_jsonl_result so a read failure surfaces to the caller -/// instead of exporting an empty page. -fn parse_jsonl(file: &Path) -> Vec { - parse_jsonl_result(file).unwrap_or_default() -} - -fn usage_of(u: &Value) -> Value { - let mut usage = json!({ - "in": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "out": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let object = usage.as_object_mut().unwrap(); - for (source, target) in [ - ("credits", "credits"), - ("original_credits", "originalCredits"), - ("context_usage_ratio", "contextUsageRatio"), - ] { - if let Some(value) = u.get(source).filter(|value| value.is_number()) { - object.insert(target.to_string(), value.clone()); - } - } - usage -} - -fn cap_content(content: &Value) -> Value { - if let Some(s) = content.as_str() { - return json!(cap(s, CAP_TEXT)); - } - let arr = match content.as_array() { - Some(a) => a, - None => return content.clone(), - }; - let mapped: Vec = arr - .iter() - .map(|b| { - let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); - match ty { - "text" => json!({ "type": "text", "text": cap(b.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_TEXT) }), - "thinking" => json!({ "type": "thinking", "thinking": cap(b.get("thinking").and_then(|t| t.as_str()).unwrap_or(""), CAP_THINKING) }), - "skill_load" => json!({ - "type": "skill_load", - "name": b.get("name").cloned().unwrap_or(Value::Null), - "path": b.get("path").cloned().unwrap_or(Value::Null), - "snapshot": b - .get("snapshot") - .and_then(|v| v.as_str()) - .map(|v| json!(cap(v, CAP_SKILL_SNAPSHOT))) - .unwrap_or(Value::Null), - }), - "tool_use" => { - let mut input = b.get("input").cloned().unwrap_or(json!({})); - if let Some(obj) = input.as_object_mut() { - if let Some(p) = obj.get("prompt").and_then(|v| v.as_str()) { - let c = cap(p, CAP_PROMPT); - obj.insert("prompt".into(), json!(c)); - } - if let Some(p) = obj.get("content").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); - obj.insert("content".into(), json!(c)); - } - if let Some(p) = obj.get("patch").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); // codex ApplyPatch envelopes can be huge - obj.insert("patch".into(), json!(c)); - } - if let Some(p) = obj.get("code").and_then(|v| v.as_str()) { - let c = cap(p, CAP_CONTENT); // code-mode Script bodies - obj.insert("code".into(), json!(c)); - } - } - json!({ "type": "tool_use", "id": b.get("id").cloned().unwrap_or(Value::Null), "name": b.get("name").cloned().unwrap_or(Value::Null), "input": input }) - } - "tool_result" => { - let c = match b.get("content") { - Some(Value::String(s)) => json!(cap(s, CAP_RESULT)), - Some(Value::Array(ca)) => Value::Array( - ca.iter() - .map(|x| { - if x.get("type").and_then(|t| t.as_str()) == Some("text") { - json!({ "type": "text", "text": cap(x.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_RESULT) }) - } else { - x.clone() - } - }) - .collect(), - ), - other => other.cloned().unwrap_or(Value::Null), - }; - json!({ "type": "tool_result", "tool_use_id": b.get("tool_use_id").cloned().unwrap_or(Value::Null), "is_error": b.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false), "content": c }) - } - "image" => { - let oversized = b.get("source").and_then(|s| s.get("data")).and_then(|d| d.as_str()).map(|d| d.len() > 600000).unwrap_or(false); - if oversized { - json!({ "type": "image", "source": { "media_type": b.get("source").and_then(|s| s.get("media_type")).and_then(|m| m.as_str()).unwrap_or("image/png"), "oversized": true } }) - } else { - b.clone() - } - } - _ => b.clone(), - } - }) - .collect(); - Value::Array(mapped) -} - -fn line_to_msg(rec: &Value) -> Option { - let ty = rec.get("type").and_then(|v| v.as_str())?; - if ty != "user" && ty != "assistant" { - return None; - } - let m = rec.get("message")?; - m.get("role").and_then(|v| v.as_str())?; - let mut out = json!({ - "role": m.get("role").cloned().unwrap_or(Value::Null), - "content": cap_content(m.get("content").unwrap_or(&Value::Null)), - "ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), - "meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), - }); - if ty == "assistant" { - let o = out.as_object_mut().unwrap(); - o.insert( - "model".into(), - m.get("model").cloned().unwrap_or(Value::Null), - ); - o.insert( - "usage".into(), - m.get("usage").map(usage_of).unwrap_or(Value::Null), - ); - o.insert( - "stop".into(), - m.get("stop_reason").cloned().unwrap_or(Value::Null), - ); - } - Some(out) -} - -fn content_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - if let Some(arr) = content.as_array() { - return arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(" "); - } - String::new() -} -fn command_label(raw: &str) -> String { - let name = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(n, _)| n.trim().to_string()) - .unwrap_or_default(); - if name.is_empty() { - return String::new(); - } - let args = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(a, _)| a.trim().to_string()) - .unwrap_or_default(); - format!("{} {}", name, args).trim().to_string() -} -fn first_user_text(messages: &[Value]) -> String { - let mut fallback = String::new(); - for m in messages { - if m.get("role").and_then(|r| r.as_str()) != Some("user") - || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) - { - continue; - } - let raw = content_text(m.get("content").unwrap_or(&Value::Null)); - let raw = raw.trim(); - if raw.is_empty() { - continue; - } - if raw.starts_with('<') { - if fallback.is_empty() { - fallback = command_label(raw); - } - continue; - } - let t: String = raw.split_whitespace().collect::>().join(" "); - if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { - continue; - } - return t.chars().take(100).collect(); - } - fallback.chars().take(100).collect() -} -fn base_name(p: &str) -> String { - p.split('/') - .filter(|s| !s.is_empty()) - .last() - .unwrap_or(p) - .to_string() -} - -struct Shaped { - messages: Vec, - model: Option, - totals: (i64, i64, i64, i64), - cache_creation: i64, - credits: Option, - token_usage_available: bool, - first_ts: Option, - last_ts: Option, -} -fn shape_session(recs: &[Value]) -> Shaped { - let mut messages = vec![]; - let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut credits = 0.0f64; - let mut has_credits = false; - let mut model = None; - let mut first_ts = None; - let mut last_ts = None; - for r in recs { - let lm = match line_to_msg(r) { - Some(m) => m, - None => continue, - }; - if lm.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - if let Some(ts) = lm.get("ts").and_then(|v| v.as_str()) { - if first_ts.is_none() { - first_ts = Some(ts.to_string()); - } - last_ts = Some(ts.to_string()); - } - if let Some(md) = lm.get("model").and_then(|v| v.as_str()) { - model = Some(md.to_string()); - } - if let Some(u) = lm.get("usage").filter(|u| u.is_object()) { - tin += u.get("in").and_then(|v| v.as_i64()).unwrap_or(0); - tout += u.get("out").and_then(|v| v.as_i64()).unwrap_or(0); - tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); - tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); - if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { - credits += value; - has_credits = true; - } - turns += 1; - } - messages.push(lm); - } - Shaped { - messages, - model, - totals: (tin, tout, tcr, turns), - cache_creation: tcc, - credits: has_credits.then_some(credits), - token_usage_available: !(has_credits && tin == 0 && tout == 0 && tcr == 0 && tcc == 0), - first_ts, - last_ts, - } -} - -fn read_subagents(file: &Path) -> Value { - let qoder = crate::qoder::looks_qoder_path(file); - let dir = file.parent().map(|p| { - p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")) - .join("subagents") - }); - let dir = match dir { - Some(d) => d, - None => return json!({}), - }; - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return json!({}), - }; - let mut agent_names: Vec = entries - .flatten() - .map(|ent| ent.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with("agent-") && name.ends_with(".jsonl")) - .collect(); - agent_names.sort(); - // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch. - if qoder { - let mut warm: Vec = vec![]; - for name in &agent_names { - warm.push(dir.join(name)); - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); - warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); - } - crate::qoder::prefetch(&warm); - } - let mut by_tool = serde_json::Map::new(); - for name in agent_names { - let agent_id = name - .trim_start_matches("agent-") - .trim_end_matches(".jsonl") - .to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if qoder { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(json!({})); - let recs = parse_jsonl(&dir.join(&name)); - let shaped = shape_session(&recs); - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - by_tool.insert( - key, - json!({ - "agentId": agent_id, - "type": meta.get("agentType").or_else(|| meta.get("subagent_type")).and_then(|v| v.as_str()).unwrap_or("agent"), - "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), - "skill": crate::history::skill_from_recs(&recs), - "count": shaped.messages.len(), - "totals": { - "in": shaped.totals.0, - "out": shaped.totals.1, - "cacheRead": shaped.totals.2, - "cacheCreation": shaped.cache_creation, - "turns": shaped.totals.3, - "credits": shaped.credits, - "tokenUsageAvailable": shaped.token_usage_available, - }, - "messages": shaped.messages, - }), - ); - } - Value::Object(by_tool) -} - -// Non-Claude session detail → the export data shape (messages re-capped + field names the -// viewer runtime reads: model / usage{in,out,cacheRead} / stop). `assistant` labels turns on -// the exported page (Codex / Grok / Copilot / Antigravity). -fn build_from_session(sess: Value, assistant: &str) -> Value { - let m = sess.get("meta").cloned().unwrap_or_else(|| json!({})); - let messages: Vec = sess - .get("messages") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .map(|msg| { - let mut out = json!({ - "role": msg.get("role").cloned().unwrap_or(Value::Null), - "content": cap_content(msg.get("content").unwrap_or(&Value::Null)), - "ts": msg.get("ts").cloned().unwrap_or(Value::Null), - "meta": msg - .get("meta") - .or_else(|| msg.get("_meta")) - .and_then(|v| v.as_bool()) - .unwrap_or(false), - }); - let o = out.as_object_mut().unwrap(); - if let Some(md) = msg.get("modelActual") { - o.insert("model".into(), md.clone()); - } - if let Some(u) = msg.get("usage") { - let mut usage = json!({ - "in": u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "out": u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let usage_object = usage.as_object_mut().unwrap(); - for field in ["credits", "originalCredits", "contextUsageRatio"] { - if let Some(value) = u.get(field).filter(|value| value.is_number()) { - usage_object.insert(field.to_string(), value.clone()); - } - } - o.insert( - "usage".into(), - usage, - ); - } - out - }) - .collect() - }) - .unwrap_or_default(); - let t = m.get("totals").cloned().unwrap_or_else(|| json!({})); - let title = m - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - json!({ - "meta": { - "title": if title.is_empty() { "(conversation)".to_string() } else { title }, - "assistant": assistant, - "model": m.get("model").cloned().unwrap_or(Value::Null), - "project": m.get("project").cloned().unwrap_or(Value::Null), - "cwd": m.get("cwd").cloned().unwrap_or(Value::Null), - "branch": m.get("gitBranch").cloned().unwrap_or(Value::Null), - "sessionId": m.get("sessionId").cloned().unwrap_or(Value::Null), - "version": m.get("version").cloned().unwrap_or(Value::Null), - "count": messages.len(), - "turns": t.get("turns").cloned().unwrap_or(json!(0)), - "inTok": t.get("in").cloned().unwrap_or(json!(0)), - "outTok": t.get("out").cloned().unwrap_or(json!(0)), - "cacheTok": t.get("cacheRead").cloned().unwrap_or(json!(0)), - "credits": t.get("credits").cloned().unwrap_or(Value::Null), - "tokenUsageAvailable": t.get("tokenUsageAvailable").cloned().unwrap_or(json!(true)), - "subagentCount": 0, - "firstTs": m.get("firstTs").cloned().unwrap_or(Value::Null), - "lastTs": m.get("lastTs").cloned().unwrap_or(Value::Null), - }, - "messages": messages, - "subagents": {}, - }) -} - -pub fn build_data(file: &str) -> Value { - let path = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(path); - // Antigravity first — it's SQLite and its shaper opens the DB itself. Every other source - // reads the transcript here, and a failed MAIN read returns the structured error (the export - // command surfaces it) instead of silently exporting an empty page. - if matches!(crate::history::foreign_kind(path), Some(crate::history::Foreign::Antigravity)) { - return build_from_session(crate::antigravity::session_from(file), "Antigravity"); - } - let recs = match parse_jsonl_result(path) { - Ok(recs) => recs, - Err(error) => return crate::history::session_read_error(path, &error), - }; - match crate::history::foreign_kind(path) { - Some(crate::history::Foreign::Grok) => { - return build_from_session(crate::grok::session_from_recs(file, &recs), "Grok"); - } - Some(crate::history::Foreign::Copilot) => { - return build_from_session(crate::copilot::session_from_recs(file, &recs), "Copilot"); - } - _ => {} - } - if crate::codex::looks_codex(&recs) { - return build_from_session(crate::codex::session_from_recs(file, &recs), "Codex"); - } - // Qoder sessions are Claude-format (the shaping below applies as-is) — brand the exported - // page and prefer qoder's own stored title over first-user-text. - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let s = shape_session(&recs); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(str::to_string); - let cwd = if qoder { - crate::qoder::working_dir_from(&recs).or(top_level_cwd) - } else { - top_level_cwd - }; - let title = { - let t = (if qoder { - crate::qoder::session_title_from(&recs) - } else { - None - }) - .unwrap_or_else(|| first_user_text(&s.messages)); - if t.is_empty() { - "(conversation)".to_string() - } else { - t - } - }; - let model = if qoder { - crate::qoder::model_from(&recs).or(s.model.clone()) - } else { - s.model.clone() - }; - let stem = path.file_stem().and_then(|x| x.to_str()).unwrap_or(""); - let mut subagents = read_subagents(path); - if let Some(map) = subagents.as_object_mut() { - // The spawning Skill tool_use overrides the sentinel fallback (mirrors exportHtml.js). - crate::history::apply_skill_names(&s.messages, map); - } - json!({ - "meta": { - "title": title, - // The viewer runtime labels turns `meta.assistant || 'Claude'`. - "assistant": if qoder { json!("Qoder") } else { Value::Null }, - "model": model, - "project": cwd.as_deref().map(base_name), - "cwd": cwd, - "branch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(stem), - "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), - "count": s.messages.len(), - "turns": s.totals.3, - "inTok": s.totals.0, "outTok": s.totals.1, "cacheTok": s.totals.2, - "credits": s.credits, - "tokenUsageAvailable": s.token_usage_available, - "subagentCount": subagents.as_object().map(|o| o.len()).unwrap_or(0), - "firstTs": s.first_ts, "lastTs": s.last_ts, - }, - "messages": s.messages, - "subagents": subagents, - }) -} - -pub fn html_from_data(data: &Value) -> String { - let json = serde_json::to_string(data) - .unwrap_or_default() - .replace('<', "\\u003c"); - // Tab title uses the project name (already public via the export's filename), NOT the - // conversation title: Clarity reports document.title as page metadata that masking can't - // reach, and the conversation title is first-message text. The full title still renders - // in the viewer header, inside the Clarity-masked #app. - let title = data - .get("meta") - .and_then(|m| m.get("project")) - .and_then(|v| v.as_str()) - .unwrap_or("Conversation") - .replace(['<', '>'], ""); - // The exported viewer is a static file opened in a plain browser (no app CSP). A nonce-based - // CSP lets ONLY these four generator-emitted \ -\ -\ -\ -", - csp = csp, - title = title, - skin = SKIN, - hljscss = HLJS_CSS, - marked = MARKED, - hljs = HLJS, - json = json, - version = env!("CARGO_PKG_VERSION"), - runtime = RUNTIME, - ) -} - -pub fn build_export_html(file: &str) -> String { - html_from_data(&build_data(file)) -} - -// ---- export filename ---- -// Default export base name: `--`, both timestamps as YYMMDDHHmm -// (local time). Earlier exports used collision-prone names (UUID-only JSONL or first-message HTML); -// this keeps bulk exports stable and sortable. - -// path/url-hostile chars + whitespace runs collapse to a single `_`; leading/trailing `_ . -` are -// trimmed; result capped at 60 chars. -fn sanitize_name(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut prev_underscore = false; - for ch in s.chars() { - let bad = matches!( - ch, - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r' - ) || ch.is_whitespace(); - if bad { - if !prev_underscore { - out.push('_'); - prev_underscore = true; - } - } else { - out.push(ch); - prev_underscore = false; - } - } - out.trim_matches(|c| c == '_' || c == '.' || c == '-') - .chars() - .take(60) - .collect() -} - -// Parse an ISO-8601 `ts` and render it as YYMMDDHHmm in local time (matches `new Date(ts)` + the -// Date's local getters used by the original). -fn fmt_ts_local(ts: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(ts).ok().map(|dt| { - dt.with_timezone(&chrono::Local) - .format("%y%m%d%H%M") - .to_string() - }) -} - -// Derive the base name from already-built export `data` (avoids re-parsing for the HTML path). -pub fn export_base_name_from_data(data: &Value) -> String { - let meta = data.get("meta"); - let project = meta - .and_then(|m| m.get("project")) - .and_then(|v| v.as_str()) - .map(sanitize_name) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "conversation".to_string()); - let conv_part = meta - .and_then(|m| m.get("firstTs")) - .and_then(|v| v.as_str()) - .and_then(fmt_ts_local) - .unwrap_or_else(|| "unknown".to_string()); - let exported_at = chrono::Local::now().format("%y%m%d%H%M").to_string(); - format!("{}-{}-{}", project, conv_part, exported_at) -} - -// Build + shape the file, then derive the base name (JSONL export path, which has no `data` yet). -pub fn export_base_name(file: &str) -> String { - export_base_name_from_data(&build_data(file)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn qoder_fixture(name: &str) -> (PathBuf, PathBuf) { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "ccbud-exporthtml-{name}-{}-{nonce}", - std::process::id() - )); - let file = root - .join(".qoder") - .join("projects") - .join("-work-project") - .join("session.jsonl"); - fs::create_dir_all(file.parent().unwrap()).unwrap(); - (root, file) - } - - fn write_jsonl(path: &Path, records: &[Value]) { - let raw = records - .iter() - .map(|record| serde_json::to_string(record).unwrap()) - .collect::>() - .join("\n"); - fs::write(path, format!("{raw}\n")).unwrap(); - } - - fn streamed_assistant_records() -> Vec { - vec![ - json!({ - "type": "assistant", - "uuid": "wrap-1", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "", - "content": [{ "type": "thinking", "thinking": "plan" }], - "stop_reason": null, - }, - }), - json!({ - "type": "assistant", - "uuid": "wrap-2", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "ultimate", - "content": [{ "type": "redacted_thinking", "data": "opaque" }], - "stop_reason": null, - }, - }), - json!({ - "type": "assistant", - "uuid": "wrap-3", - "message": { - "id": "msg-1", - "role": "assistant", - "model": "ultimate", - "content": [{ - "type": "tool_use", - "id": "tool-1", - "name": "Read", - "input": { "file_path": "/work/a" }, - }], - "usage": { "input_tokens": 7, "output_tokens": 3 }, - "stop_reason": "tool_use", - }, - }), - ] - } - - #[test] - fn qoder_jsonl_normalizes_streamed_assistant_records() { - let (root, file) = qoder_fixture("normalize"); - write_jsonl(&file, &streamed_assistant_records()); - - let records = parse_jsonl(&file); - assert_eq!(records.len(), 1); - assert_eq!( - records[0].get("uuid").and_then(Value::as_str), - Some("wrap-1") - ); - let message = records[0].get("message").unwrap(); - assert_eq!( - message.get("model").and_then(Value::as_str), - Some("ultimate") - ); - assert_eq!( - message.get("stop_reason").and_then(Value::as_str), - Some("tool_use") - ); - assert_eq!( - message - .get("usage") - .and_then(|usage| usage.get("input_tokens")) - .and_then(Value::as_i64), - Some(7) - ); - let content = message.get("content").and_then(Value::as_array).unwrap(); - assert_eq!(content.len(), 2); - assert_eq!( - content[0].get("type").and_then(Value::as_str), - Some("thinking") - ); - assert_eq!( - content[1].get("type").and_then(Value::as_str), - Some("tool_use") - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn ordinary_jsonl_keeps_streamed_records_unchanged() { - let (root, _) = qoder_fixture("ordinary"); - let file = root.join("ordinary.jsonl"); - write_jsonl(&file, &streamed_assistant_records()); - - let records = parse_jsonl(&file); - assert_eq!(records.len(), 3); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn qoder_export_uses_record_metadata_and_reads_subagent_meta() { - let (root, file) = qoder_fixture("metadata"); - write_jsonl( - &file, - &[ - json!({ - "type": "workspace-directories", - "sessionId": "session", - "directories": ["/work/project"], - }), - json!({ - "type": "runtime-config", - "sessionId": "session", - "model": "ultimate", - }), - json!({ - "type": "ai-title", - "sessionId": "session", - "aiTitle": "Inline Qoder title", - }), - json!({ - "type": "user", - "uuid": "user-1", - "timestamp": "2026-08-04T08:00:00Z", - "message": { "role": "user", "content": "User fallback title" }, - }), - json!({ - "type": "assistant", - "uuid": "assistant-1", - "timestamp": "2026-08-04T08:01:00Z", - "message": { - "id": "answer-1", - "role": "assistant", - "model": "message-model", - "content": [{ "type": "text", "text": "Done" }], - "usage": { "input_tokens": 2, "output_tokens": 1 }, - "stop_reason": "end_turn", - }, - }), - ], - ); - fs::write( - file.parent().unwrap().join("session-session.json"), - r#"{"title":"stale companion title","working_dir":"/stale/path"}"#, - ) - .unwrap(); - - let subagent_dir = file.parent().unwrap().join("session").join("subagents"); - fs::create_dir_all(&subagent_dir).unwrap(); - write_jsonl( - &subagent_dir.join("agent-a.jsonl"), - &[ - json!({ - "type": "user", - "uuid": "sub-user", - "message": { "role": "user", "content": "Investigate" }, - }), - json!({ - "type": "assistant", - "uuid": "sub-assistant", - "message": { - "id": "sub-answer", - "role": "assistant", - "model": "ultimate", - "content": [{ "type": "text", "text": "Found it" }], - }, - }), - ], - ); - fs::write( - subagent_dir.join("agent-a.meta.json"), - r#"{"toolUseId":"tool-a","agentType":"Explore","description":"trace"}"#, - ) - .unwrap(); - - let data = build_data(&file.to_string_lossy()); - let meta = data.get("meta").unwrap(); - assert_eq!(meta.get("assistant").and_then(Value::as_str), Some("Qoder")); - assert_eq!( - meta.get("title").and_then(Value::as_str), - Some("Inline Qoder title") - ); - assert_eq!( - meta.get("cwd").and_then(Value::as_str), - Some("/work/project") - ); - assert_eq!(meta.get("project").and_then(Value::as_str), Some("project")); - assert_eq!(meta.get("model").and_then(Value::as_str), Some("ultimate")); - assert_eq!(meta.get("subagentCount").and_then(Value::as_u64), Some(1)); - let subagent = data - .get("subagents") - .and_then(|subagents| subagents.get("tool-a")) - .unwrap(); - assert_eq!( - subagent.get("type").and_then(Value::as_str), - Some("Explore") - ); - assert_eq!( - subagent.get("description").and_then(Value::as_str), - Some("trace") - ); - assert_eq!(subagent.get("count").and_then(Value::as_u64), Some(2)); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn qoder_export_falls_back_to_top_level_cwd_and_assistant_model() { - let (root, file) = qoder_fixture("metadata-fallback"); - write_jsonl( - &file, - &[ - json!({ - "type": "user", - "uuid": "user-1", - "sessionId": "session", - "cwd": "/legacy/work", - "message": { "role": "user", "content": "Fallback title" }, - }), - json!({ - "type": "assistant", - "uuid": "assistant-1", - "message": { - "id": "answer-1", - "role": "assistant", - "model": "legacy-model", - "content": [{ "type": "text", "text": "Done" }], - }, - }), - ], - ); - - let data = build_data(&file.to_string_lossy()); - let meta = data.get("meta").unwrap(); - assert_eq!( - meta.get("cwd").and_then(Value::as_str), - Some("/legacy/work") - ); - assert_eq!(meta.get("project").and_then(Value::as_str), Some("work")); - assert_eq!( - meta.get("model").and_then(Value::as_str), - Some("legacy-model") - ); - - fs::remove_dir_all(root).unwrap(); - } -} diff --git a/src-tauri/src/exporthtml/assets.rs b/src-tauri/src/exporthtml/assets.rs new file mode 100644 index 0000000..4544c83 --- /dev/null +++ b/src-tauri/src/exporthtml/assets.rs @@ -0,0 +1,24 @@ +// The embedded viewer assets (skin, runtime parts, vendored marked/highlight.js) and the +// per-field size caps. Moved verbatim from exporthtml.rs — the include_str! paths gained one +// `../` because this file sits one directory deeper. + +pub(super) const SKIN: &str = include_str!("../../../src/main/export-assets/skin.css"); +// The viewer runtime ships as four source parts (each within the repo's module-size limit) +// concatenated verbatim into one \ +\ +\ +\ +", + csp = csp, + title = title, + skin = SKIN, + hljscss = HLJS_CSS, + marked = MARKED, + hljs = HLJS, + json = json, + version = env!("CARGO_PKG_VERSION"), + runtime = RUNTIME, + ) +} + +pub fn build_export_html(file: &str) -> String { + html_from_data(&build_data(file)) +} diff --git a/src-tauri/src/exporthtml/mod.rs b/src-tauri/src/exporthtml/mod.rs new file mode 100644 index 0000000..6af8e25 --- /dev/null +++ b/src-tauri/src/exporthtml/mod.rs @@ -0,0 +1,22 @@ +// Standalone conversation export → a single self-contained .html viewer. Rust port of exportHtml.js. +// +// Embeds the conversation as JSON plus a Claude-design skin (light/dark) + a client runtime +// (render + theme + search + expandable tools/subagents), with marked + highlight.js vendored. +// Heavy content fields are capped so the embedded JSON stays bounded. + +#![allow(dead_code)] +mod assets; +mod build; +mod html; +mod name; +mod parse; +mod session; +mod shape; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use build::build_data; +pub use html::{build_export_html, html_from_data}; +pub use name::{export_base_name, export_base_name_from_data}; diff --git a/src-tauri/src/exporthtml/name.rs b/src-tauri/src/exporthtml/name.rs new file mode 100644 index 0000000..ea7951d --- /dev/null +++ b/src-tauri/src/exporthtml/name.rs @@ -0,0 +1,69 @@ +// The default export filename. Moved verbatim from exporthtml.rs. + +use serde_json::Value; + +use super::build::build_data; + +// ---- export filename ---- +// Default export base name: `--`, both timestamps as YYMMDDHHmm +// (local time). Earlier exports used collision-prone names (UUID-only JSONL or first-message HTML); +// this keeps bulk exports stable and sortable. + +// path/url-hostile chars + whitespace runs collapse to a single `_`; leading/trailing `_ . -` are +// trimmed; result capped at 60 chars. +pub(super) fn sanitize_name(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_underscore = false; + for ch in s.chars() { + let bad = matches!( + ch, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\n' | '\r' + ) || ch.is_whitespace(); + if bad { + if !prev_underscore { + out.push('_'); + prev_underscore = true; + } + } else { + out.push(ch); + prev_underscore = false; + } + } + out.trim_matches(|c| c == '_' || c == '.' || c == '-') + .chars() + .take(60) + .collect() +} + +// Parse an ISO-8601 `ts` and render it as YYMMDDHHmm in local time (matches `new Date(ts)` + the +// Date's local getters used by the original). +pub(super) fn fmt_ts_local(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts).ok().map(|dt| { + dt.with_timezone(&chrono::Local) + .format("%y%m%d%H%M") + .to_string() + }) +} + +// Derive the base name from already-built export `data` (avoids re-parsing for the HTML path). +pub fn export_base_name_from_data(data: &Value) -> String { + let meta = data.get("meta"); + let project = meta + .and_then(|m| m.get("project")) + .and_then(|v| v.as_str()) + .map(sanitize_name) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "conversation".to_string()); + let conv_part = meta + .and_then(|m| m.get("firstTs")) + .and_then(|v| v.as_str()) + .and_then(fmt_ts_local) + .unwrap_or_else(|| "unknown".to_string()); + let exported_at = chrono::Local::now().format("%y%m%d%H%M").to_string(); + format!("{}-{}-{}", project, conv_part, exported_at) +} + +// Build + shape the file, then derive the base name (JSONL export path, which has no `data` yet). +pub fn export_base_name(file: &str) -> String { + export_base_name_from_data(&build_data(file)) +} diff --git a/src-tauri/src/exporthtml/parse.rs b/src-tauri/src/exporthtml/parse.rs new file mode 100644 index 0000000..440b335 --- /dev/null +++ b/src-tauri/src/exporthtml/parse.rs @@ -0,0 +1,143 @@ +// Transcript reading and the per-field capping that keeps the embedded JSON bounded. Moved +// verbatim from exporthtml.rs. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::assets::{ + CAP_CONTENT, CAP_PROMPT, CAP_RESULT, CAP_SKILL_SNAPSHOT, CAP_TEXT, CAP_THINKING, +}; + +fn cap(s: &str, n: usize) -> String { + if s.chars().count() > n { + let truncated: String = s.chars().take(n).collect(); + let dropped = s.chars().count() - n; + format!("{}\n…[truncated {} chars]", truncated, dropped) + } else { + s.to_string() + } +} + +pub(super) fn parse_jsonl_result(file: &Path) -> std::io::Result> { + let qoder = crate::qoder::looks_qoder_path(file); + let raw = if qoder { crate::qoder::read_text(file) } else { fs::read_to_string(file) }?; + let records: Vec = raw + .split('\n') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect(); + Ok(if qoder { + crate::qoder::normalize_records(&records) + } else { + records + }) +} + +/// Skip-on-error variant for subagent sidecars — one broken agent file must not sink the export. +/// The MAIN transcript goes through parse_jsonl_result so a read failure surfaces to the caller +/// instead of exporting an empty page. +pub(super) fn parse_jsonl(file: &Path) -> Vec { + parse_jsonl_result(file).unwrap_or_default() +} + +pub(super) fn usage_of(u: &Value) -> Value { + let mut usage = json!({ + "in": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "out": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }); + let object = usage.as_object_mut().unwrap(); + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage +} + +pub(super) fn cap_content(content: &Value) -> Value { + if let Some(s) = content.as_str() { + return json!(cap(s, CAP_TEXT)); + } + let arr = match content.as_array() { + Some(a) => a, + None => return content.clone(), + }; + let mapped: Vec = arr + .iter() + .map(|b| { + let ty = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match ty { + "text" => json!({ "type": "text", "text": cap(b.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_TEXT) }), + "thinking" => json!({ "type": "thinking", "thinking": cap(b.get("thinking").and_then(|t| t.as_str()).unwrap_or(""), CAP_THINKING) }), + "skill_load" => json!({ + "type": "skill_load", + "name": b.get("name").cloned().unwrap_or(Value::Null), + "path": b.get("path").cloned().unwrap_or(Value::Null), + "snapshot": b + .get("snapshot") + .and_then(|v| v.as_str()) + .map(|v| json!(cap(v, CAP_SKILL_SNAPSHOT))) + .unwrap_or(Value::Null), + }), + "tool_use" => { + let mut input = b.get("input").cloned().unwrap_or(json!({})); + if let Some(obj) = input.as_object_mut() { + if let Some(p) = obj.get("prompt").and_then(|v| v.as_str()) { + let c = cap(p, CAP_PROMPT); + obj.insert("prompt".into(), json!(c)); + } + if let Some(p) = obj.get("content").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); + obj.insert("content".into(), json!(c)); + } + if let Some(p) = obj.get("patch").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); // codex ApplyPatch envelopes can be huge + obj.insert("patch".into(), json!(c)); + } + if let Some(p) = obj.get("code").and_then(|v| v.as_str()) { + let c = cap(p, CAP_CONTENT); // code-mode Script bodies + obj.insert("code".into(), json!(c)); + } + } + json!({ "type": "tool_use", "id": b.get("id").cloned().unwrap_or(Value::Null), "name": b.get("name").cloned().unwrap_or(Value::Null), "input": input }) + } + "tool_result" => { + let c = match b.get("content") { + Some(Value::String(s)) => json!(cap(s, CAP_RESULT)), + Some(Value::Array(ca)) => Value::Array( + ca.iter() + .map(|x| { + if x.get("type").and_then(|t| t.as_str()) == Some("text") { + json!({ "type": "text", "text": cap(x.get("text").and_then(|t| t.as_str()).unwrap_or(""), CAP_RESULT) }) + } else { + x.clone() + } + }) + .collect(), + ), + other => other.cloned().unwrap_or(Value::Null), + }; + json!({ "type": "tool_result", "tool_use_id": b.get("tool_use_id").cloned().unwrap_or(Value::Null), "is_error": b.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false), "content": c }) + } + "image" => { + let oversized = b.get("source").and_then(|s| s.get("data")).and_then(|d| d.as_str()).map(|d| d.len() > 600000).unwrap_or(false); + if oversized { + json!({ "type": "image", "source": { "media_type": b.get("source").and_then(|s| s.get("media_type")).and_then(|m| m.as_str()).unwrap_or("image/png"), "oversized": true } }) + } else { + b.clone() + } + } + _ => b.clone(), + } + }) + .collect(); + Value::Array(mapped) +} diff --git a/src-tauri/src/exporthtml/session.rs b/src-tauri/src/exporthtml/session.rs new file mode 100644 index 0000000..dde3666 --- /dev/null +++ b/src-tauri/src/exporthtml/session.rs @@ -0,0 +1,146 @@ +// Whole-session shaping (messages + totals + timestamps) and the subagent transcript scan. +// Moved verbatim from exporthtml.rs. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::parse::parse_jsonl; +use super::shape::line_to_msg; + +pub(super) struct Shaped { + pub(super) messages: Vec, + pub(super) model: Option, + pub(super) totals: (i64, i64, i64, i64), + pub(super) cache_creation: i64, + pub(super) credits: Option, + pub(super) token_usage_available: bool, + pub(super) first_ts: Option, + pub(super) last_ts: Option, +} +pub(super) fn shape_session(recs: &[Value]) -> Shaped { + let mut messages = vec![]; + let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; + let mut model = None; + let mut first_ts = None; + let mut last_ts = None; + for r in recs { + let lm = match line_to_msg(r) { + Some(m) => m, + None => continue, + }; + if lm.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + if let Some(ts) = lm.get("ts").and_then(|v| v.as_str()) { + if first_ts.is_none() { + first_ts = Some(ts.to_string()); + } + last_ts = Some(ts.to_string()); + } + if let Some(md) = lm.get("model").and_then(|v| v.as_str()) { + model = Some(md.to_string()); + } + if let Some(u) = lm.get("usage").filter(|u| u.is_object()) { + tin += u.get("in").and_then(|v| v.as_i64()).unwrap_or(0); + tout += u.get("out").and_then(|v| v.as_i64()).unwrap_or(0); + tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); + tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } + turns += 1; + } + messages.push(lm); + } + Shaped { + messages, + model, + totals: (tin, tout, tcr, turns), + cache_creation: tcc, + credits: has_credits.then_some(credits), + token_usage_available: !(has_credits && tin == 0 && tout == 0 && tcr == 0 && tcc == 0), + first_ts, + last_ts, + } +} + +pub(super) fn read_subagents(file: &Path) -> Value { + let qoder = crate::qoder::looks_qoder_path(file); + let dir = file.parent().map(|p| { + p.join(file.file_stem().and_then(|s| s.to_str()).unwrap_or("")) + .join("subagents") + }); + let dir = match dir { + Some(d) => d, + None => return json!({}), + }; + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return json!({}), + }; + let mut agent_names: Vec = entries + .flatten() + .map(|ent| ent.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("agent-") && name.ends_with(".jsonl")) + .collect(); + agent_names.sort(); + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch. + if qoder { + let mut warm: Vec = vec![]; + for name in &agent_names { + warm.push(dir.join(name)); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); + } + crate::qoder::prefetch(&warm); + } + let mut by_tool = serde_json::Map::new(); + for name in agent_names { + let agent_id = name + .trim_start_matches("agent-") + .trim_end_matches(".jsonl") + .to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let recs = parse_jsonl(&dir.join(&name)); + let shaped = shape_session(&recs); + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + by_tool.insert( + key, + json!({ + "agentId": agent_id, + "type": meta.get("agentType").or_else(|| meta.get("subagent_type")).and_then(|v| v.as_str()).unwrap_or("agent"), + "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "skill": crate::history::skill_from_recs(&recs), + "count": shaped.messages.len(), + "totals": { + "in": shaped.totals.0, + "out": shaped.totals.1, + "cacheRead": shaped.totals.2, + "cacheCreation": shaped.cache_creation, + "turns": shaped.totals.3, + "credits": shaped.credits, + "tokenUsageAvailable": shaped.token_usage_available, + }, + "messages": shaped.messages, + }), + ); + } + Value::Object(by_tool) +} diff --git a/src-tauri/src/exporthtml/shape.rs b/src-tauri/src/exporthtml/shape.rs new file mode 100644 index 0000000..4b4f397 --- /dev/null +++ b/src-tauri/src/exporthtml/shape.rs @@ -0,0 +1,102 @@ +// One transcript line → one viewer message, plus the small text helpers the title and the +// filename derive from. Moved verbatim from exporthtml.rs. + +use serde_json::{json, Value}; + +use super::parse::{cap_content, usage_of}; + +pub(super) fn line_to_msg(rec: &Value) -> Option { + let ty = rec.get("type").and_then(|v| v.as_str())?; + if ty != "user" && ty != "assistant" { + return None; + } + let m = rec.get("message")?; + m.get("role").and_then(|v| v.as_str())?; + let mut out = json!({ + "role": m.get("role").cloned().unwrap_or(Value::Null), + "content": cap_content(m.get("content").unwrap_or(&Value::Null)), + "ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), + "meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), + }); + if ty == "assistant" { + let o = out.as_object_mut().unwrap(); + o.insert( + "model".into(), + m.get("model").cloned().unwrap_or(Value::Null), + ); + o.insert( + "usage".into(), + m.get("usage").map(usage_of).unwrap_or(Value::Null), + ); + o.insert( + "stop".into(), + m.get("stop_reason").cloned().unwrap_or(Value::Null), + ); + } + Some(out) +} + +fn content_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(arr) = content.as_array() { + return arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" "); + } + String::new() +} +fn command_label(raw: &str) -> String { + let name = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(n, _)| n.trim().to_string()) + .unwrap_or_default(); + if name.is_empty() { + return String::new(); + } + let args = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(a, _)| a.trim().to_string()) + .unwrap_or_default(); + format!("{} {}", name, args).trim().to_string() +} +pub(super) fn first_user_text(messages: &[Value]) -> String { + let mut fallback = String::new(); + for m in messages { + if m.get("role").and_then(|r| r.as_str()) != Some("user") + || m.get("meta").and_then(|v| v.as_bool()).unwrap_or(false) + { + continue; + } + let raw = content_text(m.get("content").unwrap_or(&Value::Null)); + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + if raw.starts_with('<') { + if fallback.is_empty() { + fallback = command_label(raw); + } + continue; + } + let t: String = raw.split_whitespace().collect::>().join(" "); + if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { + continue; + } + return t.chars().take(100).collect(); + } + fallback.chars().take(100).collect() +} +pub(super) fn base_name(p: &str) -> String { + p.split('/') + .filter(|s| !s.is_empty()) + .last() + .unwrap_or(p) + .to_string() +} diff --git a/src-tauri/src/exporthtml/tests.rs b/src-tauri/src/exporthtml/tests.rs new file mode 100644 index 0000000..9680e63 --- /dev/null +++ b/src-tauri/src/exporthtml/tests.rs @@ -0,0 +1,129 @@ +use super::parse::parse_jsonl; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn qoder_fixture(name: &str) -> (PathBuf, PathBuf) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "ccbud-exporthtml-{name}-{}-{nonce}", + std::process::id() + )); + let file = root + .join(".qoder") + .join("projects") + .join("-work-project") + .join("session.jsonl"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + (root, file) +} + +pub(super) fn write_jsonl(path: &Path, records: &[Value]) { + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n"); + fs::write(path, format!("{raw}\n")).unwrap(); +} + +pub(super) fn streamed_assistant_records() -> Vec { + vec![ + json!({ + "type": "assistant", + "uuid": "wrap-1", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "", + "content": [{ "type": "thinking", "thinking": "plan" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-2", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "redacted_thinking", "data": "opaque" }], + "stop_reason": null, + }, + }), + json!({ + "type": "assistant", + "uuid": "wrap-3", + "message": { + "id": "msg-1", + "role": "assistant", + "model": "ultimate", + "content": [{ + "type": "tool_use", + "id": "tool-1", + "name": "Read", + "input": { "file_path": "/work/a" }, + }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, + "stop_reason": "tool_use", + }, + }), + ] +} + +#[test] +fn qoder_jsonl_normalizes_streamed_assistant_records() { + let (root, file) = qoder_fixture("normalize"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].get("uuid").and_then(Value::as_str), + Some("wrap-1") + ); + let message = records[0].get("message").unwrap(); + assert_eq!( + message.get("model").and_then(Value::as_str), + Some("ultimate") + ); + assert_eq!( + message.get("stop_reason").and_then(Value::as_str), + Some("tool_use") + ); + assert_eq!( + message + .get("usage") + .and_then(|usage| usage.get("input_tokens")) + .and_then(Value::as_i64), + Some(7) + ); + let content = message.get("content").and_then(Value::as_array).unwrap(); + assert_eq!(content.len(), 2); + assert_eq!( + content[0].get("type").and_then(Value::as_str), + Some("thinking") + ); + assert_eq!( + content[1].get("type").and_then(Value::as_str), + Some("tool_use") + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ordinary_jsonl_keeps_streamed_records_unchanged() { + let (root, _) = qoder_fixture("ordinary"); + let file = root.join("ordinary.jsonl"); + write_jsonl(&file, &streamed_assistant_records()); + + let records = parse_jsonl(&file); + assert_eq!(records.len(), 3); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/src-tauri/src/exporthtml/tests_more.rs b/src-tauri/src/exporthtml/tests_more.rs new file mode 100644 index 0000000..eb831f9 --- /dev/null +++ b/src-tauri/src/exporthtml/tests_more.rs @@ -0,0 +1,152 @@ +use super::build::build_data; +use super::tests::{qoder_fixture, write_jsonl}; +use serde_json::{json, Value}; +use std::fs; + +#[test] +fn qoder_export_uses_record_metadata_and_reads_subagent_meta() { + let (root, file) = qoder_fixture("metadata"); + write_jsonl( + &file, + &[ + json!({ + "type": "workspace-directories", + "sessionId": "session", + "directories": ["/work/project"], + }), + json!({ + "type": "runtime-config", + "sessionId": "session", + "model": "ultimate", + }), + json!({ + "type": "ai-title", + "sessionId": "session", + "aiTitle": "Inline Qoder title", + }), + json!({ + "type": "user", + "uuid": "user-1", + "timestamp": "2026-08-04T08:00:00Z", + "message": { "role": "user", "content": "User fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "timestamp": "2026-08-04T08:01:00Z", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "message-model", + "content": [{ "type": "text", "text": "Done" }], + "usage": { "input_tokens": 2, "output_tokens": 1 }, + "stop_reason": "end_turn", + }, + }), + ], + ); + fs::write( + file.parent().unwrap().join("session-session.json"), + r#"{"title":"stale companion title","working_dir":"/stale/path"}"#, + ) + .unwrap(); + + let subagent_dir = file.parent().unwrap().join("session").join("subagents"); + fs::create_dir_all(&subagent_dir).unwrap(); + write_jsonl( + &subagent_dir.join("agent-a.jsonl"), + &[ + json!({ + "type": "user", + "uuid": "sub-user", + "message": { "role": "user", "content": "Investigate" }, + }), + json!({ + "type": "assistant", + "uuid": "sub-assistant", + "message": { + "id": "sub-answer", + "role": "assistant", + "model": "ultimate", + "content": [{ "type": "text", "text": "Found it" }], + }, + }), + ], + ); + fs::write( + subagent_dir.join("agent-a.meta.json"), + r#"{"toolUseId":"tool-a","agentType":"Explore","description":"trace"}"#, + ) + .unwrap(); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!(meta.get("assistant").and_then(Value::as_str), Some("Qoder")); + assert_eq!( + meta.get("title").and_then(Value::as_str), + Some("Inline Qoder title") + ); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/work/project") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("project")); + assert_eq!(meta.get("model").and_then(Value::as_str), Some("ultimate")); + assert_eq!(meta.get("subagentCount").and_then(Value::as_u64), Some(1)); + let subagent = data + .get("subagents") + .and_then(|subagents| subagents.get("tool-a")) + .unwrap(); + assert_eq!( + subagent.get("type").and_then(Value::as_str), + Some("Explore") + ); + assert_eq!( + subagent.get("description").and_then(Value::as_str), + Some("trace") + ); + assert_eq!(subagent.get("count").and_then(Value::as_u64), Some(2)); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn qoder_export_falls_back_to_top_level_cwd_and_assistant_model() { + let (root, file) = qoder_fixture("metadata-fallback"); + write_jsonl( + &file, + &[ + json!({ + "type": "user", + "uuid": "user-1", + "sessionId": "session", + "cwd": "/legacy/work", + "message": { "role": "user", "content": "Fallback title" }, + }), + json!({ + "type": "assistant", + "uuid": "assistant-1", + "message": { + "id": "answer-1", + "role": "assistant", + "model": "legacy-model", + "content": [{ "type": "text", "text": "Done" }], + }, + }), + ], + ); + + let data = build_data(&file.to_string_lossy()); + let meta = data.get("meta").unwrap(); + assert_eq!( + meta.get("cwd").and_then(Value::as_str), + Some("/legacy/work") + ); + assert_eq!(meta.get("project").and_then(Value::as_str), Some("work")); + assert_eq!( + meta.get("model").and_then(Value::as_str), + Some("legacy-model") + ); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs deleted file mode 100644 index d201c78..0000000 --- a/src-tauri/src/gateway.rs +++ /dev/null @@ -1,3286 +0,0 @@ -// Gateway core. -// -// Implements deterministic model routing and the localhost reverse proxy: header sanitizing, -// upstream forwarding, 429 retry, SSE streaming with model rewrite + usage sniffing, buffered-JSON -// model rewrite, /v1/models merge/synthesize, count_tokens fallback, HEAD / fallback, and bounded -// monitor exchange capture. -#![allow(dead_code)] - -use axum::{ - body::{to_bytes, Body}, - extract::State, - http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}, - response::Response, - Router, -}; -use bytes::Bytes; -use futures_util::StreamExt; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use tauri::Emitter; -use tokio::sync::{oneshot, Mutex}; - -use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; -use crate::store; - -/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code). -pub const CLAUDE_TIER_MODELS: &[&str] = &[ - "claude-fable-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-haiku-4-5-20251001", -]; - -/// Stable Codex model identities advertised by the gateway. These names are understood by the -/// current Codex CLI and keep its ordinary function/custom tool registry enabled; synthetic -/// `gpt-5.6-sol*` identities select Codex's code-mode metadata and produce an empty Responses -/// `tools` array against a generic custom provider. -pub const CODEX_TIER_MODELS: &[&str] = &["gpt-5.4", "gpt-5.4-mini"]; - -/// Which coding-agent family a model name belongs to. Claude Code sends `claude-*`, -/// Codex sends `gpt-*`; each names its primary vs fast tier differently. -enum ModelFamily { - Claude, - Codex, - Other, -} -fn model_family(name: &str) -> ModelFamily { - let n = name.to_ascii_lowercase(); - if n.starts_with("claude-") || n.starts_with("claude_") { - ModelFamily::Claude - } else if n.starts_with("gpt-") || n.starts_with("gpt_") { - ModelFamily::Codex - } else { - ModelFamily::Other - } -} -/// Claude fast/light tier = the haiku models; fable/opus/sonnet (and any other -/// claude-*) route to the primary model. -fn is_claude_fast(name: &str) -> bool { - name.to_ascii_lowercase().contains("haiku") -} -/// The stable auto-connect identity and legacy `sol` / `terra` aliases route to primary. Explicit -/// small-model identities route to fast; other foreign `gpt-*` names retain the historical fast -/// fallback instead of unexpectedly consuming the primary provider model. -fn is_codex_primary(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - if lower == "gpt-5.4" { - return true; - } - let segments = lower - .split(|c| c == '-' || c == '_') - .collect::>(); - !segments - .iter() - .any(|seg| matches!(*seg, "mini" | "nano" | "luna" | "spark")) - && segments.iter().any(|seg| matches!(*seg, "sol" | "terra")) -} -/// True if the request comes from a Codex/OpenAI-family client (vs Claude), detected by -/// the client's self-reported identity — User-Agent, or Codex's `originator` header. -fn client_is_codex(h: &HeaderMap) -> bool { - let field = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); - field("user-agent").contains("codex") || field("originator").contains("codex") -} - -#[derive(Debug, Clone)] -pub struct Routing { - pub provider_id: String, - pub outgoing_model: Option, - pub client_facing_model: Option, -} - -// Claude Code rebuilds assistant tool_use history from its known fields and drops provider -// metadata, so Gemini's signature cannot round-trip through the Anthropic wire. Keep a bounded, -// session-scoped server-side copy and restore it before the next Google/OpenAI-compatible request. -const THOUGHT_SIGNATURE_TTL_MS: i64 = 6 * 60 * 60 * 1000; -const THOUGHT_SIGNATURE_CACHE_MAX: usize = 2048; -const GEMINI_SIGNATURE_FALLBACK: &str = "skip_thought_signature_validator"; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct CachedToolCall { - call_id: String, - name: String, - arguments: String, - signature: Option, -} - -#[derive(Clone, Debug)] -struct ThoughtSignatureBatch { - calls: Vec, - touched_at: i64, -} - -#[derive(Default)] -struct ThoughtSignatureCache { - batches: HashMap<(String, String), ThoughtSignatureBatch>, -} - -fn canonical_tool_arguments(arguments: &str) -> String { - if arguments.trim().is_empty() { - return "{}".to_string(); - } - serde_json::from_str::(arguments) - .map(|v| v.to_string()) - .unwrap_or_else(|_| arguments.to_string()) -} - -/// Codex records a model-emitted function call even when the host cannot parse its arguments, then -/// sends that failed call back on the next Responses turn beside the router error. OpenAI accepts -/// the arguments as an opaque string, but stricter chat providers (notably Gemini) parse every -/// historical `tool_calls[].function.arguments` value and reject the whole request when a model -/// appended prose or a second object. Preserve valid object arguments byte-for-byte so cached -/// thought signatures still match; otherwise salvage the first complete object, or wrap the raw -/// text in a valid object as a last resort. -fn provider_safe_history_tool_arguments(arguments: &str) -> Option { - let trimmed = arguments.trim(); - if trimmed.is_empty() { - return Some("{}".to_string()); - } - match serde_json::from_str::(arguments) { - Ok(Value::Object(_)) => return None, - Ok(value) => return Some(json!({ "_ccbuddy_value": value }).to_string()), - Err(_) => {} - } - if let Some(Ok(Value::Object(object))) = serde_json::Deserializer::from_str(trimmed) - .into_iter::() - .next() - { - return Some(Value::Object(object).to_string()); - } - Some(json!({ "_ccbuddy_raw_arguments": arguments }).to_string()) -} - -fn sanitize_provider_history_tool_arguments( - request: &mut llm_connector::types::ChatRequest, -) -> usize { - let mut repaired = 0usize; - for message in &mut request.messages { - let Some(calls) = message.tool_calls.as_mut() else { continue }; - for call in calls { - let Some(arguments) = provider_safe_history_tool_arguments(&call.function.arguments) - else { continue }; - call.function.arguments = arguments; - // A provider signature authenticates the exact call payload. Repaired arguments must - // use the documented synthetic-history fallback instead of a now-stale real signature. - call.thought_signature = None; - call.function.thought_signature = None; - repaired += 1; - } - } - repaired -} - -fn current_tool_turn_start(request: &llm_connector::types::ChatRequest) -> usize { - request.messages.iter() - .rposition(|message| message.role == llm_connector::types::Role::User) - .map(|index| index + 1) - .unwrap_or(0) -} - -impl ThoughtSignatureCache { - fn prune(&mut self, now: i64) { - self.batches.retain(|_, batch| { - now.saturating_sub(batch.touched_at) <= THOUGHT_SIGNATURE_TTL_MS - }); - } - - fn remember( - &mut self, - provider_id: &str, - session_id: Option<&str>, - captured_calls: &[crate::protocol::stream::CapturedToolCall], - ) { - let now = now_ms(); - self.prune(now); - let Some(session_id) = session_id else { return }; - let calls: Vec = captured_calls.iter().map(|call| CachedToolCall { - call_id: call.call_id.clone(), - name: call.name.clone(), - arguments: canonical_tool_arguments(&call.arguments), - signature: call.thought_signature.as_deref() - .filter(|signature| !signature.is_empty()) - .map(str::to_string), - }).collect(); - let key = (provider_id.to_string(), session_id.to_string()); - if !calls.iter().any(|call| call.signature.is_some()) { - if !calls.is_empty() { - self.batches.remove(&key); - } - return; - } - - if self.batches.len() >= THOUGHT_SIGNATURE_CACHE_MAX && !self.batches.contains_key(&key) { - if let Some(oldest) = self.batches.iter() - .min_by_key(|(_, batch)| batch.touched_at) - .map(|(key, _)| key.clone()) - { - self.batches.remove(&oldest); - } - } - // Replacing the latest batch also makes terminal/EOF observations idempotent. - self.batches.insert(key, ThoughtSignatureBatch { - calls, - touched_at: now, - }); - } - - fn restore( - &mut self, - provider_id: &str, - session_id: Option<&str>, - request: &mut llm_connector::types::ChatRequest, - ) -> usize { - let now = now_ms(); - self.prune(now); - let Some(session_id) = session_id else { return 0 }; - let current_turn = current_tool_turn_start(request); - let Some(message_index) = request.messages.iter() - .enumerate() - .skip(current_turn) - .rev() - .find_map(|(message_index, message)| { - message.tool_calls.as_ref() - .filter(|calls| !calls.is_empty()) - .map(|_| message_index) - }) - else { return 0 }; - let Some(calls) = request.messages[message_index].tool_calls.as_mut() else { return 0 }; - let key = (provider_id.to_string(), session_id.to_string()); - let Some(batch) = self.batches.get_mut(&key) else { return 0 }; - if batch.calls.len() != calls.len() - || !batch.calls.iter().zip(calls.iter()).all(|(cached, current)| { - cached.call_id == current.id - && cached.name == current.function.name - && cached.arguments == canonical_tool_arguments(¤t.function.arguments) - }) - { - return 0; - } - batch.touched_at = now; - let mut restored = 0usize; - for (call, cached) in calls.iter_mut().zip(&batch.calls) { - if crate::protocol::tool_call_thought_signature(call).is_none() { - if let Some(signature) = &cached.signature { - call.thought_signature = Some(signature.clone()); - restored += 1; - } - } - } - restored - } -} - -/// Google documents this sentinel for function-call history that did not originate from the -/// current API response (transferred/synthetic history). We use it only when Claude stripped the -/// real signature and the session cache cannot recover it. For parallel calls, only the first call -/// in a model step gets a signature, matching Gemini's validation contract. -fn apply_gemini_signature_fallback(request: &mut llm_connector::types::ChatRequest) -> usize { - let mut applied = 0usize; - // Gemini validates only the current turn: everything after the most recent ordinary user - // message. Tool results decode as Role::Tool, so sequential tool steps remain in this slice. - let current_turn = current_tool_turn_start(request); - for message in request.messages.iter_mut().skip(current_turn) { - let Some(calls) = message.tool_calls.as_mut() else { continue }; - if calls.is_empty() - || crate::protocol::tool_call_thought_signature(&calls[0]).is_some() - { - continue; - } - calls[0].thought_signature = Some(GEMINI_SIGNATURE_FALLBACK.to_string()); - applied += 1; - } - applied -} - -fn request_session_id(body: &Value) -> Option { - // Claude Code: metadata.user_id is a JSON string carrying session_id. - if let Some(raw) = body.pointer("/metadata/user_id").and_then(Value::as_str) { - if let Ok(metadata) = serde_json::from_str::(raw.trim()) { - if let Some(session) = metadata.get("session_id").and_then(Value::as_str) - .filter(|session| !session.is_empty()) - { - return Some(session.to_string()); - } - } - } - // Codex (Responses client): prompt_cache_key carries the conversation id. - body.get("prompt_cache_key").and_then(Value::as_str) - .map(str::trim) - .filter(|session| !session.is_empty()) - .map(str::to_string) -} - -fn codex_history_scope_for_session(request_session: Option<&str>) -> String { - request_session.unwrap_or("").to_string() -} - -fn response_tool_calls( - response: &llm_connector::types::ChatResponse, -) -> Vec { - response.choices.first().and_then(|choice| choice.message.tool_calls.as_ref()) - .map(|calls| calls.iter().map(|call| { - crate::protocol::stream::CapturedToolCall { - call_id: call.id.clone(), - name: call.function.name.clone(), - arguments: call.function.arguments.clone(), - thought_signature: crate::protocol::tool_call_thought_signature(call), - } - }).collect()) - .unwrap_or_default() -} - -fn response_tool_calls_with_client_ids( - response: &llm_connector::types::ChatResponse, - encoded_response: &Value, -) -> Vec { - let mut captured = response_tool_calls(response); - let client_ids = encoded_response - .get("output") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter(|item| { - matches!( - item.get("type").and_then(Value::as_str), - Some("function_call" | "custom_tool_call" | "tool_search_call") - ) - }) - .filter_map(|item| item.get("call_id").and_then(Value::as_str)); - for (call, client_id) in captured.iter_mut().zip(client_ids) { - call.call_id = client_id.to_string(); - } - captured -} - -/// Decide how to route a request and translate its model name. Mirrors proxy.js `resolveRouting`. -pub fn resolve_routing( - requested_model: Option<&str>, - config: &Value, - known_models: Option<&HashSet>, -) -> Option { - let providers = config.get("providers")?.as_array()?; - if providers.is_empty() { - return None; - } - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active = providers - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| providers.first())?; - let pid = active.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - - let pass = |m: &str| { - Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(m.to_string()), - client_facing_model: Some(m.to_string()), - }) - }; - - let requested = match requested_model { - None => { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: None, - client_facing_model: None, - }) - } - Some(m) => m, - }; - - let primary = active.get("defaultModel").and_then(|v| v.as_str()).unwrap_or(""); - let light = active.get("smallFastModel").and_then(|v| v.as_str()).unwrap_or(""); - let models = active.get("models").and_then(|v| v.as_array()); - - if let Some(ms) = models { - for m in ms { - let alias = m.get("alias").and_then(|v| v.as_str()).unwrap_or(""); - let upstream = m.get("upstream").and_then(|v| v.as_str()).unwrap_or(""); - if !alias.is_empty() && alias == requested && !upstream.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(upstream.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - } - } - if requested == primary || requested == light { - return pass(requested); - } - if let Some(ms) = models { - for m in ms { - if m.get("upstream").and_then(|v| v.as_str()) == Some(requested) { - return pass(requested); - } - } - } - if let Some(known) = known_models { - if known.contains(requested) { - return pass(requested); - } - } - // Codex connects with the sentinel model "gpt-5.5-ccbud" — a name Codex's model-family - // detection accepts (gpt-5.5 prefix), so it doesn't warn about an unknown model. Route the - // sentinel to the active provider's PRIMARY model (never the lightweight fallback). - if requested.ends_with("-ccbud") { - let target = if !primary.is_empty() { primary } else { light }; - if !target.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(target.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - } - let map_default = active - .get("mapDefaultModels") - .map(|v| v.as_bool().unwrap_or(true)) - .unwrap_or(true); - if !map_default { - return pass(requested); - } - let big = if !primary.is_empty() { primary } else { light }; - let small = if !light.is_empty() { light } else { primary }; - // Claude and Codex name their primary vs fast tiers differently, so classify by - // family: claude-haiku* → fast, other claude-* → primary; gpt-*-sol / gpt-*-terra - // → primary, other gpt-* → fast; anything else → fast. - let target = match model_family(requested) { - ModelFamily::Claude => if is_claude_fast(requested) { small } else { big }, - ModelFamily::Codex => if is_codex_primary(requested) { big } else { small }, - ModelFamily::Other => small, - }; - if !target.is_empty() { - return Some(Routing { - provider_id: pid.clone(), - outgoing_model: Some(target.to_string()), - client_facing_model: Some(requested.to_string()), - }); - } - pass(requested) -} - -// ---------------- gateway runtime ---------------- - -pub struct GatewayState { - app: tauri::AppHandle, - known: Mutex>>, - thought_signatures: Mutex, - codex_history: crate::protocol::codex_history::CodexHistoryStore, - seq: AtomicU64, - running: Mutex>, - // Sync mirror of the bound port (0 = stopped) for callers that can't await (tray refresh). - running_port: std::sync::atomic::AtomicU32, - exchanges: Mutex>, - client: reqwest::Client, - client_insecure: reqwest::Client, - // Ring buffer of recent gateway log lines (seq+ts stamped) so the settings Logs panel can - // backfill on open — mirrors main.js gatewayLogs (cap 80). std Mutex: log() is sync. - logs: std::sync::Mutex>, - log_seq: AtomicU64, -} -struct RunningServer { - port: u16, - shutdown: oneshot::Sender<()>, -} - -impl GatewayState { - pub fn new(app: tauri::AppHandle) -> Arc { - let client = reqwest::Client::builder() - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - let client_insecure = reqwest::Client::builder() - .danger_accept_invalid_certs(true) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Arc::new(Self { - app, - known: Mutex::new(HashMap::new()), - thought_signatures: Mutex::new(ThoughtSignatureCache::default()), - codex_history: crate::protocol::codex_history::CodexHistoryStore::default(), - seq: AtomicU64::new(0), - running: Mutex::new(None), - running_port: std::sync::atomic::AtomicU32::new(0), - exchanges: Mutex::new(VecDeque::new()), - client, - client_insecure, - logs: std::sync::Mutex::new(VecDeque::new()), - log_seq: AtomicU64::new(0), - }) - } - - pub fn log(&self, level: &str, msg: impl AsRef) { - let seq = self.log_seq.fetch_add(1, Ordering::Relaxed) + 1; - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let entry = json!({ "seq": seq, "ts": ts, "level": level, "msg": msg.as_ref() }); - if let Ok(mut buf) = self.logs.lock() { - buf.push_back(entry.clone()); - while buf.len() > 80 { - buf.pop_front(); - } - } - let _ = self.app.emit("gateway:log", entry); - } - - /// Snapshot of the recent-log ring, oldest→newest (logs_get backfill). - pub fn logs_snapshot(&self) -> Value { - self.logs - .lock() - .map(|b| Value::Array(b.iter().cloned().collect())) - .unwrap_or_else(|_| json!([])) - } - pub fn logs_clear(&self) { - if let Ok(mut b) = self.logs.lock() { - b.clear(); - } - } - - pub async fn status(&self) -> Value { - match self.running.lock().await.as_ref() { - Some(rs) => json!({ "running": true, "port": rs.port }), - None => json!({ "running": false, "port": Value::Null }), - } - } - - pub async fn current_port(&self) -> Option { - self.running.lock().await.as_ref().map(|r| r.port) - } - - /// Sync view of the running state (tray menu refresh runs on the main thread, no await). - pub fn port_sync(&self) -> Option { - match self.running_port.load(Ordering::Relaxed) { - 0 => None, - p => Some(p as u16), - } - } - - pub fn emit(&self, event: &str, payload: Value) { - let _ = self.app.emit(event, payload); - } - - pub async fn start(self: &Arc, port: u16) -> Result { - if let Some(rs) = self.running.lock().await.as_ref() { - return Ok(rs.port); - } - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) - .await - .map_err(|e| e.to_string())?; - let actual = listener.local_addr().map_err(|e| e.to_string())?.port(); - let (tx, rx) = oneshot::channel::<()>(); - let router = Router::new().fallback(handle).with_state(self.clone()); - tauri::async_runtime::spawn(async move { - let _ = axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = rx.await; - }) - .await; - }); - *self.running.lock().await = Some(RunningServer { port: actual, shutdown: tx }); - self.running_port.store(actual as u32, Ordering::Relaxed); - self.log("info", format!("gateway listening on http://127.0.0.1:{}", actual)); - let status = self.status().await; - let _ = self.app.emit("gateway:status", status); - Ok(actual) - } - - pub async fn stop(self: &Arc) { - self.running_port.store(0, Ordering::Relaxed); - let taken = self.running.lock().await.take(); - if let Some(rs) = taken { - let _ = rs.shutdown.send(()); - self.log("info", "gateway stopped"); - } - let status = self.status().await; - let _ = self.app.emit("gateway:status", status); - } - - fn next_id(&self) -> u64 { - self.seq.fetch_add(1, Ordering::Relaxed) + 1 - } - /// Bounded live-debugging capture: keep only the most recent exchanges (matches the monitor - /// stream's 100-row window so every visible row can open its detail). - pub async fn record_exchange(&self, ex: Value) { - let mut buf = self.exchanges.lock().await; - buf.push_back(ex); - while buf.len() > 100 { - buf.pop_front(); - } - } - pub async fn monitor_get(&self, id: i64) -> Value { - let buf = self.exchanges.lock().await; - buf.iter() - .rev() - .find(|e| e.get("id").and_then(|v| v.as_i64()) == Some(id)) - .cloned() - .unwrap_or(Value::Null) - } - pub async fn monitor_clear(&self) { - self.exchanges.lock().await.clear(); - } - pub async fn monitor_recent(&self) -> Value { - self.exchanges.lock().await.back().cloned().unwrap_or(Value::Null) - } - - fn emit_request(&self, id: u64, started: std::time::Instant, method: &Method, path: &str, provider: &str, routing: &Routing, status: u16, usage: Option<&UsageAcc>) { - let (it, ot, cr, cc) = usage - .map(|u| (u.input, u.output, u.cache_read, u.cache_creation)) - .unwrap_or((0, 0, 0, 0)); - let _ = self.app.emit( - "gateway:request", - json!({ - "id": id, - "method": method.as_str(), - "path": path, - "provider": provider, - "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, - "clientFacingModel": routing.client_facing_model, - "status": status, - "ms": started.elapsed().as_millis() as u64, - "inputTokens": it, "outputTokens": ot, "cacheRead": cr, "cacheCreation": cc, - }), - ); - } -} - -#[derive(Default, Clone)] -struct UsageAcc { - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, - saw: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesForwardMode { - Original, - Materialized, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ResponsesHistoryDecision { - forward: ResponsesForwardMode, - descendant_materializable: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesHistoryError { - Unavailable, -} - -#[derive(Clone)] -struct NativeResponsesHistoryContext { - scope: String, - request: Value, - provider_id: String, - materializable: bool, -} - -fn decide_responses_history( - provider_wire: crate::protocol::Wire, - provider_id: &str, - resolution: &HistoryResolution, -) -> Result { - if !resolution.had_previous_response_id { - return Ok(ResponsesHistoryDecision { - forward: if resolution.changed > 0 { - ResponsesForwardMode::Materialized - } else { - ResponsesForwardMode::Original - }, - descendant_materializable: true, - }); - } - - if provider_wire != crate::protocol::Wire::OpenAiResponses { - return (resolution.previous_found && resolution.previous_materialized) - .then_some(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - .ok_or(ResponsesHistoryError::Unavailable); - } - - if !resolution.previous_found { - // Restart compatibility: the selected native provider may still own this id even though - // the gateway cache does not. Keep the id intact, but do not make descendants portable. - return Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }); - } - - let same_native_owner = matches!( - resolution.previous_origin.as_ref(), - Some(ResponseOrigin::Native(owner)) if owner == provider_id - ); - if same_native_owner { - return Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: resolution.previous_materialized, - }); - } - - resolution - .previous_materialized - .then_some(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - .ok_or(ResponsesHistoryError::Unavailable) -} - -fn decide_responses_compact_history( - provider_id: &str, - resolution: &HistoryResolution, -) -> Result { - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - provider_id, - resolution, - ) - .map(|decision| decision.forward) -} - -fn request_body_with_model(request: &Value, outgoing_model: Option<&str>) -> Option { - let mut request = request.clone(); - if let (Some(object), Some(model)) = (request.as_object_mut(), outgoing_model) { - object.insert("model".to_string(), Value::String(model.to_string())); - } - serde_json::to_vec(&request).ok().map(Bytes::from) -} - -fn apply_responses_chat_request_controls(body: &mut Value, request: &Value) { - if let Some(parallel_tool_calls) = request - .get("parallel_tool_calls") - .and_then(Value::as_bool) - { - body["parallel_tool_calls"] = json!(parallel_tool_calls); - } -} - -/// Makes a streaming request visible in the monitor even when the client aborts mid-stream. -/// The row + exchange record are normally emitted at the END of the response generator; when the -/// client disconnects, axum simply drops the generator and that code never runs — the request -/// vanished from the request stream (Codex users interrupt turns constantly). The generator owns -/// this guard: `complete()` hands back the prepared exchange (bodies filled) for the normal path, -/// and Drop-without-complete emits the row + a record. -/// -/// The response capture buffers live IN the guard rather than in generator locals: a dropped -/// generator then still records whatever already streamed through. This matters beyond real -/// aborts — Responses clients (Codex) tear the connection down the moment the terminal -/// `response.completed` event arrives, before upstream EOF, which used to lose BOTH response -/// bodies on every transcoded turn. When the transcoder has already emitted its terminal event -/// (`finished`), that disconnect is the normal end of a turn and is not flagged `aborted`. -struct StreamAbortGuard { - armed: bool, - st: Arc, - id: u64, - started: std::time::Instant, - method: Method, - path: String, - provider: String, - routing: Routing, - status: u16, - ex: Value, - res_cap: String, - up_cap: Option, - finished: bool, - usage: Option, -} - -/// Raw upstream capture (pre-translation) for transcoded streams: status + headers are fixed at -/// guard construction, text accumulates as chunks arrive. -struct UpCapture { - status: u16, - headers: Value, - text: String, - total: usize, -} - -const RES_CAP_MAX: usize = 2 * 1024 * 1024; -const UP_CAP_MAX: usize = 1024 * 1024; - -impl StreamAbortGuard { - #[allow(clippy::too_many_arguments)] - fn new( - st: Arc, - id: u64, - started: std::time::Instant, - method: Method, - path: String, - provider: String, - routing: Routing, - status: u16, - ex: Value, - upstream: Option<(u16, Value)>, - ) -> Self { - let up_cap = upstream.map(|(status, headers)| UpCapture { status, headers, text: String::new(), total: 0 }); - Self { - armed: true, st, id, started, method, path, provider, routing, status, ex, - res_cap: String::new(), up_cap, finished: false, usage: None, - } - } - - /// Append to the client-facing response capture (the translated stream for transcoded pairs). - fn push_res(&mut self, s: &str) { - if self.res_cap.len() < RES_CAP_MAX { - self.res_cap.push_str(s); - } - } - - /// Append raw upstream bytes (pre-translation) when this guard tracks an upstream capture. - fn push_up(&mut self, raw: &str) { - if let Some(u) = self.up_cap.as_mut() { - u.total += raw.len(); - if u.text.len() < UP_CAP_MAX { - u.text.push_str(raw); - } - } - } - - /// Write the captured bodies into the exchange skeleton — shared by normal and abort paths. - fn fill_bodies(&mut self) { - self.ex["resBody"] = json!({ "text": self.res_cap, "bytes": self.res_cap.len(), "truncated": 0 }); - if let Some(u) = self.up_cap.as_ref() { - self.ex["upstreamRes"] = json!({ "status": u.status, "headers": u.headers, - "body": { "text": u.text, "bytes": u.total, "truncated": u.total.saturating_sub(u.text.len()) } }); - } - } - - /// Normal completion: disarm and hand the exchange (bodies filled) back to the caller (who - /// fills in ms / usage and records it). - fn complete(&mut self) -> Value { - self.armed = false; - self.fill_bodies(); - std::mem::take(&mut self.ex) - } -} - -impl Drop for StreamAbortGuard { - fn drop(&mut self) { - if !self.armed { - return; - } - self.fill_bodies(); - let mut ex = std::mem::take(&mut self.ex); - ex["ms"] = json!(self.started.elapsed().as_millis() as u64); - // A disconnect after the transcoder's terminal event is the normal end of a Responses - // turn — only flag genuinely interrupted streams. - if !self.finished { - ex["aborted"] = json!(true); - } - self.st.emit_request(self.id, self.started, &self.method, &self.path, &self.provider, &self.routing, self.status, self.usage.as_ref()); - let st = self.st.clone(); - // record_exchange is async and Drop is sync — spawn it, tolerating an already-torn-down - // runtime at app quit. - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - tauri::async_runtime::spawn(async move { st.record_exchange(ex).await }); - })); - } -} - -fn retry_delay(retry_after: Option<&str>, attempt: i64, base: i64) -> u64 { - let cap = 30_000u64; - if let Some(ra) = retry_after { - let s = ra.trim(); - if let Ok(n) = s.parse::() { - return (n.saturating_mul(1000)).min(cap); - } - // HTTP-date form (RFC 7231 IMF-fixdate) — honor the absolute time the upstream named - // (proxy.js parity). chrono is already a dep, so no extra crate is pulled in for this. - if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%a, %d %b %Y %H:%M:%S GMT") { - let ms = (dt.and_utc() - chrono::Utc::now()).num_milliseconds().max(0) as u64; - return ms.min(cap); - } - } - let base = if base > 0 { base as u64 } else { 500 }; - base.saturating_mul(2u64.saturating_pow(attempt.clamp(0, 20) as u32)) - .min(8000) -} - -fn model_rewrite_re() -> &'static regex::Regex { - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - RE.get_or_init(|| regex::Regex::new(r#"("model"\s*:\s*")[^"]*(")"#).unwrap()) -} - -fn absorb_usage_sse(obj: &Value, usage: &mut UsageAcc) { - match obj.get("type").and_then(|v| v.as_str()) { - Some("message_start") => { - if let Some(u) = obj.get("message").and_then(|m| m.get("usage")) { - usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.saw = true; - } - } - Some("message_delta") => { - if let Some(o) = obj.get("usage").and_then(|u| u.get("output_tokens")).and_then(|v| v.as_i64()) { - usage.output = o; - usage.saw = true; - } - } - _ => {} - } -} - -fn process_sse_line(line: &str, rewrite_model: Option<&str>, usage: &mut UsageAcc) -> String { - if line.contains("\"usage\"") { - if let Some(i) = line.find('{') { - if let Ok(obj) = serde_json::from_str::(line[i..].trim()) { - absorb_usage_sse(&obj, usage); - } - } - } - if let Some(m) = rewrite_model { - if line.contains("\"model\"") { - return model_rewrite_re() - .replace_all(line, |caps: ®ex::Captures| format!("{}{}{}", &caps[1], m, &caps[2])) - .into_owned(); - } - } - line.to_string() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponsesTerminalKind { - Completed, - Incomplete, - Failed, -} - -impl ResponsesTerminalKind { - fn is_resumable(self) -> bool { - matches!(self, Self::Completed | Self::Incomplete) - } -} - -#[derive(Debug, Clone)] -struct ResponsesTerminal { - kind: ResponsesTerminalKind, - response: Option, -} - -fn responses_terminal_event(sse: &str) -> Option { - sse.lines().rev().find_map(|line| { - let payload = line.trim().strip_prefix("data:")?.trim(); - let event: Value = serde_json::from_str(payload).ok()?; - let kind = match event.get("type").and_then(Value::as_str)? { - "response.completed" => ResponsesTerminalKind::Completed, - "response.incomplete" => ResponsesTerminalKind::Incomplete, - "response.failed" => ResponsesTerminalKind::Failed, - _ => return None, - }; - Some(ResponsesTerminal { - kind, - response: event.get("response").cloned(), - }) - }) -} - -fn responses_terminal_object(response: &Value) -> Option { - let kind = match response.get("status").and_then(Value::as_str)? { - "completed" => ResponsesTerminalKind::Completed, - "incomplete" => ResponsesTerminalKind::Incomplete, - "failed" => ResponsesTerminalKind::Failed, - _ => return None, - }; - Some(ResponsesTerminal { - kind, - response: Some(response.clone()), - }) -} - -fn is_responses_compact_path(path: &str) -> bool { - matches!( - path.trim_end_matches('/'), - "/responses/compact" | "/v1/responses/compact" - ) -} - -fn build_target(base_url: &str, uri: &Uri) -> Option { - if base_url.is_empty() { - return None; - } - let base = base_url.trim_end_matches('/'); - let path = uri.path(); - let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); - // If the provider baseUrl already carries a path prefix (e.g. ".../v1") and the - // inbound path repeats it (e.g. "/v1/responses"), collapse the overlap so we don't - // forward to ".../v1/v1/responses". This is what bites an openai-* provider whose - // baseUrl ends in /v1 (incl. the sidecar plugins) on same-protocol passthrough. - // Segment-aware so a "/v1" base won't eat a "/v1beta" path. - let base_path = base_url_path(base).trim_end_matches('/'); - let path_out: &str = if base_path.is_empty() || base_path == "/" { - path - } else if path == base_path { - "" - } else { - match path.strip_prefix(base_path) { - Some(rest) if rest.starts_with('/') => rest, - _ => path, - } - }; - Some(format!("{}{}{}", base, path_out, query)) -} - -/// Resolve one of the three primary API endpoints against the configured base URL. The base is -/// authoritative: an inbound `/v1/...` path does not cause ccbud to insert `/v1` upstream. -fn endpoint_targets(base_url: &str, uri: &Uri) -> Option<(String, Option)> { - if base_url.trim().is_empty() { - return None; - } - let wire = crate::protocol::Wire::from_request_endpoint(uri.path())?; - let with_query = |mut url: String| { - if let Some(query) = uri.query() { - url.push('?'); - url.push_str(query); - } - url - }; - Some(( - with_query(wire.upstream_url_for_request(base_url, uri.path())), - wire.v1_fallback_url_for_request(base_url, uri.path()).map(with_query), - )) -} - -/// Standalone Responses compaction returns a distinct `response.compaction` object whose output -/// is the canonical replacement context window. Chat and Anthropic upstreams cannot provide that -/// contract through the ordinary response transcoder, so fail explicitly instead of turning a -/// compact request into an unrelated model turn. Responses providers keep the passthrough path. -fn cross_wire_compact_error(path: &str, provider_wire: crate::protocol::Wire) -> Option { - (is_responses_compact_path(path) - && provider_wire != crate::protocol::Wire::OpenAiResponses) - .then(|| { - error_response( - StatusCode::NOT_IMPLEMENTED, - "CC Buddy: /v1/responses/compact requires an openai-responses provider; cross-protocol compaction is not supported", - "invalid_request_error", - ) - }) -} - -/// The path component of a base URL (everything after scheme://authority), or "". -fn base_url_path(base: &str) -> &str { - let after_scheme = base.split_once("://").map(|(_, rest)| rest).unwrap_or(base); - match after_scheme.find('/') { - Some(i) => &after_scheme[i..], - None => "", - } -} - -fn error_response(status: StatusCode, msg: &str, etype: &str) -> Response { - json_response(status, &json!({ "type": "error", "error": { "type": etype, "message": msg } })) -} -fn json_response(status: StatusCode, body: &Value) -> Response { - let bytes = serde_json::to_vec(body).unwrap_or_default(); - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Body::from(bytes)) - .unwrap() -} - -// ---- /v1/models augmentation ---- -fn model_entry(id: &str) -> Value { - json!({ "type": "model", "id": id, "display_name": id, "created_at": "2025-01-01T00:00:00Z" }) -} -fn alias_entries(config: &Value) -> Vec { - let mut out = vec![]; - let mut seen = HashSet::new(); - if let Some(ps) = config.get("providers").and_then(|v| v.as_array()) { - for p in ps { - if let Some(ms) = p.get("models").and_then(|v| v.as_array()) { - for m in ms { - if let Some(a) = m.get("alias").and_then(|v| v.as_str()) { - if !a.is_empty() && seen.insert(a.to_string()) { - out.push(model_entry(a)); - } - } - } - } - } - } - out -} -/// Default tier models for the requesting client's family (Codex → gpt tiers, -/// Claude → claude tiers). -fn tier_entries(is_codex: bool) -> Vec { - if is_codex { - CODEX_TIER_MODELS.iter().map(|n| model_entry(n)).collect() - } else { - CLAUDE_TIER_MODELS.iter().map(|n| model_entry(n)).collect() - } -} -fn merge_models(upstream: &Value, config: &Value, is_codex: bool) -> Value { - let data = upstream.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default(); - let mut have: HashSet = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - let mut adds = vec![]; - for a in alias_entries(config).into_iter().chain(tier_entries(is_codex)) { - let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if have.insert(id) { - adds.push(a); - } - } - let mut merged = upstream.clone(); - adds.extend(data); - merged["data"] = json!(adds); - merged -} -fn synthesize_models(config: &Value, is_codex: bool) -> Value { - let mut out = alias_entries(config); - if out.is_empty() { - let ps = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active = ps - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| ps.first()); - let mut seen = HashSet::new(); - if let Some(a) = active { - for k in ["defaultModel", "smallFastModel"] { - if let Some(id) = a.get(k).and_then(|v| v.as_str()) { - if !id.is_empty() && seen.insert(id.to_string()) { - out.push(model_entry(id)); - } - } - } - } - } - let mut have: HashSet = out - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - for e in tier_entries(is_codex) { - let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if have.insert(id) { - out.push(e); - } - } - let first = out.first().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); - let last = out.last().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); - json!({ "data": out, "has_more": false, "first_id": first, "last_id": last }) -} - -fn redact_value(key: &str, val: &str) -> String { - let k = key.to_ascii_lowercase(); - if matches!(k.as_str(), "authorization" | "x-api-key" | "cookie" | "set-cookie" | "proxy-authorization" | "x-goog-api-key") { - "••••••(已隐藏)".to_string() - } else { - val.to_string() - } -} -fn redact_headers(h: &HeaderMap) -> Value { - let mut o = serde_json::Map::new(); - for (k, v) in h.iter() { - o.insert(k.as_str().to_string(), Value::String(redact_value(k.as_str(), v.to_str().unwrap_or("")))); - } - Value::Object(o) -} -fn vec_headers(pairs: &[(String, String)]) -> Value { - let mut o = serde_json::Map::new(); - for (k, v) in pairs { - o.insert(k.clone(), Value::String(redact_value(k, v))); - } - Value::Object(o) -} -fn cap_text(bytes: &[u8], cap: usize) -> Value { - let total = bytes.len(); - let end = total.min(cap); - json!({ "text": String::from_utf8_lossy(&bytes[..end]), "bytes": total, "truncated": total.saturating_sub(cap) }) -} - -fn now_ms() -> i64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) -} - -const HOP_BY_HOP_REQ: &[&str] = &[ - "host", "content-length", "authorization", "x-api-key", "accept-encoding", "cookie", - "proxy-authorization", "connection", "proxy-connection", "transfer-encoding", -]; -const HOP_BY_HOP_RES: &[&str] = &[ - "content-length", "transfer-encoding", "content-encoding", "connection", "keep-alive", - "proxy-authenticate", "proxy-connection", "set-cookie", -]; - -/// The localhost reverse-proxy handler. Mirrors proxy.js `handle`. -async fn handle(State(st): State>, req: axum::extract::Request) -> Response { - let started = std::time::Instant::now(); - let (parts, body) = req.into_parts(); - let method = parts.method; - let uri = parts.uri; - let in_headers = parts.headers; - let req_path = uri.path().to_string(); - let body_bytes = to_bytes(body, 64 * 1024 * 1024).await.unwrap_or_default(); - - let config = store::read_config(); - - // Optional local gateway token (defense in depth; already bound to localhost). - if config.get("requireToken").and_then(|v| v.as_bool()).unwrap_or(false) { - let token = config.get("gatewayToken").and_then(|v| v.as_str()).unwrap_or(""); - if !token.is_empty() { - let auth = in_headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); - let bearer = auth - .strip_prefix("Bearer ") - .or_else(|| auth.strip_prefix("bearer ")); - let presented = bearer.unwrap_or_else(|| { - in_headers.get("x-api-key").and_then(|v| v.to_str().ok()).unwrap_or("") - }); - if presented != token { - return error_response(StatusCode::UNAUTHORIZED, "CC Buddy: invalid gateway token", "authentication_error"); - } - } - } - - let is_json = in_headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|s| s.contains("application/json")) - .unwrap_or(false); - let mut parsed: Option = None; - let mut requested_model: Option = None; - if !body_bytes.is_empty() && is_json { - if let Ok(v) = serde_json::from_slice::(&body_bytes) { - requested_model = v.get("model").and_then(|m| m.as_str()).map(|s| s.to_string()); - parsed = Some(v); - } - } - - let providers = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); - let active_pid = providers - .iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) - .or_else(|| providers.first()) - .and_then(|p| p.get("id").and_then(|v| v.as_str())) - .map(|s| s.to_string()); - let known = match &active_pid { - Some(pid) => st.known.lock().await.get(pid).cloned(), - None => None, - }; - - let routing = match resolve_routing(requested_model.as_deref(), &config, known.as_ref()) { - Some(r) => r, - None => { - st.log("warn", "request rejected: no provider configured"); - return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured. Add one in the app.", "api_error"); - } - }; - let provider = match providers.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(routing.provider_id.as_str())) { - Some(p) => p, - None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured.", "api_error"), - }; - let base_url = provider.get("baseUrl").and_then(|v| v.as_str()).unwrap_or(""); - let auth_token = provider.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let provider_name = provider - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(routing.provider_id.as_str()) - .to_string(); - - let need_rewrite = match (routing.client_facing_model.as_ref(), routing.outgoing_model.as_ref()) { - (Some(c), Some(o)) => c != o, - _ => false, - }; - let mut out_body = body_bytes.clone(); - if let (Some(p0), Some(out_model)) = (parsed.as_ref(), routing.outgoing_model.as_ref()) { - if Some(out_model) != requested_model.as_ref() { - let mut p = p0.clone(); - p["model"] = json!(out_model); - if let Ok(b) = serde_json::to_vec(&p) { - out_body = Bytes::from(b); - } - } - } - - let endpoint_pair = if method == Method::POST { - endpoint_targets(base_url, &uri) - } else { - None - }; - let (mut target, mut v1_fallback_target) = match endpoint_pair { - Some(pair) => pair, - None => match build_target(base_url, &uri) { - Some(t) => (t, None), - None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: invalid provider baseUrl", "api_error"), - }, - }; - let is_models_list = method == Method::GET && (req_path.ends_with("/v1/models") || req_path.ends_with("/v1/models/")); - // Codex and Claude clients both GET /v1/models — tell them apart by client identity - // so each gets its own family's default model list. - let client_codex = client_is_codex(&in_headers); - let is_head_root = method == Method::HEAD && req_path == "/"; - let is_count_tokens = method == Method::POST - && (req_path.ends_with("/v1/messages/count_tokens") || req_path.ends_with("/v1/messages/count_tokens/")); - - // ---- protocol translation ---- - // When the client's wire protocol (inferred from the request path) differs from the provider's - // declared protocol, translate the request into the provider's format and remember to translate - // the response back. Same-protocol requests skip this entirely and keep the verbatim passthrough - // fast path below (so Anthropic→Anthropic behavior is byte-for-byte unchanged). Streaming pairs - // with an incremental transcoder (see protocol::stream::Transcoder) stream token-by-token; the - // rest force the upstream buffered (stream=false) and synthesize the client SSE from the full - // response. - let client_wire = crate::protocol::Wire::from_request_path(&uri); - let provider_wire = - crate::protocol::Wire::from_provider(provider.get("protocol").and_then(|v| v.as_str())); - let is_responses_compact = method == Method::POST && is_responses_compact_path(&req_path); - if method == Method::POST { - if let Some(response) = cross_wire_compact_error(&req_path, provider_wire) { - return response; - } - } - let request_session = parsed.as_ref().and_then(request_session_id); - // Conversation history belongs to the client session, not the provider: users may switch the - // active provider mid-turn and previous_response_id must still restore the same transcript. - // Sessionless requests can use direct response-id lookup, but never call-id fallback because - // call ids are routinely reused across unrelated agent runs. - let codex_history_scope = codex_history_scope_for_session(request_session.as_deref()); - let allow_codex_call_fallback = request_session.is_some(); - let mut prepared_responses_request: Option = None; - let mut native_responses_history: Option = None; - let mut history_localized = false; - if is_responses_compact { - if let Some(request) = parsed.as_ref() { - let previous_response_id = request - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let mut materialized_request = request.clone(); - let resolution = st - .codex_history - .materialize_request_scoped( - &codex_history_scope, - allow_codex_call_fallback, - &mut materialized_request, - ) - .await; - let forward = match decide_responses_compact_history( - &routing.provider_id, - &resolution, - ) { - Ok(forward) => forward, - Err(ResponsesHistoryError::Unavailable) => { - return error_response( - StatusCode::BAD_REQUEST, - &format!( - "CC Buddy cannot compact previous_response_id '{}' with provider '{}': its complete context cannot be materialized; retry with the owning Responses provider", - previous_response_id.as_deref().unwrap_or(""), - provider_name - ), - "invalid_request_error", - ); - } - }; - if forward == ResponsesForwardMode::Materialized { - history_localized = true; - let Some(body) = request_body_with_model( - &materialized_request, - routing.outgoing_model.as_deref(), - ) else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "CC Buddy failed to serialize locally materialized compact history", - "api_error", - ); - }; - out_body = body; - } - } - } else if client_wire == crate::protocol::Wire::OpenAiResponses && method == Method::POST { - if let Some(request) = parsed.as_ref() { - let previous_response_id = request - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let mut materialized_request = request.clone(); - let resolution = st - .codex_history - .materialize_request_scoped( - &codex_history_scope, - allow_codex_call_fallback, - &mut materialized_request, - ) - .await; - let decision = match decide_responses_history( - provider_wire, - &routing.provider_id, - &resolution, - ) { - Ok(decision) => decision, - Err(ResponsesHistoryError::Unavailable) => { - let detail = if resolution.previous_found { - "is known locally but its complete context cannot be materialized" - } else { - "is not available in local history" - }; - return error_response( - StatusCode::BAD_REQUEST, - &format!( - "CC Buddy cannot continue previous_response_id '{}' through provider '{}': it {}; retry with the owning Responses provider or start a new conversation", - previous_response_id.as_deref().unwrap_or(""), - provider_name, - detail - ), - "invalid_request_error", - ); - } - }; - if decision.forward == ResponsesForwardMode::Materialized { - history_localized = true; - if provider_wire == crate::protocol::Wire::OpenAiResponses { - let Some(body) = request_body_with_model( - &materialized_request, - routing.outgoing_model.as_deref(), - ) else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "CC Buddy failed to serialize locally materialized Responses history", - "api_error", - ); - }; - out_body = body; - } - } - if provider_wire == crate::protocol::Wire::OpenAiResponses { - native_responses_history = Some(NativeResponsesHistoryContext { - scope: codex_history_scope.clone(), - request: materialized_request.clone(), - provider_id: routing.provider_id.clone(), - materializable: decision.descendant_materializable, - }); - } - prepared_responses_request = Some(materialized_request); - } - } - let is_gemini_upstream = provider_wire == crate::protocol::Wire::OpenAiChat - && routing.outgoing_model.as_deref().unwrap_or("").to_ascii_lowercase().contains("gemini"); - // translate ctx: (client wire, provider wire, client model, wanted stream, incremental, - // request-scoped Responses tool metadata, full translated client request for history, - // client-session history scope) - // `incremental` = we can transcode the upstream stream event-by-event to the client (true - // token-by-token). Otherwise we force the upstream buffered and synthesize the client response. - let mut translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, - crate::protocol::openai_responses::CodexToolContext, - Value, - String)> = None; - if client_wire != provider_wire && method == Method::POST && !is_models_list && !is_count_tokens { - if let Some(p) = parsed.as_ref() { - let wanted_stream = p.get("stream").and_then(|v| v.as_bool()).unwrap_or(false); - let incremental = wanted_stream - && crate::protocol::can_transcode_stream(provider_wire, client_wire); - let client_model = routing.client_facing_model.clone().unwrap_or_default(); - let outgoing = routing.outgoing_model.clone().unwrap_or_default(); - let request_for_translation = prepared_responses_request - .clone() - .unwrap_or_else(|| p.clone()); - let decoded = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::decode_request_with_context( - &request_for_translation, - ) - } else { - crate::protocol::decode_client_request(client_wire, &request_for_translation).map( - |request| { - ( - request, - crate::protocol::openai_responses::CodexToolContext::default(), - ) - }, - ) - }; - let (mut ir, tool_context) = match decoded { - Ok(decoded) => decoded, - Err(e) => { - st.log( - "warn", - format!("client protocol decode ({:?}) failed: {}", client_wire, e), - ); - return error_response( - StatusCode::BAD_REQUEST, - &format!("CC Buddy invalid client request: {}", e), - "invalid_request_error", - ); - } - }; - // Neither the Anthropic nor the Responses client wire round-trips Gemini's thought - // signature, so every translated client (Claude Code AND Codex) needs the session-cache - // restore + documented fallback sentinel — Gemini 3 rejects current-turn function calls - // without a signature (400). - if is_gemini_upstream { - st.thought_signatures.lock().await.restore( - &routing.provider_id, - request_session.as_deref(), - &mut ir, - ); - } - // Repair after signature restoration so any call whose provider-visible payload changes - // cannot accidentally regain a cached signature that authenticated different bytes. - if provider_wire == crate::protocol::Wire::OpenAiChat { - sanitize_provider_history_tool_arguments(&mut ir); - } - if is_gemini_upstream { - apply_gemini_signature_fallback(&mut ir); - } - let translated_body = crate::protocol::encode_upstream_request(provider_wire, &ir, &outgoing, incremental); - match translated_body { - Ok(mut body) => { - if client_wire == crate::protocol::Wire::OpenAiResponses - && provider_wire == crate::protocol::Wire::OpenAiChat - { - apply_responses_chat_request_controls(&mut body, &request_for_translation); - } - // Ask OpenAI-family upstreams to include usage in the final stream chunk. - if incremental && provider_wire == crate::protocol::Wire::OpenAiChat { - body["stream_options"] = json!({ "include_usage": true }); - } - if let Ok(b) = serde_json::to_vec(&body) { - out_body = Bytes::from(b); - } - // Send to the provider protocol's endpoint (drop the inbound path/query). - target = provider_wire.upstream_url(base_url); - v1_fallback_target = provider_wire.v1_fallback_url(base_url); - translate = Some((client_wire, provider_wire, client_model, wanted_stream, incremental, - tool_context, request_for_translation, codex_history_scope.clone())); - } - Err(e) => { - st.log("error", format!("protocol translate ({:?}→{:?}) failed: {}", client_wire, provider_wire, e)); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy protocol translation failed: {}", e), "api_error"); - } - } - } - } - - // upstream headers (sanitized + provider token swapped in) - let mut up_headers = HeaderMap::new(); - for (k, v) in in_headers.iter() { - let kn = k.as_str().to_ascii_lowercase(); - if HOP_BY_HOP_REQ.contains(&kn.as_str()) { - continue; - } - up_headers.insert(k.clone(), v.clone()); - } - up_headers.insert(axum::http::header::ACCEPT_ENCODING, HeaderValue::from_static("identity")); - // A translated Anthropic upstream needs the anthropic-version header; OpenAI-family clients - // (Codex) never send one. - if translate.as_ref().map(|t| t.1) == Some(crate::protocol::Wire::Anthropic) - && !up_headers.contains_key("anthropic-version") - { - up_headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); - } - if !auth_token.is_empty() { - // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips - // providers that reject having the two auth headers present at once (matches provider_test). - // Both inbound auth headers are already stripped by HOP_BY_HOP_REQ above. - if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", auth_token)) { - up_headers.insert(axum::http::header::AUTHORIZATION, val); - } - } - - let ex_id = st.next_id(); - let ex_req_headers = redact_headers(&up_headers); - let ex_req_body = cap_text(&out_body, 4 * 1024 * 1024); - let original_target = target.clone(); - let mut ex_url = target.clone(); - // Client-side view of the exchange — what the gateway RECEIVED, before any translation — so - // the monitor can show a protocol translation's exact before/after (inbound URL/headers/body - // vs. the upstream URL/headers/body above). The body is duplicated only when a translation - // applies; for passthrough, reqBody already IS the client body (modulo the model rewrite). - let ex_translated = translate.as_ref().map(|t| format!("{} → {}", t.0.label(), t.1.label())); - let ex_client_req = { - let mut o = json!({ - "url": uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| req_path.clone()), - "headers": redact_headers(&in_headers), - }); - if ex_translated.is_some() || history_localized { - o["body"] = cap_text(&body_bytes, 1024 * 1024); - } - o - }; - - let insecure = config.get("insecureSkipVerify").and_then(|v| v.as_bool()).unwrap_or(false) - && target.starts_with("https:"); - let client = if insecure { &st.client_insecure } else { &st.client }; - - let rc = config.get("retry429").cloned().unwrap_or(json!({})); - let retry_enabled = rc.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let retry_max = rc.get("max").and_then(|v| v.as_i64()).unwrap_or(3); - let retry_base = rc.get("baseMs").and_then(|v| v.as_i64()).unwrap_or(500); - - // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first - // response is retained until the fallback succeeds, so a failed fallback never masks the - // upstream's original error. - let mut attempt = 0i64; - let mut tried_v1_fallback = false; - let mut used_v1_fallback = false; - let mut first_path_error: Option = None; - let resp = loop { - let r = client - .request(method.clone(), &target) - .headers(up_headers.clone()) - .body(out_body.clone()) - .send() - .await; - match r { - Ok(resp) => { - if !tried_v1_fallback - && retry_enabled - && resp.status().as_u16() == 429 - && attempt < retry_max - { - let ra = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); - let delay = retry_delay(ra.as_deref(), attempt, retry_base); - st.log("warn", format!("upstream 429 — retry {}/{} in {}ms ({})", attempt + 1, retry_max, delay, provider_name)); - tokio::time::sleep(std::time::Duration::from_millis(delay)).await; - attempt += 1; - continue; - } - if !tried_v1_fallback - && crate::protocol::should_try_v1_fallback(resp.status().as_u16()) - { - if let Some(fallback) = v1_fallback_target.take() { - first_path_error = Some(resp); - target = fallback; - ex_url = target.clone(); - tried_v1_fallback = true; - attempt = 0; - continue; - } - } - if tried_v1_fallback { - if resp.status().is_success() { - used_v1_fallback = true; - ex_url = target.clone(); - break resp; - } - ex_url = original_target.clone(); - break first_path_error.take().expect("v1 fallback keeps the first response"); - } - break resp; - } - Err(e) => { - if tried_v1_fallback { - if let Some(first) = first_path_error.take() { - ex_url = original_target.clone(); - st.log("info", format!("/v1 compatibility retry failed: {} ({})", e, provider_name)); - break first; - } - } - if is_models_list { - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return json_response(StatusCode::OK, &synthesize_models(&config, client_codex)); - } - if is_count_tokens { - let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return Response::builder() - .status(200) - .header("content-type", "application/json") - .header("x-ccbud-tokens", "estimated") - .header("x-ccbud-upstream-status", "error") - .body(Body::from(serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default())) - .unwrap(); - } - st.log("error", format!("upstream error: {}", e)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy upstream error: {}", e), "api_error"); - } - } - }; - - if used_v1_fallback { - if let Some(saved) = store::migrate_provider_base_url_to_v1(&routing.provider_id, base_url) { - st.log("info", format!("provider base URL updated with /v1 ({})", provider_name)); - st.emit("config:changed", saved); - } - } - - let status = resp.status(); - let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); - - if is_head_root && status.as_u16() == 404 { - st.log("info", format!("HEAD / fallback: upstream 404 → gateway 200 ({})", provider_name)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "x-ccbud-fallback": "head-root-404-to-200", "x-ccbud-upstream-status": "404" }), - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("x-ccbud-fallback", "head-root-404-to-200") - .header("x-ccbud-upstream-status", "404") - .body(Body::empty()) - .unwrap(); - } - - let mut out_headers: Vec<(String, String)> = vec![]; - for (k, v) in resp.headers().iter() { - let kn = k.as_str().to_ascii_lowercase(); - if HOP_BY_HOP_RES.contains(&kn.as_str()) { - continue; - } - if let Ok(s) = v.to_str() { - out_headers.push((k.as_str().to_string(), s.to_string())); - } - } - - // streaming SSE — rewrite model + sniff usage, line-buffered - if ct.contains("text/event-stream") { - // Incremental cross-protocol transcode: feed each upstream SSE line through a stateful - // transcoder that emits the client protocol's events as they arrive (true token-by-token). - if let Some((client_wire, provider_wire, mut tc, history_request, history_scope)) = translate - .as_ref() - .filter(|t| t.4) - .and_then(|t| { - // can_transcode_stream guarded `incremental`, so new() matches a wired pair. - crate::protocol::stream::Transcoder::new_with_context(t.1, t.0, &t.2, t.5.clone()).map(|tc| (t.0, t.1, tc, t.6.clone(), t.7.clone())) - }) - { - let st2 = st.clone(); - let signature_provider_id = routing.provider_id.clone(); - let signature_session = request_session.clone(); - // Any Gemini-backed transcoded stream (Claude Code or Codex client) feeds the - // signature cache; transcoders that don't track calls return an empty capture. - let capture_thought_signatures = is_gemini_upstream; - let status_code = status.as_u16(); - let ex_id2 = ex_id; - let started2 = started; - let xlabel = format!("{:?}->{:?}", provider_wire, client_wire); - let up_res_headers = vec_headers(&out_headers); - let mut guard = StreamAbortGuard::new( - st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), - routing.clone(), status_code, - json!({ - "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "content-type": "text/event-stream", "x-ccbud-translated": xlabel }), - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - }), - // raw upstream capture (pre-translation), so the monitor can show the exact - // upstream stream next to the translated one the client received - Some((status_code, up_res_headers)), - ); - let method2 = method.clone(); - let path2 = req_path.clone(); - let pname2 = provider_name.clone(); - let routing2 = routing.clone(); - let body_stream = async_stream::stream! { - let mut s = resp.bytes_stream(); - let mut buf = String::new(); - let mut history_recorded = false; - while let Some(chunk) = s.next().await { - match chunk { - Ok(bytes) => { - let raw = String::from_utf8_lossy(&bytes); - guard.push_up(&raw); - buf.push_str(&raw); - let mut out = String::new(); - while let Some(idx) = buf.find('\n') { - let line: String = buf.drain(..=idx).collect(); - out.push_str(&tc.push(&line)); - } - if capture_thought_signatures && tc.succeeded() { - let captured_calls = tc.captured_tool_calls(); - st2.thought_signatures.lock().await.remember( - &signature_provider_id, - signature_session.as_deref(), - &captured_calls, - ); - } - // Keep the guard current BEFORE suspending: once the terminal event is - // out, Codex closes the socket and the generator is dropped mid-await. - guard.push_res(&out); - guard.finished = tc.done(); - if client_wire == crate::protocol::Wire::OpenAiResponses - && !history_recorded - { - if let Some(terminal) = responses_terminal_event(&out) { - history_recorded = true; - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st2.codex_history - .record_response_scoped_with_metadata( - &history_scope, - ResponseOrigin::Local, - true, - &history_request, - response, - ) - .await; - } - } - } - } - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if !out.is_empty() { - yield Ok::(Bytes::from(out)); - } - } - Err(error) => { - let message = format!("upstream stream transport error: {}", error); - st2.log("error", format!("{} ({})", message, pname2)); - buf.clear(); - let out = tc.fail(&message); - guard.push_res(&out); - guard.finished = tc.done(); - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if !out.is_empty() { - yield Ok(Bytes::from(out)); - } - break; - } - } - } - let mut tail = String::new(); - if !buf.is_empty() { tail.push_str(&tc.push(&buf)); } - tail.push_str(&tc.finish()); - guard.finished = tc.done(); - guard.usage = Some(UsageAcc { - input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() - }); - if capture_thought_signatures && tc.succeeded() { - let captured_calls = tc.captured_tool_calls(); - st2.thought_signatures.lock().await.remember( - &signature_provider_id, - signature_session.as_deref(), - &captured_calls, - ); - } - if !tail.is_empty() { - guard.push_res(&tail); - if client_wire == crate::protocol::Wire::OpenAiResponses - && !history_recorded - { - if let Some(terminal) = responses_terminal_event(&tail) { - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st2.codex_history - .record_response_scoped_with_metadata( - &history_scope, - ResponseOrigin::Local, - true, - &history_request, - response, - ) - .await; - } - } - } - } - yield Ok(Bytes::from(tail)); - } - let mut usage = UsageAcc::default(); - usage.input = tc.input_tokens(); - usage.output = tc.output_tokens(); - usage.saw = true; - st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); - let mut ex = guard.complete(); - ex["ms"] = json!(started2.elapsed().as_millis() as u64); - st2.record_exchange(ex).await; - }; - let mut builder = Response::builder() - .status(status.as_u16()) - .header("content-type", "text/event-stream") - .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); - // Forward the upstream request id — clients (Claude Code) persist it as `requestId`, - // which usage analytics use as half of the de-dup key. - for (k, v) in &out_headers { - if k == "request-id" || k == "x-request-id" { - builder = builder.header(k, v); - } - } - return builder.body(Body::from_stream(body_stream)).unwrap(); - } - let rewrite_model = if need_rewrite { routing.client_facing_model.clone() } else { None }; - let st2 = st.clone(); - let status_code = status.as_u16(); - let ex_id2 = ex_id; - let started2 = started; - let res_headers = vec_headers(&out_headers); - let mut guard = StreamAbortGuard::new( - st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), - routing.clone(), status_code, - json!({ - "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": res_headers, - "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), - }), - None, - ); - let method2 = method.clone(); - let path2 = req_path.clone(); - let pname2 = provider_name.clone(); - let routing2 = routing.clone(); - let native_history = native_responses_history.clone(); - let native_responses_stream = client_wire == crate::protocol::Wire::OpenAiResponses; - let body_stream = async_stream::stream! { - let mut s = resp.bytes_stream(); - let mut buf = String::new(); - let mut usage = UsageAcc::default(); - let mut history_recorded = false; - while let Some(chunk) = s.next().await { - match chunk { - Ok(bytes) => { - buf.push_str(&String::from_utf8_lossy(&bytes)); - let mut out = String::new(); - while let Some(idx) = buf.find('\n') { - let line: String = buf.drain(..=idx).collect(); - out.push_str(&process_sse_line(&line, rewrite_model.as_deref(), &mut usage)); - } - let terminal = native_responses_stream - .then(|| responses_terminal_event(&out)) - .flatten(); - guard.push_res(&out); - if let Some(terminal) = terminal { - guard.finished = true; - if !history_recorded - && (200..300).contains(&status_code) - && terminal.kind.is_resumable() - { - if let (Some(history), Some(response)) = - (native_history.as_ref(), terminal.response.as_ref()) - { - st2.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - response, - ) - .await; - } - } - history_recorded = true; - } - guard.usage = Some(usage.clone()); - if !out.is_empty() { - yield Ok::(Bytes::from(out)); - } - } - Err(error) => { - let message = format!("upstream stream transport error: {}", error); - st2.log("error", format!("{} ({})", message, pname2)); - yield Err(std::io::Error::new(std::io::ErrorKind::Other, message)); - return; - } - } - } - if !buf.is_empty() { - let line = process_sse_line(&buf, rewrite_model.as_deref(), &mut usage); - let terminal = native_responses_stream - .then(|| responses_terminal_event(&line)) - .flatten(); - guard.push_res(&line); - if let Some(terminal) = terminal { - guard.finished = true; - if !history_recorded - && (200..300).contains(&status_code) - && terminal.kind.is_resumable() - { - if let (Some(history), Some(response)) = - (native_history.as_ref(), terminal.response.as_ref()) - { - st2.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - response, - ) - .await; - } - } - } - yield Ok(Bytes::from(line)); - } - if native_responses_stream && !guard.finished { - let message = "upstream Responses stream ended before a terminal event"; - st2.log("error", format!("{} ({})", message, pname2)); - yield Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, message)); - return; - } - st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); - let mut ex = guard.complete(); - ex["ms"] = json!(started2.elapsed().as_millis() as u64); - st2.record_exchange(ex).await; - }; - let mut builder = Response::builder().status(status.as_u16()); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - return builder.body(Body::from_stream(body_stream)).unwrap(); - } - - // buffered (reqwest auto-decoded gzip/br/deflate) - let buf = match resp.bytes().await { - Ok(buf) => buf, - Err(error) => { - let message = format!("upstream response body transport error: {}", error); - st.log("error", format!("{} ({})", message, provider_name)); - st.emit_request( - ex_id, - started, - &method, - &req_path, - &provider_name, - &routing, - StatusCode::BAD_GATEWAY.as_u16(), - None, - ); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy: {}", message), "api_error"); - } - }; - - // count_tokens: pass the upstream's real number when it implements the endpoint; otherwise - // (404 / non-JSON / missing input_tokens) estimate locally so Claude Code's sizing keeps working. - if is_count_tokens { - let upstream_ok = status.is_success() - && serde_json::from_slice::(&buf) - .ok() - .and_then(|o| o.get("input_tokens").and_then(|v| v.as_i64())) - .is_some(); - if upstream_ok { - let mut builder = Response::builder().status(200).header("x-ccbud-tokens", "upstream"); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - return builder.body(Body::from(buf)).unwrap(); - } - let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); - let ebody = serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default(); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "x-ccbud-tokens": "estimated", "x-ccbud-upstream-status": status.as_u16().to_string() }), - "resBody": json!({ "text": String::from_utf8_lossy(&ebody), "bytes": ebody.len(), "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("content-type", "application/json") - .header("x-ccbud-tokens", "estimated") - .header("x-ccbud-upstream-status", status.as_u16().to_string()) - .body(Body::from(ebody)) - .unwrap(); - } - - if is_models_list { - let mut merged = None; - if status.is_success() { - if let Ok(o) = serde_json::from_slice::(&buf) { - if let Some(data) = o.get("data").and_then(|d| d.as_array()) { - if let Some(pid) = &active_pid { - let ids: HashSet = data - .iter() - .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect(); - if !ids.is_empty() { - st.known.lock().await.insert(pid.clone(), ids); - } - } - merged = Some(merge_models(&o, &config, client_codex)); - } - } - } - let result = merged.unwrap_or_else(|| synthesize_models(&config, client_codex)); - let rbody = serde_json::to_vec(&result).unwrap_or_default(); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": json!({ "content-type": "application/json" }), - "resBody": json!({ "text": String::from_utf8_lossy(&rbody), "bytes": rbody.len(), "truncated": 0 }), - })) - .await; - return Response::builder() - .status(200) - .header("content-type", "application/json") - .body(Body::from(rbody)) - .unwrap(); - } - - // Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's - // protocol. We forced stream=false upstream, so the reply is always buffered here. - if let Some((client_wire, provider_wire, ref client_model, wanted_stream, _incremental, - ref tool_context, ref history_request, ref history_scope)) = translate { - let text = String::from_utf8_lossy(&buf); - if !status.is_success() { - st.log("warn", format!("upstream {} on translated request ({})", status.as_u16(), provider_name)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), None); - return error_response( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), - &format!("CC Buddy upstream error: {}", text.chars().take(400).collect::()), - "api_error", - ); - } - let ir = match crate::protocol::decode_upstream_response(provider_wire, &text) { - Ok(ir) => ir, - Err(e) => { - st.log("error", format!("response translate ({:?}→{:?}) failed: {}", provider_wire, client_wire, e)); - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); - return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy response translation failed: {}", e), "api_error"); - } - }; - let mut usage = UsageAcc::default(); - if let Some(u) = ir.usage.as_ref() { - usage.input = u.prompt_tokens as i64; - usage.output = u.completion_tokens as i64; - usage.saw = true; - } - let (ct_out, body_bytes, terminal_response) = if wanted_stream { - let sse = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::encode_response_sse_with_context( - &ir, - client_model, - tool_context, - ) - } else { - crate::protocol::encode_client_response_sse(client_wire, &ir, client_model).unwrap_or_default() - }; - let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) - .then(|| responses_terminal_event(&sse)) - .flatten(); - ("text/event-stream", Bytes::from(sse), terminal) - } else { - let j = if client_wire == crate::protocol::Wire::OpenAiResponses { - crate::protocol::openai_responses::encode_response_with_context( - &ir, - client_model, - tool_context, - ) - } else { - crate::protocol::encode_client_response(client_wire, &ir, client_model).unwrap_or_else(|_| json!({})) - }; - let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) - .then(|| responses_terminal_object(&j)) - .flatten(); - ("application/json", Bytes::from(serde_json::to_vec(&j).unwrap_or_default()), terminal) - }; - if is_gemini_upstream { - let captured_calls = if client_wire == crate::protocol::Wire::OpenAiResponses { - terminal_response - .as_ref() - .filter(|terminal| terminal.kind == ResponsesTerminalKind::Completed) - .and_then(|terminal| terminal.response.as_ref()) - .map(|response| response_tool_calls_with_client_ids(&ir, response)) - .unwrap_or_default() - } else { - response_tool_calls(&ir) - }; - if !captured_calls.is_empty() { - st.thought_signatures.lock().await.remember( - &routing.provider_id, - request_session.as_deref(), - &captured_calls, - ); - } - } - if let Some(terminal) = terminal_response.as_ref() { - if terminal.kind.is_resumable() { - if let Some(response) = terminal.response.as_ref() { - st.codex_history - .record_response_scoped_with_metadata( - history_scope, - ResponseOrigin::Local, - true, - history_request, - response, - ) - .await; - } - } - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "upstreamRes": json!({ "status": status.as_u16(), "headers": vec_headers(&out_headers), "body": cap_text(&buf, 1024 * 1024) }), - "resHeaders": json!({ "content-type": ct_out, "x-ccbud-translated": format!("{:?}->{:?}", provider_wire, client_wire) }), - "resBody": cap_text(&body_bytes, 2 * 1024 * 1024), - })) - .await; - let mut builder = Response::builder() - .status(status.as_u16()) - .header("content-type", ct_out) - .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); - for (k, v) in &out_headers { - if k == "request-id" || k == "x-request-id" { - builder = builder.header(k, v); - } - } - return builder.body(Body::from(body_bytes)).unwrap(); - } - - let mut out_buf = buf.clone(); - let mut usage = UsageAcc::default(); - if ct.contains("application/json") || native_responses_history.is_some() { - if let Ok(mut o) = serde_json::from_slice::(&buf) { - if let Some(u) = o.get("usage").cloned() { - usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.output += u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - usage.saw = true; - } - if status.is_success() { - if let (Some(history), Some(terminal)) = ( - native_responses_history.as_ref(), - responses_terminal_object(&o), - ) { - if terminal.kind.is_resumable() { - st.codex_history - .record_response_scoped_with_metadata( - &history.scope, - ResponseOrigin::Native(history.provider_id.clone()), - history.materializable, - &history.request, - &o, - ) - .await; - } - } - } - if need_rewrite { - if let Some(cf) = &routing.client_facing_model { - if o.get("model").and_then(|v| v.as_str()).is_some() { - o["model"] = json!(cf); - if let Ok(b) = serde_json::to_vec(&o) { - out_buf = Bytes::from(b); - } - } - } - } - } - } - st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); - st.record_exchange(json!({ - "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, - "method": method.as_str(), "path": req_path, "url": ex_url, - "provider": provider_name, "requestedModel": routing.client_facing_model, - "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, - "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, - "clientReq": ex_client_req, "translated": ex_translated, - "resHeaders": vec_headers(&out_headers), "resBody": cap_text(&out_buf, 2 * 1024 * 1024), - })) - .await; - - let mut builder = Response::builder().status(status.as_u16()); - for (k, v) in &out_headers { - builder = builder.header(k, v); - } - builder.body(Body::from(out_buf)).unwrap() -} - -// ---- mock upstream + end-to-end gateway selftest (debug only) ---- - -/// Spawn an in-process mock Anthropic-style upstream on a random port. Echoes back the model -/// the gateway forwarded (proving the outgoing rewrite), with usage, as JSON or SSE. -pub async fn start_mock_upstream() -> Option { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.ok()?; - let port = listener.local_addr().ok()?.port(); - let app: Router = Router::new().fallback(mock_handler); - tauri::async_runtime::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - Some(port) -} - -async fn mock_handler(req: axum::extract::Request) -> Response { - let (parts, body) = req.into_parts(); - let path = parts.uri.path().to_string(); - let bytes = to_bytes(body, 1024 * 1024).await.unwrap_or_default(); - if path.ends_with("/count_tokens") || path == "/" { - // Simulate a provider that implements neither count_tokens nor `HEAD /` → the gateway - // estimates locally / serves the health-probe fallback. - return Response::builder() - .status(404) - .header("content-type", "application/json") - .body(Body::from("{\"error\":\"not found\"}")) - .unwrap(); - } - let v: Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})); - let stream = v.get("stream").and_then(|s| s.as_bool()).unwrap_or(false); - let model = v.get("model").and_then(|m| m.as_str()).unwrap_or("upstream-model").to_string(); - // OpenAI Chat endpoint: answer in Chat Completions shape so the gateway's protocol translation - // (Anthropic→chat request, chat→Anthropic response) can be exercised end-to-end. The gateway - // forces stream=false upstream when translating, so we only need the buffered form here. - if path.contains("/chat/completions") { - if stream { - // OpenAI Chat streaming chunks (text split across two chunks + a usage-bearing final - // chunk), so the incremental transcoder is exercised end-to-end. - let sse = format!( - "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"hi \"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"from chat\"}}}}]}}\n\n\ - data: {{\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":12,\"completion_tokens\":5}}}}\n\n\ - data: [DONE]\n\n" - ); - let _ = &model; - return Response::builder() - .status(200) - .header("content-type", "text/event-stream") - .body(Body::from(sse)) - .unwrap(); - } - return json_response( - StatusCode::OK, - &json!({ - "id": "chatcmpl-mock", "object": "chat.completion", "created": 1, "model": model, - "choices": [{ "index": 0, "finish_reason": "stop", - "message": { "role": "assistant", "content": "hi from chat" } }], - "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }, - }), - ); - } - // OpenAI Responses endpoint: reply in Responses shape (buffered) so messages→responses can be - // exercised end-to-end. (The gateway forces stream=false upstream for the responses direction.) - if path.ends_with("/responses") || path.ends_with("/responses/") { - return json_response( - StatusCode::OK, - &json!({ - "id": "resp-mock", "object": "response", "created_at": 1, "model": model, "status": "completed", - "output": [{ "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": "hi from responses" }] }], - "output_text": "hi from responses", - "usage": { "input_tokens": 14, "output_tokens": 6, "total_tokens": 20 }, - }), - ); - } - if stream { - // Anthropic streaming with a real text block, so the Anthropic→Responses incremental - // transcoder (Codex client) has content to carry, not just usage bookkeeping. - let sse = format!( - "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_mock\",\"model\":\"{m}\",\"usage\":{{\"input_tokens\":10,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}}}}\n\nevent: content_block_start\ndata: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\nevent: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"hi from anthropic\"}}}}\n\nevent: content_block_stop\ndata: {{\"type\":\"content_block_stop\",\"index\":0}}\n\nevent: message_delta\ndata: {{\"type\":\"message_delta\",\"usage\":{{\"output_tokens\":7}}}}\n\nevent: message_stop\ndata: {{\"type\":\"message_stop\"}}\n\n", - m = model - ); - Response::builder() - .status(200) - .header("content-type", "text/event-stream") - .body(Body::from(sse)) - .unwrap() - } else { - json_response( - StatusCode::OK, - &json!({ "id":"msg_mock", "type":"message", "role":"assistant", "model":model, "content":[{"type":"text","text":"hi"}], "stop_reason":"end_turn", "usage":{"input_tokens":10,"output_tokens":7} }), - ) - } -} - -/// End-to-end gateway test against the mock upstream: routing + response model rewrite for both -/// buffered JSON and streaming SSE. Mutates CCBUD_HOME config (only called in a throwaway run). -pub async fn gateway_selftest(gport: u16) -> Value { - if gport == 0 { - return json!({ "err": "gateway not running" }); - } - let mock = match start_mock_upstream().await { - Some(p) => p, - None => return json!({ "err": "mock failed to start" }), - }; - let cfg = json!({ "port": gport, "activeProviderId":"mock", "providers":[ - { "id":"mock","name":"Mock","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","defaultModel":"upstream-model","smallFastModel":"upstream-model","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"upstream-model"}] } - ]}); - store::write_config(cfg); - tokio::time::sleep(std::time::Duration::from_millis(80)).await; - - let client = reqwest::Client::new(); - let base = format!("http://127.0.0.1:{}/v1/messages", gport); - - let ns = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (ns_status, ns_model) = match ns { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - (s, j.get("model").and_then(|m| m.as_str()).unwrap_or("").to_string()) - } - Err(e) => (0, format!("ERR:{}", e)), - }; - - let stm = client - .post(&base) - .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (st_status, st_text) = match stm { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // count_tokens — mock 404s, so the gateway must estimate locally - let ct = client - .post(format!("http://127.0.0.1:{}/v1/messages/count_tokens", gport)) - .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hello world this is a token counting test"}] })) - .send() - .await; - let (ct_status, ct_tokens, ct_estimated) = match ct { - Ok(r) => { - let s = r.status().as_u16(); - let estimated = r.headers().get("x-ccbud-tokens").and_then(|v| v.to_str().ok()) == Some("estimated"); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - (s, j.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(-1), estimated) - } - Err(_) => (0, -1, false), - }; - - // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Chat provider ---- - // Reconfigure the mock provider to speak openai-chat, then hit /v1/messages and prove the - // response comes back Anthropic-shaped (non-stream) and as a valid Anthropic SSE (stream). - let cfg2 = json!({ "port": gport, "activeProviderId":"mockoa", "providers":[ - { "id":"mockoa","name":"MockOpenAI","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-chat","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } - ]}); - store::write_config(cfg2.clone()); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let tx_ns = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model) = match tx_ns { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); - let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let model = j.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_msg, text, model) - } - Err(e) => (0, false, format!("ERR:{}", e), String::new()), - }; - - let tx_st = client - .post(&base) - .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (tx_st_status, tx_st_text) = match tx_st { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Responses provider ---- - let cfg3 = json!({ "port": gport, "activeProviderId":"mockre", "providers":[ - { "id":"mockre","name":"MockResponses","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-responses","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } - ]}); - store::write_config(cfg3); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let rx = client - .post(&base) - .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (rx_status, rx_anthropic, rx_text) = match rx { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); - let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_msg, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - - // ---- reverse: an OpenAI-Chat client (/v1/chat/completions) → an Anthropic provider ---- - let cfg4 = json!({ "port": gport, "activeProviderId":"mockan", "providers":[ - { "id":"mockan","name":"MockAnthropic","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"anthropic","defaultModel":"claude-mock","smallFastModel":"claude-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"claude-mock"}] } - ]}); - store::write_config(cfg4); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let rev = client - .post(format!("http://127.0.0.1:{}/v1/chat/completions", gport)) - .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hi"}] })) - .send() - .await; - let (rev_status, rev_is_chat, rev_text) = match rev { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_chat = j.get("object").and_then(|v| v.as_str()) == Some("chat.completion"); - let text = j.get("choices").and_then(|c| c.as_array()).and_then(|a| a.first()) - .and_then(|c| c.get("message")).and_then(|m| m.get("content")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_chat, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - - // ---- Codex (OpenAI-Responses client, /v1/responses) → an Anthropic provider ---- - // The shape Codex sends with wire_api="responses": instructions + item-based input + flattened - // function tools. Non-stream proves the buffered translate; stream proves the incremental - // Anthropic→Responses transcoder (item done events + terminal response.completed). - let codex_body = json!({ "model":"test-alias", "instructions":"be nice", - "input":[{ "type":"message","role":"user","content":[{ "type":"input_text","text":"hi" }] }], - "tools":[{ "type":"function","name":"shell","description":"run","parameters":{ "type":"object" } }], - "tool_choice":"auto", "store": false }); - let cdx = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_body) - .send() - .await; - let (cdx_status, cdx_is_response, cdx_text) = match cdx { - Ok(r) => { - let s = r.status().as_u16(); - let j: Value = r.json().await.unwrap_or_else(|_| json!({})); - let is_resp = j.get("object").and_then(|v| v.as_str()) == Some("response"); - let text = j.get("output_text").and_then(|v| v.as_str()).unwrap_or("").to_string(); - (s, is_resp, text) - } - Err(e) => (0, false, format!("ERR:{}", e)), - }; - let mut codex_stream_body = codex_body.clone(); - codex_stream_body["stream"] = json!(true); - let cdx_st = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_stream_body) - .send() - .await; - let (cdx_st_status, cdx_st_text) = match cdx_st { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - // ---- Codex → an OpenAI-Chat provider (incremental chat→Responses transcoding) ---- - store::write_config(cfg2.clone()); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let cdx_chat = client - .post(format!("http://127.0.0.1:{}/v1/responses", gport)) - .json(&codex_stream_body) - .send() - .await; - let (cdx_chat_status, cdx_chat_text) = match cdx_chat { - Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), - Err(e) => (0, format!("ERR:{}", e)), - }; - - json!({ - "nonStreamStatus": ns_status, - "nonStreamModel": ns_model, - "nonStreamRewritten": ns_model == "test-alias", - "xlateResponsesStatus": rx_status, - "xlateResponsesIsAnthropic": rx_anthropic, - "xlateResponsesText": rx_text, - "revChatStatus": rev_status, - "revChatIsChatCompletion": rev_is_chat, - "revChatText": rev_text, - "streamStatus": st_status, - "streamHasStart": st_text.contains("message_start"), - "streamRewritten": st_text.contains("\"test-alias\"") && !st_text.contains("upstream-model"), - "countTokensStatus": ct_status, - "countTokensEstimated": ct_estimated, - "countTokens": ct_tokens, - // protocol translation (messages→chat) - "xlateNonStreamStatus": tx_ns_status, - "xlateNonStreamIsAnthropic": tx_ns_anthropic, - "xlateNonStreamText": tx_ns_text, - "xlateNonStreamModel": tx_ns_model, - "xlateStreamStatus": tx_st_status, - "xlateStreamHasStart": tx_st_text.contains("message_start"), - "xlateStreamHasStop": tx_st_text.contains("message_stop"), - // incremental transcode: OpenAI chunks → Anthropic text_delta events (text split across - // chunks), a real content_block_delta, and end_turn stop. - "xlateStreamIncremental": tx_st_text.contains("content_block_delta") && tx_st_text.contains("text_delta"), - "xlateStreamText": tx_st_text.contains("from chat"), - "xlateStreamStop": tx_st_text.contains("\"stop_reason\":\"end_turn\""), - // Codex (Responses client): buffered translate + incremental stream transcoders. Codex - // materializes items from response.output_item.done and requires response.completed. - "codexNonStreamStatus": cdx_status, - "codexNonStreamIsResponse": cdx_is_response, - "codexNonStreamText": cdx_text, - "codexAnthropicStreamStatus": cdx_st_status, - "codexAnthropicStreamDelta": cdx_st_text.contains("response.output_text.delta"), - "codexAnthropicStreamItemDone": cdx_st_text.contains("response.output_item.done"), - "codexAnthropicStreamCompleted": cdx_st_text.contains("response.completed"), - "codexAnthropicStreamText": cdx_st_text.contains("hi from anthropic"), - "codexChatStreamStatus": cdx_chat_status, - "codexChatStreamDelta": cdx_chat_text.contains("response.output_text.delta"), - "codexChatStreamItemDone": cdx_chat_text.contains("response.output_item.done"), - "codexChatStreamCompleted": cdx_chat_text.contains("response.completed"), - "codexChatStreamText": cdx_chat_text.contains("from chat"), - }) -} - -/// In-binary equivalent of test/selftest.js's 8 routing unit checks. -pub fn routing_selftest() -> Value { - let config = json!({ "port":0, "activeProviderId":"glm", "providers":[ - { "id":"glm","name":"GLM","baseUrl":"https://x","authToken":"","defaultModel":"glm-5.1","smallFastModel":"glm-5.1","mapDefaultModels":true,"models":[{"alias":"claude-opus-4.8[1m]","upstream":"glm-5.1"}] } - ]}); - let cfg2 = json!({ "port":0, "activeProviderId":"main", "providers":[ - { "id":"main","name":"Main","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big-model","smallFastModel":"small-model","mapDefaultModels":true,"models":[{"alias":"my-alias","upstream":"aliased-up"}] }, - { "id":"other","name":"Other","baseUrl":"http://127.0.0.1:2","authToken":"k","defaultModel":"other-big","smallFastModel":"other-small","mapDefaultModels":true,"models":[{"alias":"other-alias","upstream":"other-up"}] } - ]}); - let off = json!({ "port":0, "activeProviderId":"m", "providers":[ - { "id":"m","name":"M","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big","smallFastModel":"small","mapDefaultModels":false,"models":[] } - ]}); - - let out = |r: &Option| r.as_ref().and_then(|x| x.outgoing_model.clone()); - let cf = |r: &Option| r.as_ref().and_then(|x| x.client_facing_model.clone()); - let pidf = |r: &Option| r.as_ref().map(|x| x.provider_id.clone()); - - let mut fails: Vec = vec![]; - let mut n = 0; - let mut chk = |name: &str, cond: bool| { - n += 1; - if !cond { - fails.push(name.to_string()); - } - }; - - let r = resolve_routing(Some("claude-opus-4.8[1m]"), &config, None); - chk("1 alias→upstream", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("claude-opus-4.8[1m]")); - let r = resolve_routing(Some("glm-5.1"), &config, None); - chk("2 real passthrough", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("glm-5.1")); - let r = resolve_routing(Some("claude-3-5-haiku-20241022"), &cfg2, None); - chk("3 haiku→light", out(&r).as_deref() == Some("small-model")); - let r = resolve_routing(Some("claude-sonnet-4-6"), &cfg2, None); - chk("4 sonnet→primary", out(&r).as_deref() == Some("big-model")); - let r = resolve_routing(Some("gpt-4-turbo"), &cfg2, None); - chk("5 foreign→light", out(&r).as_deref() == Some("small-model")); - let mut known = HashSet::new(); - known.insert("glm-5.2".to_string()); - let r = resolve_routing(Some("glm-5.2"), &cfg2, Some(&known)); - chk("6 known passthrough", out(&r).as_deref() == Some("glm-5.2")); - let r = resolve_routing(Some("other-alias"), &cfg2, None); - chk("7 stays on active", pidf(&r).as_deref() == Some("main") && out(&r).as_deref() == Some("small-model")); - let r = resolve_routing(Some("whatever-x"), &off, None); - chk("8 mapoff passthrough", out(&r).as_deref() == Some("whatever-x")); - let r = resolve_routing(Some("gpt-5.5-ccbud"), &cfg2, None); - chk( - "9 codex sentinel→primary", - out(&r).as_deref() == Some("big-model") && cf(&r).as_deref() == Some("gpt-5.5-ccbud"), - ); - - json!({ "total": n, "passed": n - fails.len(), "failed": fails.len(), "fails": fails }) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn routing_parity_with_proxy_js() { - let r = routing_selftest(); - assert_eq!(r.get("failed").and_then(|v| v.as_i64()), Some(0), "routing mismatch: {:?}", r); - assert_eq!(r.get("passed").and_then(|v| v.as_i64()), Some(9)); - } - #[test] - fn synthesize_models_includes_claude_tiers() { - let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); - let s = synthesize_models(&cfg, false); - let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); - assert!(ids.contains(&"claude-sonnet-5")); - assert!(ids.contains(&"claude-fable-5")); - assert!(!ids.iter().any(|id| id.starts_with("gpt-"))); - } - #[test] - fn synthesize_models_codex_returns_gpt_tiers() { - let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); - let s = synthesize_models(&cfg, true); - let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); - assert!(ids.contains(&"gpt-5.4")); - assert!(ids.contains(&"gpt-5.4-mini")); - assert!(!ids.iter().any(|id| id.starts_with("claude-"))); - } - #[test] - fn responses_chat_translation_preserves_parallel_tool_calls() { - let mut body = json!({ "model": "upstream", "messages": [] }); - apply_responses_chat_request_controls( - &mut body, - &json!({ "parallel_tool_calls": false }), - ); - assert_eq!(body["parallel_tool_calls"], false); - - let mut absent = json!({ "model": "upstream", "messages": [] }); - apply_responses_chat_request_controls(&mut absent, &json!({})); - assert!(absent.get("parallel_tool_calls").is_none()); - } - #[test] - fn build_target_collapses_path_overlap() { - let u = |s: &str| s.parse::().unwrap(); - // openai-* provider / sidecar plugin: base ends in /v1 and the client path - // repeats /v1 → collapse (was ".../v1/v1/responses" → 404). - assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/responses")).unwrap(), "http://127.0.0.1:57085/v1/responses"); - assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/models?x=1")).unwrap(), "http://127.0.0.1:57085/v1/models?x=1"); - // non-overlapping prefix (anthropic providers) → plain concat, unchanged. - assert_eq!(build_target("https://api.deepseek.com/anthropic", &u("/v1/messages")).unwrap(), "https://api.deepseek.com/anthropic/v1/messages"); - // base without a path → unchanged. - assert_eq!(build_target("http://127.0.0.1:9", &u("/v1/responses")).unwrap(), "http://127.0.0.1:9/v1/responses"); - // segment-aware: a /v1 base must NOT eat a /v1beta path. - assert_eq!(build_target("http://h/v1", &u("/v1beta/x")).unwrap(), "http://h/v1/v1beta/x"); - } - #[test] - fn primary_endpoints_use_the_configured_base_and_offer_one_v1_fallback() { - let u = |s: &str| s.parse::().unwrap(); - assert_eq!( - endpoint_targets("https://example.com/api", &u("/v1/messages?x=1")), - Some(( - "https://example.com/api/messages?x=1".to_string(), - Some("https://example.com/api/v1/messages?x=1".to_string()), - )) - ); - assert_eq!( - endpoint_targets("https://example.com/v4", &u("/v1/chat/completions")), - Some(("https://example.com/v4/chat/completions".to_string(), None)) - ); - assert_eq!( - endpoint_targets("https://example.com/v1", &u("/v1/responses")), - Some(("https://example.com/v1/responses".to_string(), None)) - ); - assert_eq!( - endpoint_targets("https://example.com/v1", &u("/v1/responses/compact")), - Some(("https://example.com/v1/responses/compact".to_string(), None)) - ); - assert_eq!( - endpoint_targets( - "https://generativelanguage.googleapis.com/v1beta/openai", - &u("/v1/chat/completions"), - ), - Some(( - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions".to_string(), - None, - )) - ); - assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/models")), None); - assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/messages/count_tokens")), None); - } - #[tokio::test] - async fn compact_rejects_cross_wire_and_allows_responses_passthrough() { - for provider_wire in [ - crate::protocol::Wire::OpenAiChat, - crate::protocol::Wire::Anthropic, - ] { - let response = cross_wire_compact_error("/v1/responses/compact/", provider_wire) - .expect("cross-wire compact must be rejected locally"); - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - let body = to_bytes(response.into_body(), 4096).await.unwrap(); - let error: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(error["error"]["type"], "invalid_request_error"); - assert!(error["error"]["message"] - .as_str() - .unwrap() - .contains("cross-protocol compaction is not supported")); - } - - assert!(cross_wire_compact_error( - "/v1/responses/compact", - crate::protocol::Wire::OpenAiResponses, - ) - .is_none()); - assert!(cross_wire_compact_error( - "/v1/responses", - crate::protocol::Wire::OpenAiChat, - ) - .is_none()); - } - #[test] - fn responses_history_policy_covers_native_and_translated_provider_switches() { - let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { - changed: 3, - had_previous_response_id: true, - previous_found: true, - previous_materialized: materializable, - previous_origin: Some(origin), - }; - let portable = ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }; - - // Native Responses A → translated chat/Anthropic. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiChat, - "provider-chat", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(portable) - ); - // Translated/local → native Responses. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Local, true), - ), - Ok(portable) - ); - // Native Responses A → native Responses B. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(portable) - ); - // Same native owner keeps the provider-side id while the materialized local copy is retained - // for recording its descendant. - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), true), - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: true, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), false), - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }) - ); - - let missing = HistoryResolution { - had_previous_response_id: true, - ..HistoryResolution::default() - }; - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &missing, - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Original, - descendant_materializable: false, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::Anthropic, - "provider-anthropic", - &missing, - ), - Err(ResponsesHistoryError::Unavailable) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-a", - &HistoryResolution { - changed: 2, - ..HistoryResolution::default() - }, - ), - Ok(ResponsesHistoryDecision { - forward: ResponsesForwardMode::Materialized, - descendant_materializable: true, - }) - ); - assert_eq!( - decide_responses_history( - crate::protocol::Wire::OpenAiResponses, - "provider-b", - &known(ResponseOrigin::Local, false), - ), - Err(ResponsesHistoryError::Unavailable) - ); - } - #[test] - fn responses_compact_localizes_portable_foreign_history_and_allows_owner_or_cache_miss() { - let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { - changed: 2, - had_previous_response_id: true, - previous_found: true, - previous_materialized: materializable, - previous_origin: Some(origin), - }; - let missing = HistoryResolution { - had_previous_response_id: true, - ..HistoryResolution::default() - }; - assert_eq!( - decide_responses_compact_history("provider-a", &missing), - Ok(ResponsesForwardMode::Original) - ); - assert_eq!( - decide_responses_compact_history( - "provider-a", - &known(ResponseOrigin::Native("provider-a".to_string()), false), - ), - Ok(ResponsesForwardMode::Original) - ); - for origin in [ - ResponseOrigin::Local, - ResponseOrigin::Native("provider-b".to_string()), - ] { - assert_eq!( - decide_responses_compact_history("provider-a", &known(origin, true)), - Ok(ResponsesForwardMode::Materialized) - ); - } - assert_eq!( - decide_responses_compact_history( - "provider-a", - &known(ResponseOrigin::Local, false), - ), - Err(ResponsesHistoryError::Unavailable) - ); - } - #[test] - fn responses_terminal_parser_keeps_completed_and_incomplete_but_marks_failed() { - for (event_type, status, expected) in [ - ( - "response.completed", - "completed", - ResponsesTerminalKind::Completed, - ), - ( - "response.incomplete", - "incomplete", - ResponsesTerminalKind::Incomplete, - ), - ("response.failed", "failed", ResponsesTerminalKind::Failed), - ] { - let sse = format!( - "event: {event_type}\ndata: {{\"type\":\"{event_type}\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"{status}\",\"output\":[]}}}}\n\n" - ); - let terminal = responses_terminal_event(&sse).unwrap(); - assert_eq!(terminal.kind, expected); - assert_eq!(terminal.kind.is_resumable(), status != "failed"); - assert_eq!(terminal.response.as_ref().unwrap()["status"], status); - } - assert!(responses_terminal_event( - "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" - ) - .is_none()); - } - #[test] - fn routing_classifies_by_family() { - let cfg = json!({ "providers": [{ "id": "p", "baseUrl": "http://127.0.0.1:1", "authToken": "k", - "defaultModel": "big", "smallFastModel": "small", "mapDefaultModels": true, "models": [] }], "activeProviderId": "p" }); - let out = |r: Option| r.and_then(|x| x.outgoing_model); - // Claude: haiku → fast, fable/opus/sonnet → primary. - assert_eq!(out(resolve_routing(Some("claude-haiku-4-5"), &cfg, None)).as_deref(), Some("small")); - assert_eq!(out(resolve_routing(Some("claude-fable-5"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("claude-opus-4-8"), &cfg, None)).as_deref(), Some("big")); - // Codex: stable/default identities → primary; explicit small tiers → fast. Legacy - // sol/terra names remain primary for existing configs. - assert_eq!( - out(resolve_routing(Some("gpt-5.4"), &cfg, None)).as_deref(), - Some("big") - ); - assert_eq!( - out(resolve_routing(Some("gpt-5.4-mini"), &cfg, None)).as_deref(), - Some("small") - ); - assert_eq!(out(resolve_routing(Some("gpt-5.6-sol"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-terra"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-sol-pro"), &cfg, None)).as_deref(), Some("big")); - assert_eq!(out(resolve_routing(Some("gpt-5.6-luna"), &cfg, None)).as_deref(), Some("small")); - } - #[test] - fn retry_delay_honors_seconds_and_backoff() { - assert_eq!(retry_delay(Some("2"), 0, 500), 2000); - assert_eq!(retry_delay(None, 0, 500), 500); - assert_eq!(retry_delay(None, 1, 500), 1000); - // HTTP-date in the past → no wait (clamped to 0), NOT a fall-through to backoff. - assert_eq!(retry_delay(Some("Wed, 21 Oct 2015 07:28:00 GMT"), 3, 500), 0); - // Unparseable Retry-After → exponential backoff (base * 2^attempt). - assert_eq!(retry_delay(Some("soon"), 2, 500), 2000); - } - #[test] - fn extracts_claude_session_id_from_metadata() { - let nested = json!({ "metadata": { "user_id": "{\"session_id\":\"session-123\",\"account_id\":\"a\"}" } }); - assert_eq!(request_session_id(&nested).as_deref(), Some("session-123")); - assert!(request_session_id(&json!({ "metadata": { "user_id": "user-123" } })).is_none()); - // Codex (Responses client) identifies its conversation via prompt_cache_key. - assert_eq!( - request_session_id(&json!({ "prompt_cache_key": "conv-42" })).as_deref(), - Some("conv-42") - ); - assert!(request_session_id(&json!({ "prompt_cache_key": " " })).is_none()); - assert_eq!(codex_history_scope_for_session(Some("conv-42")), "conv-42"); - assert_eq!(codex_history_scope_for_session(None), ""); - } - - // The full Codex ⇄ Gemini(chat) signature round-trip: what ChatToResponses captured last turn - // is restored onto the function_call history Codex echoes back, and earlier steps get the - // documented fallback sentinel — without it Gemini 3 rejects the request with a 400. - #[test] - fn restores_signatures_for_codex_responses_requests() { - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("conv-42"), &[ - crate::protocol::stream::CapturedToolCall { - call_id: "call_9".to_string(), - name: "shell".to_string(), - arguments: "{\"command\":[\"ls\"]}".to_string(), - thought_signature: Some("sig-codex".to_string()), - }, - ]); - let codex = json!({ - "model": "gpt-5.5-ccbud", - "instructions": "You are Codex.", - "prompt_cache_key": "conv-42", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list, then read" }] }, - { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"pwd\"]}" }, - { "type": "function_call_output", "call_id": "call_1", "output": "/tmp" }, - { "type": "function_call", "call_id": "call_9", "name": "shell", "arguments": "{ \"command\": [\"ls\"] }" }, - { "type": "function_call_output", "call_id": "call_9", "output": "a.txt" } - ], - "store": false, "stream": true - }); - let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::OpenAiResponses, &codex).unwrap(); - assert_eq!(request_session_id(&codex).as_deref(), Some("conv-42")); - assert_eq!(cache.restore("google", Some("conv-42"), &mut ir), 1); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK)); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), - Some("sig-codex")); - // …and the encoded Gemini chat body carries them where Gemini validates them. - let body = crate::protocol::encode_upstream_request( - crate::protocol::Wire::OpenAiChat, &ir, "gemini-3-flash-preview", true, - ).unwrap(); - let signatures: Vec<_> = body["messages"].as_array().unwrap().iter() - .filter(|message| message["role"] == "assistant") - .map(|message| message["tool_calls"][0]["extra_content"]["google"]["thought_signature"].clone()) - .collect(); - assert_eq!(signatures, vec![json!(GEMINI_SIGNATURE_FALLBACK), json!("sig-codex")]); - } - - #[test] - fn repairs_malformed_history_arguments_before_strict_chat_forwarding() { - let body = json!({ - "model": "gpt-5.4", - "input": [ - { "type": "message", "role": "user", "content": [{ - "type": "input_text", "text": "Use the helper and continue" - }] }, - { "type": "function_call", "call_id": "call_bad", "name": "helper", - "arguments": "{\"value\":1} trailing-garbage" }, - { "type": "function_call_output", "call_id": "call_bad", - "output": "failed to parse function arguments" } - ], - "tools": [{ "type": "function", "name": "helper", "description": "test", - "parameters": { "type": "object", "properties": { "value": { "type": "number" } } } }] - }); - let mut ir = crate::protocol::decode_client_request( - crate::protocol::Wire::OpenAiResponses, - &body, - ) - .unwrap(); - let call = ir.messages[1].tool_calls.as_mut().unwrap().first_mut().unwrap(); - call.thought_signature = Some("stale-signature".to_string()); - - assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); - let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; - assert_eq!(serde_json::from_str::(&call.function.arguments).unwrap()["value"], 1); - assert!(crate::protocol::tool_call_thought_signature(call).is_none()); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - - let encoded = crate::protocol::encode_upstream_request( - crate::protocol::Wire::OpenAiChat, - &ir, - "gemini-3.5-flash", - false, - ) - .unwrap(); - let outgoing = &encoded["messages"][1]["tool_calls"][0]; - assert_eq!( - serde_json::from_str::(outgoing["function"]["arguments"].as_str().unwrap()) - .unwrap()["value"], - 1 - ); - assert_eq!( - outgoing["extra_content"]["google"]["thought_signature"], - GEMINI_SIGNATURE_FALLBACK - ); - } - - #[test] - fn history_argument_repair_preserves_valid_objects_and_wraps_unrecoverable_text() { - assert_eq!(provider_safe_history_tool_arguments(" { \"value\": 1 } "), None); - let scalar = provider_safe_history_tool_arguments("42").unwrap(); - assert_eq!(serde_json::from_str::(&scalar).unwrap()["_ccbuddy_value"], 42); - let raw = provider_safe_history_tool_arguments("not json at all").unwrap(); - assert_eq!( - serde_json::from_str::(&raw).unwrap()["_ccbuddy_raw_arguments"], - "not json at all" - ); - - for arguments in ["{\"value\":1}\u{00a0}", "\u{000b}{\"value\":1}"] { - let repaired = provider_safe_history_tool_arguments(arguments).unwrap(); - assert_eq!( - serde_json::from_str::(&repaired).unwrap(), - json!({ "value": 1 }) - ); - } - } - - #[test] - fn history_argument_repair_clears_a_signature_restored_for_different_bytes() { - let captured = crate::protocol::stream::CapturedToolCall { - call_id: "call_empty".to_string(), - name: "helper".to_string(), - arguments: String::new(), - thought_signature: Some("real-signature".to_string()), - }; - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("session-empty"), &[captured]); - let body = json!({ - "model": "gpt-5.4", - "input": [ - { "role": "user", "content": "Call helper" }, - { "type": "function_call", "call_id": "call_empty", "name": "helper", - "arguments": "" }, - { "type": "function_call_output", "call_id": "call_empty", "output": "invalid" } - ] - }); - let mut ir = crate::protocol::decode_client_request( - crate::protocol::Wire::OpenAiResponses, - &body, - ) - .unwrap(); - - assert_eq!(cache.restore("google", Some("session-empty"), &mut ir), 1); - assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); - let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; - assert_eq!(call.function.arguments, "{}"); - assert!(crate::protocol::tool_call_thought_signature(call).is_none()); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - assert_eq!( - crate::protocol::tool_call_thought_signature( - &ir.messages[1].tool_calls.as_ref().unwrap()[0] - ) - .as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK) - ); - } - - #[test] - fn restores_latest_batch_and_falls_back_for_prior_steps() { - let call = |id: &str, name: &str, arguments: &str, signature: Option<&str>| { - crate::protocol::stream::CapturedToolCall { - call_id: id.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - thought_signature: signature.map(str::to_string), - } - }; - let mut cache = ThoughtSignatureCache::default(); - cache.remember("google", Some("session-1"), &[ - call("default_api:Bash", "default_api:Bash", "{\"command\":\"pwd\"}", Some("sig-old")), - ]); - cache.remember("google", Some("session-1"), &[ - call("call_paris", "weather", "{ \"city\": \"Paris\" }", Some("sig-latest")), - call("call_london", "weather", "{\"city\":\"London\"}", None), - ]); - let claude = json!({ - "model": "claude-sonnet-5", "max_tokens": 1024, - "messages": [ - { "role": "user", "content": "Run pwd, then check Paris and London" }, - { "role": "assistant", "content": [{ - "type": "tool_use", "id": "default_api:Bash", "name": "default_api:Bash", - "input": { "command": "pwd" } - }] }, - { "role": "user", "content": [{ - "type": "tool_result", "tool_use_id": "default_api:Bash", "content": "/tmp" - }] }, - { "role": "assistant", "content": [ - { "type": "tool_use", "id": "call_paris", "name": "weather", - "input": { "city": "Paris" } }, - { "type": "tool_use", "id": "call_london", "name": "weather", - "input": { "city": "London" } } - ] }, - { "role": "user", "content": [ - { "type": "tool_result", "tool_use_id": "call_paris", "content": "15C" }, - { "type": "tool_result", "tool_use_id": "call_london", "content": "12C" } - ] } - ] - }); - let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::Anthropic, &claude).unwrap(); - assert_eq!(cache.restore("google", Some("session-1"), &mut ir), 1); - assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); - let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), - Some(GEMINI_SIGNATURE_FALLBACK)); - assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), - Some("sig-latest")); - assert!(crate::protocol::tool_call_thought_signature(&steps[1][1]).is_none()); - } - - #[test] - fn sessionless_cache_access_prunes_expired_batches() { - let stale = ThoughtSignatureBatch { - calls: vec![], - touched_at: now_ms().saturating_sub(THOUGHT_SIGNATURE_TTL_MS + 1), - }; - let mut cache = ThoughtSignatureCache::default(); - cache.batches.insert(("google".into(), "stale".into()), stale.clone()); - cache.remember("google", None, &[]); - assert!(cache.batches.is_empty()); - - cache.batches.insert(("google".into(), "stale".into()), stale); - let body = json!({ - "model": "claude-sonnet-5", - "max_tokens": 1, - "messages": [{ "role": "user", "content": "ping" }] - }); - let mut request = crate::protocol::decode_client_request( - crate::protocol::Wire::Anthropic, - &body, - ).unwrap(); - assert_eq!(cache.restore("google", None, &mut request), 0); - assert!(cache.batches.is_empty()); - } -} diff --git a/src-tauri/src/gateway/capture.rs b/src-tauri/src/gateway/capture.rs new file mode 100644 index 0000000..8c92728 --- /dev/null +++ b/src-tauri/src/gateway/capture.rs @@ -0,0 +1,128 @@ +use axum::http::Method; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::monitor::UsageAcc; +use super::routing::Routing; +use super::state::GatewayState; + +/// Makes a streaming request visible in the monitor even when the client aborts mid-stream. +/// The row + exchange record are normally emitted at the END of the response generator; when the +/// client disconnects, axum simply drops the generator and that code never runs — the request +/// vanished from the request stream (Codex users interrupt turns constantly). The generator owns +/// this guard: `complete()` hands back the prepared exchange (bodies filled) for the normal path, +/// and Drop-without-complete emits the row + a record. +/// +/// The response capture buffers live IN the guard rather than in generator locals: a dropped +/// generator then still records whatever already streamed through. This matters beyond real +/// aborts — Responses clients (Codex) tear the connection down the moment the terminal +/// `response.completed` event arrives, before upstream EOF, which used to lose BOTH response +/// bodies on every transcoded turn. When the transcoder has already emitted its terminal event +/// (`finished`), that disconnect is the normal end of a turn and is not flagged `aborted`. +pub(super) struct StreamAbortGuard { + armed: bool, + st: Arc, + id: u64, + started: std::time::Instant, + method: Method, + path: String, + provider: String, + routing: Routing, + status: u16, + ex: Value, + res_cap: String, + up_cap: Option, + pub(super) finished: bool, + pub(super) usage: Option, +} + +/// Raw upstream capture (pre-translation) for transcoded streams: status + headers are fixed at +/// guard construction, text accumulates as chunks arrive. +struct UpCapture { + status: u16, + headers: Value, + text: String, + total: usize, +} + +const RES_CAP_MAX: usize = 2 * 1024 * 1024; +const UP_CAP_MAX: usize = 1024 * 1024; + +impl StreamAbortGuard { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + st: Arc, + id: u64, + started: std::time::Instant, + method: Method, + path: String, + provider: String, + routing: Routing, + status: u16, + ex: Value, + upstream: Option<(u16, Value)>, + ) -> Self { + let up_cap = upstream.map(|(status, headers)| UpCapture { status, headers, text: String::new(), total: 0 }); + Self { + armed: true, st, id, started, method, path, provider, routing, status, ex, + res_cap: String::new(), up_cap, finished: false, usage: None, + } + } + + /// Append to the client-facing response capture (the translated stream for transcoded pairs). + pub(super) fn push_res(&mut self, s: &str) { + if self.res_cap.len() < RES_CAP_MAX { + self.res_cap.push_str(s); + } + } + + /// Append raw upstream bytes (pre-translation) when this guard tracks an upstream capture. + pub(super) fn push_up(&mut self, raw: &str) { + if let Some(u) = self.up_cap.as_mut() { + u.total += raw.len(); + if u.text.len() < UP_CAP_MAX { + u.text.push_str(raw); + } + } + } + + /// Write the captured bodies into the exchange skeleton — shared by normal and abort paths. + fn fill_bodies(&mut self) { + self.ex["resBody"] = json!({ "text": self.res_cap, "bytes": self.res_cap.len(), "truncated": 0 }); + if let Some(u) = self.up_cap.as_ref() { + self.ex["upstreamRes"] = json!({ "status": u.status, "headers": u.headers, + "body": { "text": u.text, "bytes": u.total, "truncated": u.total.saturating_sub(u.text.len()) } }); + } + } + + /// Normal completion: disarm and hand the exchange (bodies filled) back to the caller (who + /// fills in ms / usage and records it). + pub(super) fn complete(&mut self) -> Value { + self.armed = false; + self.fill_bodies(); + std::mem::take(&mut self.ex) + } +} + +impl Drop for StreamAbortGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + self.fill_bodies(); + let mut ex = std::mem::take(&mut self.ex); + ex["ms"] = json!(self.started.elapsed().as_millis() as u64); + // A disconnect after the transcoder's terminal event is the normal end of a Responses + // turn — only flag genuinely interrupted streams. + if !self.finished { + ex["aborted"] = json!(true); + } + self.st.emit_request(self.id, self.started, &self.method, &self.path, &self.provider, &self.routing, self.status, self.usage.as_ref()); + let st = self.st.clone(); + // record_exchange is async and Drop is sync — spawn it, tolerating an already-torn-down + // runtime at app quit. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + tauri::async_runtime::spawn(async move { st.record_exchange(ex).await }); + })); + } +} diff --git a/src-tauri/src/gateway/finish.rs b/src-tauri/src/gateway/finish.rs new file mode 100644 index 0000000..a29fb07 --- /dev/null +++ b/src-tauri/src/gateway/finish.rs @@ -0,0 +1,166 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::Arc; + + +use super::models::{merge_models, synthesize_models}; +use super::redact::now_ms; +use super::routing::Routing; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_head_root( + st: &Arc, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + st.log("info", format!("HEAD / fallback: upstream 404 → gateway 200 ({})", provider_name)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "x-ccbud-fallback": "head-root-404-to-200", "x-ccbud-upstream-status": "404" }), + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("x-ccbud-fallback", "head-root-404-to-200") + .header("x-ccbud-upstream-status", "404") + .body(Body::empty()) + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_count_tokens( + st: &Arc, + status: StatusCode, + buf: Bytes, + out_headers: Vec<(String, String)>, + parsed: &Option, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { +// count_tokens: pass the upstream's real number when it implements the endpoint; otherwise +// (404 / non-JSON / missing input_tokens) estimate locally so Claude Code's sizing keeps working. + let upstream_ok = status.is_success() + && serde_json::from_slice::(&buf) + .ok() + .and_then(|o| o.get("input_tokens").and_then(|v| v.as_i64())) + .is_some(); + if upstream_ok { + let mut builder = Response::builder().status(200).header("x-ccbud-tokens", "upstream"); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return builder.body(Body::from(buf)).unwrap(); + } + let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); + let ebody = serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default(); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "x-ccbud-tokens": "estimated", "x-ccbud-upstream-status": status.as_u16().to_string() }), + "resBody": json!({ "text": String::from_utf8_lossy(&ebody), "bytes": ebody.len(), "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("content-type", "application/json") + .header("x-ccbud-tokens", "estimated") + .header("x-ccbud-upstream-status", status.as_u16().to_string()) + .body(Body::from(ebody)) + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_models_list( + st: &Arc, + status: StatusCode, + buf: Bytes, + config: &Value, + client_codex: bool, + active_pid: Option, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let mut merged = None; + if status.is_success() { + if let Ok(o) = serde_json::from_slice::(&buf) { + if let Some(data) = o.get("data").and_then(|d| d.as_array()) { + if let Some(pid) = &active_pid { + let ids: HashSet = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + if !ids.is_empty() { + st.known.lock().await.insert(pid.clone(), ids); + } + } + merged = Some(merge_models(&o, &config, client_codex)); + } + } + } + let result = merged.unwrap_or_else(|| synthesize_models(&config, client_codex)); + let rbody = serde_json::to_vec(&result).unwrap_or_default(); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": 200, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "content-type": "application/json" }), + "resBody": json!({ "text": String::from_utf8_lossy(&rbody), "bytes": rbody.len(), "truncated": 0 }), + })) + .await; + return Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(rbody)) + .unwrap(); +} diff --git a/src-tauri/src/gateway/finish_buffered.rs b/src-tauri/src/gateway/finish_buffered.rs new file mode 100644 index 0000000..5d67411 --- /dev/null +++ b/src-tauri/src/gateway/finish_buffered.rs @@ -0,0 +1,98 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::monitor::UsageAcc; +use super::redact::{cap_text, now_ms, vec_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::Routing; +use super::sse::responses_terminal_object; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_buffered( + st: &Arc, + buf: Bytes, + ct: &str, + native_responses_history: Option, + need_rewrite: bool, + routing: Routing, + status: StatusCode, + out_headers: Vec<(String, String)>, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let mut out_buf = buf.clone(); + let mut usage = UsageAcc::default(); + if ct.contains("application/json") || native_responses_history.is_some() { + if let Ok(mut o) = serde_json::from_slice::(&buf) { + if let Some(u) = o.get("usage").cloned() { + usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.output += u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.saw = true; + } + if status.is_success() { + if let (Some(history), Some(terminal)) = ( + native_responses_history.as_ref(), + responses_terminal_object(&o), + ) { + if terminal.kind.is_resumable() { + st.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + &o, + ) + .await; + } + } + } + if need_rewrite { + if let Some(cf) = &routing.client_facing_model { + if o.get("model").and_then(|v| v.as_str()).is_some() { + o["model"] = json!(cf); + if let Ok(b) = serde_json::to_vec(&o) { + out_buf = Bytes::from(b); + } + } + } + } + } + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": vec_headers(&out_headers), "resBody": cap_text(&out_buf, 2 * 1024 * 1024), + })) + .await; + + let mut builder = Response::builder().status(status.as_u16()); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + builder.body(Body::from(out_buf)).unwrap() +} diff --git a/src-tauri/src/gateway/finish_translated.rs b/src-tauri/src/gateway/finish_translated.rs new file mode 100644 index 0000000..897df63 --- /dev/null +++ b/src-tauri/src/gateway/finish_translated.rs @@ -0,0 +1,159 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::monitor::UsageAcc; +use super::redact::{cap_text, now_ms, vec_headers}; +use super::routing::Routing; +use super::session::{response_tool_calls, response_tool_calls_with_client_ids}; +use super::sse::{responses_terminal_event, responses_terminal_object, ResponsesTerminalKind}; +use super::state::GatewayState; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn finish_translated( + st: &Arc, + buf: Bytes, + status: StatusCode, + out_headers: Vec<(String, String)>, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + client_model: &str, + wanted_stream: bool, + tool_context: &crate::protocol::openai_responses::CodexToolContext, + history_request: &Value, + history_scope: &str, + routing: &Routing, + request_session: Option, + is_gemini_upstream: bool, + ex_id: u64, + started: std::time::Instant, + method: &Method, + req_path: &str, + provider_name: &str, + ex_req_headers: &Value, + ex_req_body: &Value, + ex_client_req: &Value, + ex_translated: &Option, + ex_url: &str, +) -> Response { +// Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's +// protocol. We forced stream=false upstream, so the reply is always buffered here. + let text = String::from_utf8_lossy(&buf); + if !status.is_success() { + st.log("warn", format!("upstream {} on translated request ({})", status.as_u16(), provider_name)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), None); + return error_response( + StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), + &format!("CC Buddy upstream error: {}", text.chars().take(400).collect::()), + "api_error", + ); + } + let ir = match crate::protocol::decode_upstream_response(provider_wire, &text) { + Ok(ir) => ir, + Err(e) => { + st.log("error", format!("response translate ({:?}→{:?}) failed: {}", provider_wire, client_wire, e)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); + return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy response translation failed: {}", e), "api_error"); + } + }; + let mut usage = UsageAcc::default(); + if let Some(u) = ir.usage.as_ref() { + usage.input = u.prompt_tokens as i64; + usage.output = u.completion_tokens as i64; + usage.saw = true; + } + let (ct_out, body_bytes, terminal_response) = if wanted_stream { + let sse = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::encode_response_sse_with_context( + &ir, + client_model, + tool_context, + ) + } else { + crate::protocol::encode_client_response_sse(client_wire, &ir, client_model).unwrap_or_default() + }; + let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) + .then(|| responses_terminal_event(&sse)) + .flatten(); + ("text/event-stream", Bytes::from(sse), terminal) + } else { + let j = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::encode_response_with_context( + &ir, + client_model, + tool_context, + ) + } else { + crate::protocol::encode_client_response(client_wire, &ir, client_model).unwrap_or_else(|_| json!({})) + }; + let terminal = (client_wire == crate::protocol::Wire::OpenAiResponses) + .then(|| responses_terminal_object(&j)) + .flatten(); + ("application/json", Bytes::from(serde_json::to_vec(&j).unwrap_or_default()), terminal) + }; + if is_gemini_upstream { + let captured_calls = if client_wire == crate::protocol::Wire::OpenAiResponses { + terminal_response + .as_ref() + .filter(|terminal| terminal.kind == ResponsesTerminalKind::Completed) + .and_then(|terminal| terminal.response.as_ref()) + .map(|response| response_tool_calls_with_client_ids(&ir, response)) + .unwrap_or_default() + } else { + response_tool_calls(&ir) + }; + if !captured_calls.is_empty() { + st.thought_signatures.lock().await.remember( + &routing.provider_id, + request_session.as_deref(), + &captured_calls, + ); + } + } + if let Some(terminal) = terminal_response.as_ref() { + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st.codex_history + .record_response_scoped_with_metadata( + history_scope, + ResponseOrigin::Local, + true, + history_request, + response, + ) + .await; + } + } + } + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, status.as_u16(), Some(&usage)); + st.record_exchange(json!({ + "id": ex_id, "ts": now_ms(), "ms": started.elapsed().as_millis() as u64, + "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status.as_u16(), "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "upstreamRes": json!({ "status": status.as_u16(), "headers": vec_headers(&out_headers), "body": cap_text(&buf, 1024 * 1024) }), + "resHeaders": json!({ "content-type": ct_out, "x-ccbud-translated": format!("{:?}->{:?}", provider_wire, client_wire) }), + "resBody": cap_text(&body_bytes, 2 * 1024 * 1024), + })) + .await; + let mut builder = Response::builder() + .status(status.as_u16()) + .header("content-type", ct_out) + .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); + for (k, v) in &out_headers { + if k == "request-id" || k == "x-request-id" { + builder = builder.header(k, v); + } + } + return builder.body(Body::from(body_bytes)).unwrap(); +} diff --git a/src-tauri/src/gateway/forward.rs b/src-tauri/src/gateway/forward.rs new file mode 100644 index 0000000..f2b93b6 --- /dev/null +++ b/src-tauri/src/gateway/forward.rs @@ -0,0 +1,191 @@ +use axum::{ + http::{HeaderMap, Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::Value; +use std::sync::Arc; + +use crate::store; + +use super::finish::{finish_count_tokens, finish_head_root, finish_models_list}; +use super::finish_buffered::finish_buffered; +use super::finish_translated::finish_translated; +use super::redact::HOP_BY_HOP_RES; +use super::responses_history::NativeResponsesHistoryContext; +use super::retry::forward_with_retry; +use super::routing::Routing; +use super::state::GatewayState; +use super::stream_passthrough::stream_passthrough; +use super::stream_transcode::stream_transcoded; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn forward_and_finish( + st: &Arc, + config: &Value, + client: &reqwest::Client, + method: Method, + target: String, + v1_fallback_target: Option, + up_headers: HeaderMap, + out_body: Bytes, + original_target: String, + ex_url: String, + retry_enabled: bool, + retry_max: i64, + retry_base: i64, + provider_name: String, + base_url: &str, + is_models_list: bool, + is_count_tokens: bool, + is_head_root: bool, + client_codex: bool, + parsed: Option, + ex_id: u64, + started: std::time::Instant, + req_path: String, + routing: Routing, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, + native_responses_history: Option, + request_session: Option, + is_gemini_upstream: bool, + need_rewrite: bool, + active_pid: Option, + client_wire: crate::protocol::Wire, +) -> Response { + // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first + // response is retained until the fallback succeeds, so a failed fallback never masks the + // upstream's original error. + let (resp, used_v1_fallback, ex_url) = match forward_with_retry( + st, client, &method, target, v1_fallback_target, &up_headers, &out_body, + &original_target, ex_url, retry_enabled, retry_max, retry_base, &provider_name, + is_models_list, is_count_tokens, config, client_codex, &parsed, ex_id, started, + &req_path, &routing, + ) + .await + { + Ok(forwarded) => forwarded, + Err(response) => return response, + }; + + if used_v1_fallback { + if let Some(saved) = store::migrate_provider_base_url_to_v1(&routing.provider_id, base_url) { + st.log("info", format!("provider base URL updated with /v1 ({})", provider_name)); + st.emit("config:changed", saved); + } + } + + let status = resp.status(); + let ct = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + + if is_head_root && status.as_u16() == 404 { + return finish_head_root( + st, ex_id, started, method, req_path, provider_name, routing, ex_req_headers, + ex_req_body, ex_client_req, ex_translated, ex_url, + ) + .await; + } + + let mut out_headers: Vec<(String, String)> = vec![]; + for (k, v) in resp.headers().iter() { + let kn = k.as_str().to_ascii_lowercase(); + if HOP_BY_HOP_RES.contains(&kn.as_str()) { + continue; + } + if let Ok(s) = v.to_str() { + out_headers.push((k.as_str().to_string(), s.to_string())); + } + } + + // streaming SSE — rewrite model + sniff usage, line-buffered + if ct.contains("text/event-stream") { + // Incremental cross-protocol transcode: feed each upstream SSE line through a stateful + // transcoder that emits the client protocol's events as they arrive (true token-by-token). + if let Some((client_wire, provider_wire, tc, history_request, history_scope)) = translate + .as_ref() + .filter(|t| t.4) + .and_then(|t| { + // can_transcode_stream guarded `incremental`, so new() matches a wired pair. + crate::protocol::stream::Transcoder::new_with_context(t.1, t.0, &t.2, t.5.clone()).map(|tc| (t.0, t.1, tc, t.6.clone(), t.7.clone())) + }) + { + return stream_transcoded( + st, resp, tc, client_wire, provider_wire, history_request, history_scope, routing, + request_session, is_gemini_upstream, status, ex_id, started, method, req_path, + provider_name, out_headers, ex_req_headers, ex_req_body, ex_client_req, + ex_translated, ex_url, + ); + } + return stream_passthrough( + st, resp, need_rewrite, routing, method, req_path, provider_name, status, ex_id, + started, out_headers, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, native_responses_history, client_wire, + ); + } + + // buffered (reqwest auto-decoded gzip/br/deflate) + let buf = match resp.bytes().await { + Ok(buf) => buf, + Err(error) => { + let message = format!("upstream response body transport error: {}", error); + st.log("error", format!("{} ({})", message, provider_name)); + st.emit_request( + ex_id, + started, + &method, + &req_path, + &provider_name, + &routing, + StatusCode::BAD_GATEWAY.as_u16(), + None, + ); + return error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy: {}", message), "api_error"); + } + }; + + if is_count_tokens { + return finish_count_tokens( + st, status, buf, out_headers, &parsed, ex_id, started, method, req_path, + provider_name, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, + ) + .await; + } + + if is_models_list { + return finish_models_list( + st, status, buf, config, client_codex, active_pid, ex_id, started, method, req_path, + provider_name, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, + ex_url, + ) + .await; + } + + // Translated response: decode the (buffered) upstream reply → IR → re-encode to the client's + // protocol. We forced stream=false upstream, so the reply is always buffered here. + if let Some((client_wire, provider_wire, ref client_model, wanted_stream, _incremental, + ref tool_context, ref history_request, ref history_scope)) = translate { + return finish_translated( + st, buf, status, out_headers, client_wire, provider_wire, client_model, wanted_stream, + tool_context, history_request, history_scope, &routing, request_session, + is_gemini_upstream, ex_id, started, &method, &req_path, &provider_name, + &ex_req_headers, &ex_req_body, &ex_client_req, &ex_translated, &ex_url, + ) + .await; + } + + finish_buffered( + st, buf, &ct, native_responses_history, need_rewrite, routing, status, out_headers, ex_id, + started, method, req_path, provider_name, ex_req_headers, ex_req_body, ex_client_req, + ex_translated, ex_url, + ) + .await +} diff --git a/src-tauri/src/gateway/handler.rs b/src-tauri/src/gateway/handler.rs new file mode 100644 index 0000000..2e7c376 --- /dev/null +++ b/src-tauri/src/gateway/handler.rs @@ -0,0 +1,213 @@ +use axum::{ + body::to_bytes, + extract::State, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::store; + +use super::forward::forward_and_finish; +use super::history_prep::prepare_responses_history; +use super::prepare::{gateway_token_error, parse_request_model, prepare_translation, upstream_headers}; +use super::redact::{cap_text, redact_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::{client_is_codex, resolve_routing}; +use super::session::{codex_history_scope_for_session, request_session_id}; +use super::sse::is_responses_compact_path; +use super::state::GatewayState; +use super::targets::{build_target, cross_wire_compact_error, endpoint_targets, error_response}; + +/// The localhost reverse-proxy handler. Mirrors proxy.js `handle`. +pub(super) async fn handle(State(st): State>, req: axum::extract::Request) -> Response { + let started = std::time::Instant::now(); + let (parts, body) = req.into_parts(); + let method = parts.method; + let uri = parts.uri; + let in_headers = parts.headers; + let req_path = uri.path().to_string(); + let body_bytes = to_bytes(body, 64 * 1024 * 1024).await.unwrap_or_default(); + + let config = store::read_config(); + + if let Some(response) = gateway_token_error(&config, &in_headers) { + return response; + } + + let (parsed, requested_model) = parse_request_model(&in_headers, &body_bytes); + + let providers = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active_pid = providers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| providers.first()) + .and_then(|p| p.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + let known = match &active_pid { + Some(pid) => st.known.lock().await.get(pid).cloned(), + None => None, + }; + + let routing = match resolve_routing(requested_model.as_deref(), &config, known.as_ref()) { + Some(r) => r, + None => { + st.log("warn", "request rejected: no provider configured"); + return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured. Add one in the app.", "api_error"); + } + }; + let provider = match providers.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(routing.provider_id.as_str())) { + Some(p) => p, + None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: no provider configured.", "api_error"), + }; + let base_url = provider.get("baseUrl").and_then(|v| v.as_str()).unwrap_or(""); + let auth_token = provider.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let provider_name = provider + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(routing.provider_id.as_str()) + .to_string(); + + let need_rewrite = match (routing.client_facing_model.as_ref(), routing.outgoing_model.as_ref()) { + (Some(c), Some(o)) => c != o, + _ => false, + }; + let mut out_body = body_bytes.clone(); + if let (Some(p0), Some(out_model)) = (parsed.as_ref(), routing.outgoing_model.as_ref()) { + if Some(out_model) != requested_model.as_ref() { + let mut p = p0.clone(); + p["model"] = json!(out_model); + if let Ok(b) = serde_json::to_vec(&p) { + out_body = Bytes::from(b); + } + } + } + + let endpoint_pair = if method == Method::POST { + endpoint_targets(base_url, &uri) + } else { + None + }; + let (mut target, mut v1_fallback_target) = match endpoint_pair { + Some(pair) => pair, + None => match build_target(base_url, &uri) { + Some(t) => (t, None), + None => return error_response(StatusCode::BAD_GATEWAY, "CC Buddy: invalid provider baseUrl", "api_error"), + }, + }; + let is_models_list = method == Method::GET && (req_path.ends_with("/v1/models") || req_path.ends_with("/v1/models/")); + // Codex and Claude clients both GET /v1/models — tell them apart by client identity + // so each gets its own family's default model list. + let client_codex = client_is_codex(&in_headers); + let is_head_root = method == Method::HEAD && req_path == "/"; + let is_count_tokens = method == Method::POST + && (req_path.ends_with("/v1/messages/count_tokens") || req_path.ends_with("/v1/messages/count_tokens/")); + + // ---- protocol translation ---- + // When the client's wire protocol (inferred from the request path) differs from the provider's + // declared protocol, translate the request into the provider's format and remember to translate + // the response back. Same-protocol requests skip this entirely and keep the verbatim passthrough + // fast path below (so Anthropic→Anthropic behavior is byte-for-byte unchanged). Streaming pairs + // with an incremental transcoder (see protocol::stream::Transcoder) stream token-by-token; the + // rest force the upstream buffered (stream=false) and synthesize the client SSE from the full + // response. + let client_wire = crate::protocol::Wire::from_request_path(&uri); + let provider_wire = + crate::protocol::Wire::from_provider(provider.get("protocol").and_then(|v| v.as_str())); + let is_responses_compact = method == Method::POST && is_responses_compact_path(&req_path); + if method == Method::POST { + if let Some(response) = cross_wire_compact_error(&req_path, provider_wire) { + return response; + } + } + let request_session = parsed.as_ref().and_then(request_session_id); + // Conversation history belongs to the client session, not the provider: users may switch the + // active provider mid-turn and previous_response_id must still restore the same transcript. + // Sessionless requests can use direct response-id lookup, but never call-id fallback because + // call ids are routinely reused across unrelated agent runs. + let codex_history_scope = codex_history_scope_for_session(request_session.as_deref()); + let allow_codex_call_fallback = request_session.is_some(); + let mut prepared_responses_request: Option = None; + let mut native_responses_history: Option = None; + let mut history_localized = false; + if let Some(response) = prepare_responses_history( + &st, &parsed, &method, client_wire, provider_wire, &routing, &provider_name, + &codex_history_scope, allow_codex_call_fallback, is_responses_compact, + &mut out_body, &mut history_localized, &mut prepared_responses_request, + &mut native_responses_history, + ) + .await + { + return response; + } + let is_gemini_upstream = provider_wire == crate::protocol::Wire::OpenAiChat + && routing.outgoing_model.as_deref().unwrap_or("").to_ascii_lowercase().contains("gemini"); + // translate ctx: (client wire, provider wire, client model, wanted stream, incremental, + // request-scoped Responses tool metadata, full translated client request for history, + // client-session history scope) + // `incremental` = we can transcode the upstream stream event-by-event to the client (true + // token-by-token). Otherwise we force the upstream buffered and synthesize the client response. + let mut translate: Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)> = None; + if client_wire != provider_wire && method == Method::POST && !is_models_list && !is_count_tokens { + if let Some(p) = parsed.as_ref() { + if let Some(response) = prepare_translation( + &st, p, &routing, client_wire, provider_wire, base_url, is_gemini_upstream, + request_session.as_deref(), &prepared_responses_request, &codex_history_scope, + &mut out_body, &mut target, &mut v1_fallback_target, &mut translate, + ) + .await + { + return response; + } + } + } + + // upstream headers (sanitized + provider token swapped in) + let up_headers = upstream_headers(&in_headers, &translate, &auth_token); + + let ex_id = st.next_id(); + let ex_req_headers = redact_headers(&up_headers); + let ex_req_body = cap_text(&out_body, 4 * 1024 * 1024); + let original_target = target.clone(); + let ex_url = target.clone(); + // Client-side view of the exchange — what the gateway RECEIVED, before any translation — so + // the monitor can show a protocol translation's exact before/after (inbound URL/headers/body + // vs. the upstream URL/headers/body above). The body is duplicated only when a translation + // applies; for passthrough, reqBody already IS the client body (modulo the model rewrite). + let ex_translated = translate.as_ref().map(|t| format!("{} → {}", t.0.label(), t.1.label())); + let ex_client_req = { + let mut o = json!({ + "url": uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| req_path.clone()), + "headers": redact_headers(&in_headers), + }); + if ex_translated.is_some() || history_localized { + o["body"] = cap_text(&body_bytes, 1024 * 1024); + } + o + }; + + let insecure = config.get("insecureSkipVerify").and_then(|v| v.as_bool()).unwrap_or(false) + && target.starts_with("https:"); + let client = if insecure { &st.client_insecure } else { &st.client }; + + let rc = config.get("retry429").cloned().unwrap_or(json!({})); + let retry_enabled = rc.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let retry_max = rc.get("max").and_then(|v| v.as_i64()).unwrap_or(3); + let retry_base = rc.get("baseMs").and_then(|v| v.as_i64()).unwrap_or(500); + + forward_and_finish( + &st, &config, client, method, target, v1_fallback_target, up_headers, out_body, + original_target, ex_url, retry_enabled, retry_max, retry_base, provider_name, base_url, + is_models_list, is_count_tokens, is_head_root, client_codex, parsed, ex_id, started, + req_path, routing, ex_req_headers, ex_req_body, ex_client_req, ex_translated, translate, + native_responses_history, request_session, is_gemini_upstream, need_rewrite, active_pid, + client_wire, + ) + .await +} diff --git a/src-tauri/src/gateway/history_args.rs b/src-tauri/src/gateway/history_args.rs new file mode 100644 index 0000000..e5d830f --- /dev/null +++ b/src-tauri/src/gateway/history_args.rs @@ -0,0 +1,63 @@ +use serde_json::{json, Value}; + +pub(super) fn canonical_tool_arguments(arguments: &str) -> String { + if arguments.trim().is_empty() { + return "{}".to_string(); + } + serde_json::from_str::(arguments) + .map(|v| v.to_string()) + .unwrap_or_else(|_| arguments.to_string()) +} + +/// Codex records a model-emitted function call even when the host cannot parse its arguments, then +/// sends that failed call back on the next Responses turn beside the router error. OpenAI accepts +/// the arguments as an opaque string, but stricter chat providers (notably Gemini) parse every +/// historical `tool_calls[].function.arguments` value and reject the whole request when a model +/// appended prose or a second object. Preserve valid object arguments byte-for-byte so cached +/// thought signatures still match; otherwise salvage the first complete object, or wrap the raw +/// text in a valid object as a last resort. +pub(super) fn provider_safe_history_tool_arguments(arguments: &str) -> Option { + let trimmed = arguments.trim(); + if trimmed.is_empty() { + return Some("{}".to_string()); + } + match serde_json::from_str::(arguments) { + Ok(Value::Object(_)) => return None, + Ok(value) => return Some(json!({ "_ccbuddy_value": value }).to_string()), + Err(_) => {} + } + if let Some(Ok(Value::Object(object))) = serde_json::Deserializer::from_str(trimmed) + .into_iter::() + .next() + { + return Some(Value::Object(object).to_string()); + } + Some(json!({ "_ccbuddy_raw_arguments": arguments }).to_string()) +} + +pub(super) fn sanitize_provider_history_tool_arguments( + request: &mut llm_connector::types::ChatRequest, +) -> usize { + let mut repaired = 0usize; + for message in &mut request.messages { + let Some(calls) = message.tool_calls.as_mut() else { continue }; + for call in calls { + let Some(arguments) = provider_safe_history_tool_arguments(&call.function.arguments) + else { continue }; + call.function.arguments = arguments; + // A provider signature authenticates the exact call payload. Repaired arguments must + // use the documented synthetic-history fallback instead of a now-stale real signature. + call.thought_signature = None; + call.function.thought_signature = None; + repaired += 1; + } + } + repaired +} + +pub(super) fn current_tool_turn_start(request: &llm_connector::types::ChatRequest) -> usize { + request.messages.iter() + .rposition(|message| message.role == llm_connector::types::Role::User) + .map(|index| index + 1) + .unwrap_or(0) +} diff --git a/src-tauri/src/gateway/history_prep.rs b/src-tauri/src/gateway/history_prep.rs new file mode 100644 index 0000000..bee1532 --- /dev/null +++ b/src-tauri/src/gateway/history_prep.rs @@ -0,0 +1,152 @@ +use axum::{ + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::Value; +use std::sync::Arc; + +use super::responses_history::{ + decide_responses_compact_history, decide_responses_history, request_body_with_model, + NativeResponsesHistoryContext, ResponsesForwardMode, ResponsesHistoryError, +}; +use super::routing::Routing; +use super::state::GatewayState; +use super::targets::error_response; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_responses_history( + st: &Arc, + parsed: &Option, + method: &Method, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + routing: &Routing, + provider_name: &str, + codex_history_scope: &str, + allow_codex_call_fallback: bool, + is_responses_compact: bool, + out_body: &mut Bytes, + history_localized: &mut bool, + prepared_responses_request: &mut Option, + native_responses_history: &mut Option, +) -> Option { + if is_responses_compact { + if let Some(request) = parsed.as_ref() { + let previous_response_id = request + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut materialized_request = request.clone(); + let resolution = st + .codex_history + .materialize_request_scoped( + &codex_history_scope, + allow_codex_call_fallback, + &mut materialized_request, + ) + .await; + let forward = match decide_responses_compact_history( + &routing.provider_id, + &resolution, + ) { + Ok(forward) => forward, + Err(ResponsesHistoryError::Unavailable) => { + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!( + "CC Buddy cannot compact previous_response_id '{}' with provider '{}': its complete context cannot be materialized; retry with the owning Responses provider", + previous_response_id.as_deref().unwrap_or(""), + provider_name + ), + "invalid_request_error", + )); + } + }; + if forward == ResponsesForwardMode::Materialized { + *history_localized = true; + let Some(body) = request_body_with_model( + &materialized_request, + routing.outgoing_model.as_deref(), + ) else { + return Some(error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "CC Buddy failed to serialize locally materialized compact history", + "api_error", + )); + }; + *out_body = body; + } + } + } else if client_wire == crate::protocol::Wire::OpenAiResponses && *method == Method::POST { + if let Some(request) = parsed.as_ref() { + let previous_response_id = request + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let mut materialized_request = request.clone(); + let resolution = st + .codex_history + .materialize_request_scoped( + &codex_history_scope, + allow_codex_call_fallback, + &mut materialized_request, + ) + .await; + let decision = match decide_responses_history( + provider_wire, + &routing.provider_id, + &resolution, + ) { + Ok(decision) => decision, + Err(ResponsesHistoryError::Unavailable) => { + let detail = if resolution.previous_found { + "is known locally but its complete context cannot be materialized" + } else { + "is not available in local history" + }; + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!( + "CC Buddy cannot continue previous_response_id '{}' through provider '{}': it {}; retry with the owning Responses provider or start a new conversation", + previous_response_id.as_deref().unwrap_or(""), + provider_name, + detail + ), + "invalid_request_error", + )); + } + }; + if decision.forward == ResponsesForwardMode::Materialized { + *history_localized = true; + if provider_wire == crate::protocol::Wire::OpenAiResponses { + let Some(body) = request_body_with_model( + &materialized_request, + routing.outgoing_model.as_deref(), + ) else { + return Some(error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "CC Buddy failed to serialize locally materialized Responses history", + "api_error", + )); + }; + *out_body = body; + } + } + if provider_wire == crate::protocol::Wire::OpenAiResponses { + *native_responses_history = Some(NativeResponsesHistoryContext { + scope: codex_history_scope.to_string(), + request: materialized_request.clone(), + provider_id: routing.provider_id.clone(), + materializable: decision.descendant_materializable, + }); + } + *prepared_responses_request = Some(materialized_request); + } + } + None +} diff --git a/src-tauri/src/gateway/mock.rs b/src-tauri/src/gateway/mock.rs new file mode 100644 index 0000000..ff93b40 --- /dev/null +++ b/src-tauri/src/gateway/mock.rs @@ -0,0 +1,104 @@ +use axum::{ + body::{to_bytes, Body}, + http::StatusCode, + response::Response, + Router, +}; +use serde_json::{json, Value}; + +use super::targets::json_response; + +// ---- mock upstream + end-to-end gateway selftest (debug only) ---- + +/// Spawn an in-process mock Anthropic-style upstream on a random port. Echoes back the model +/// the gateway forwarded (proving the outgoing rewrite), with usage, as JSON or SSE. +pub async fn start_mock_upstream() -> Option { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.ok()?; + let port = listener.local_addr().ok()?.port(); + let app: Router = Router::new().fallback(mock_handler); + tauri::async_runtime::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Some(port) +} + +async fn mock_handler(req: axum::extract::Request) -> Response { + let (parts, body) = req.into_parts(); + let path = parts.uri.path().to_string(); + let bytes = to_bytes(body, 1024 * 1024).await.unwrap_or_default(); + if path.ends_with("/count_tokens") || path == "/" { + // Simulate a provider that implements neither count_tokens nor `HEAD /` → the gateway + // estimates locally / serves the health-probe fallback. + return Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{\"error\":\"not found\"}")) + .unwrap(); + } + let v: Value = serde_json::from_slice(&bytes).unwrap_or_else(|_| json!({})); + let stream = v.get("stream").and_then(|s| s.as_bool()).unwrap_or(false); + let model = v.get("model").and_then(|m| m.as_str()).unwrap_or("upstream-model").to_string(); + // OpenAI Chat endpoint: answer in Chat Completions shape so the gateway's protocol translation + // (Anthropic→chat request, chat→Anthropic response) can be exercised end-to-end. The gateway + // forces stream=false upstream when translating, so we only need the buffered form here. + if path.contains("/chat/completions") { + if stream { + // OpenAI Chat streaming chunks (text split across two chunks + a usage-bearing final + // chunk), so the incremental transcoder is exercised end-to-end. + let sse = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"hi \"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"from chat\"}}}}]}}\n\n\ + data: {{\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}],\"usage\":{{\"prompt_tokens\":12,\"completion_tokens\":5}}}}\n\n\ + data: [DONE]\n\n" + ); + let _ = &model; + return Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Body::from(sse)) + .unwrap(); + } + return json_response( + StatusCode::OK, + &json!({ + "id": "chatcmpl-mock", "object": "chat.completion", "created": 1, "model": model, + "choices": [{ "index": 0, "finish_reason": "stop", + "message": { "role": "assistant", "content": "hi from chat" } }], + "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }, + }), + ); + } + // OpenAI Responses endpoint: reply in Responses shape (buffered) so messages→responses can be + // exercised end-to-end. (The gateway forces stream=false upstream for the responses direction.) + if path.ends_with("/responses") || path.ends_with("/responses/") { + return json_response( + StatusCode::OK, + &json!({ + "id": "resp-mock", "object": "response", "created_at": 1, "model": model, "status": "completed", + "output": [{ "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "hi from responses" }] }], + "output_text": "hi from responses", + "usage": { "input_tokens": 14, "output_tokens": 6, "total_tokens": 20 }, + }), + ); + } + if stream { + // Anthropic streaming with a real text block, so the Anthropic→Responses incremental + // transcoder (Codex client) has content to carry, not just usage bookkeeping. + let sse = format!( + "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_mock\",\"model\":\"{m}\",\"usage\":{{\"input_tokens\":10,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}}}}}\n\nevent: content_block_start\ndata: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\nevent: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"hi from anthropic\"}}}}\n\nevent: content_block_stop\ndata: {{\"type\":\"content_block_stop\",\"index\":0}}\n\nevent: message_delta\ndata: {{\"type\":\"message_delta\",\"usage\":{{\"output_tokens\":7}}}}\n\nevent: message_stop\ndata: {{\"type\":\"message_stop\"}}\n\n", + m = model + ); + Response::builder() + .status(200) + .header("content-type", "text/event-stream") + .body(Body::from(sse)) + .unwrap() + } else { + json_response( + StatusCode::OK, + &json!({ "id":"msg_mock", "type":"message", "role":"assistant", "model":model, "content":[{"type":"text","text":"hi"}], "stop_reason":"end_turn", "usage":{"input_tokens":10,"output_tokens":7} }), + ) + } +} diff --git a/src-tauri/src/gateway/mod.rs b/src-tauri/src/gateway/mod.rs new file mode 100644 index 0000000..46ebb9f --- /dev/null +++ b/src-tauri/src/gateway/mod.rs @@ -0,0 +1,51 @@ +// Gateway core. +// +// Implements deterministic model routing and the localhost reverse proxy: header sanitizing, +// upstream forwarding, 429 retry, SSE streaming with model rewrite + usage sniffing, buffered-JSON +// model rewrite, /v1/models merge/synthesize, count_tokens fallback, HEAD / fallback, and bounded +// monitor exchange capture. +#![allow(dead_code)] + +mod capture; +mod finish; +mod finish_buffered; +mod finish_translated; +mod forward; +mod handler; +mod history_args; +mod history_prep; +mod mock; +mod models; +mod monitor; +mod prepare; +mod redact; +mod responses_history; +mod retry; +mod routing; +mod selftest; +mod selftest_routing; +mod selftest_xlate; +mod session; +mod signatures; +mod sse; +mod state; +mod stream_passthrough; +mod stream_transcode; +mod targets; + +#[allow(unused_imports)] +pub use mock::start_mock_upstream; +#[allow(unused_imports)] +pub use routing::{resolve_routing, Routing, CLAUDE_TIER_MODELS, CODEX_TIER_MODELS}; +pub use selftest::gateway_selftest; +pub use selftest_routing::routing_selftest; +pub use state::GatewayState; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_history; +#[cfg(test)] +mod tests_routing; +#[cfg(test)] +mod tests_signatures; diff --git a/src-tauri/src/gateway/models.rs b/src-tauri/src/gateway/models.rs new file mode 100644 index 0000000..e6ed67a --- /dev/null +++ b/src-tauri/src/gateway/models.rs @@ -0,0 +1,88 @@ +use serde_json::{json, Value}; +use std::collections::HashSet; + +use super::routing::{CLAUDE_TIER_MODELS, CODEX_TIER_MODELS}; + +// ---- /v1/models augmentation ---- +fn model_entry(id: &str) -> Value { + json!({ "type": "model", "id": id, "display_name": id, "created_at": "2025-01-01T00:00:00Z" }) +} +fn alias_entries(config: &Value) -> Vec { + let mut out = vec![]; + let mut seen = HashSet::new(); + if let Some(ps) = config.get("providers").and_then(|v| v.as_array()) { + for p in ps { + if let Some(ms) = p.get("models").and_then(|v| v.as_array()) { + for m in ms { + if let Some(a) = m.get("alias").and_then(|v| v.as_str()) { + if !a.is_empty() && seen.insert(a.to_string()) { + out.push(model_entry(a)); + } + } + } + } + } + } + out +} +/// Default tier models for the requesting client's family (Codex → gpt tiers, +/// Claude → claude tiers). +fn tier_entries(is_codex: bool) -> Vec { + if is_codex { + CODEX_TIER_MODELS.iter().map(|n| model_entry(n)).collect() + } else { + CLAUDE_TIER_MODELS.iter().map(|n| model_entry(n)).collect() + } +} +pub(super) fn merge_models(upstream: &Value, config: &Value, is_codex: bool) -> Value { + let data = upstream.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default(); + let mut have: HashSet = data + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + let mut adds = vec![]; + for a in alias_entries(config).into_iter().chain(tier_entries(is_codex)) { + let id = a.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if have.insert(id) { + adds.push(a); + } + } + let mut merged = upstream.clone(); + adds.extend(data); + merged["data"] = json!(adds); + merged +} +pub(super) fn synthesize_models(config: &Value, is_codex: bool) -> Value { + let mut out = alias_entries(config); + if out.is_empty() { + let ps = config.get("providers").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active = ps + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| ps.first()); + let mut seen = HashSet::new(); + if let Some(a) = active { + for k in ["defaultModel", "smallFastModel"] { + if let Some(id) = a.get(k).and_then(|v| v.as_str()) { + if !id.is_empty() && seen.insert(id.to_string()) { + out.push(model_entry(id)); + } + } + } + } + } + let mut have: HashSet = out + .iter() + .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + for e in tier_entries(is_codex) { + let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if have.insert(id) { + out.push(e); + } + } + let first = out.first().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); + let last = out.last().and_then(|m| m.get("id").cloned()).unwrap_or(Value::Null); + json!({ "data": out, "has_more": false, "first_id": first, "last_id": last }) +} diff --git a/src-tauri/src/gateway/monitor.rs b/src-tauri/src/gateway/monitor.rs new file mode 100644 index 0000000..45e1ee3 --- /dev/null +++ b/src-tauri/src/gateway/monitor.rs @@ -0,0 +1,66 @@ +use axum::http::Method; +use serde_json::{json, Value}; +use std::sync::atomic::Ordering; +use tauri::Emitter; + +use super::routing::Routing; +use super::state::GatewayState; + +impl GatewayState { + pub(super) fn next_id(&self) -> u64 { + self.seq.fetch_add(1, Ordering::Relaxed) + 1 + } + /// Bounded live-debugging capture: keep only the most recent exchanges (matches the monitor + /// stream's 100-row window so every visible row can open its detail). + pub async fn record_exchange(&self, ex: Value) { + let mut buf = self.exchanges.lock().await; + buf.push_back(ex); + while buf.len() > 100 { + buf.pop_front(); + } + } + pub async fn monitor_get(&self, id: i64) -> Value { + let buf = self.exchanges.lock().await; + buf.iter() + .rev() + .find(|e| e.get("id").and_then(|v| v.as_i64()) == Some(id)) + .cloned() + .unwrap_or(Value::Null) + } + pub async fn monitor_clear(&self) { + self.exchanges.lock().await.clear(); + } + pub async fn monitor_recent(&self) -> Value { + self.exchanges.lock().await.back().cloned().unwrap_or(Value::Null) + } + + pub(super) fn emit_request(&self, id: u64, started: std::time::Instant, method: &Method, path: &str, provider: &str, routing: &Routing, status: u16, usage: Option<&UsageAcc>) { + let (it, ot, cr, cc) = usage + .map(|u| (u.input, u.output, u.cache_read, u.cache_creation)) + .unwrap_or((0, 0, 0, 0)); + let _ = self.app.emit( + "gateway:request", + json!({ + "id": id, + "method": method.as_str(), + "path": path, + "provider": provider, + "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, + "clientFacingModel": routing.client_facing_model, + "status": status, + "ms": started.elapsed().as_millis() as u64, + "inputTokens": it, "outputTokens": ot, "cacheRead": cr, "cacheCreation": cc, + }), + ); + } +} + +#[derive(Default, Clone)] +pub(super) struct UsageAcc { + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, + pub(super) saw: bool, +} diff --git a/src-tauri/src/gateway/prepare.rs b/src-tauri/src/gateway/prepare.rs new file mode 100644 index 0000000..8232b51 --- /dev/null +++ b/src-tauri/src/gateway/prepare.rs @@ -0,0 +1,194 @@ +use axum::{ + http::{HeaderMap, HeaderValue, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::redact::HOP_BY_HOP_REQ; +use super::responses_history::apply_responses_chat_request_controls; +use super::routing::Routing; +use super::signatures::apply_gemini_signature_fallback; +use super::state::GatewayState; +use super::targets::error_response; +use super::history_args::sanitize_provider_history_tool_arguments; + +pub(super) fn gateway_token_error(config: &Value, in_headers: &HeaderMap) -> Option { + // Optional local gateway token (defense in depth; already bound to localhost). + if config.get("requireToken").and_then(|v| v.as_bool()).unwrap_or(false) { + let token = config.get("gatewayToken").and_then(|v| v.as_str()).unwrap_or(""); + if !token.is_empty() { + let auth = in_headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); + let bearer = auth + .strip_prefix("Bearer ") + .or_else(|| auth.strip_prefix("bearer ")); + let presented = bearer.unwrap_or_else(|| { + in_headers.get("x-api-key").and_then(|v| v.to_str().ok()).unwrap_or("") + }); + if presented != token { + return Some(error_response(StatusCode::UNAUTHORIZED, "CC Buddy: invalid gateway token", "authentication_error")); + } + } + } + None +} + +pub(super) fn parse_request_model( + in_headers: &HeaderMap, + body_bytes: &Bytes, +) -> (Option, Option) { + let is_json = in_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|s| s.contains("application/json")) + .unwrap_or(false); + let mut parsed: Option = None; + let mut requested_model: Option = None; + if !body_bytes.is_empty() && is_json { + if let Ok(v) = serde_json::from_slice::(&body_bytes) { + requested_model = v.get("model").and_then(|m| m.as_str()).map(|s| s.to_string()); + parsed = Some(v); + } + } + (parsed, requested_model) +} + +pub(super) fn upstream_headers( + in_headers: &HeaderMap, + translate: &Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, + auth_token: &str, +) -> HeaderMap { + let mut up_headers = HeaderMap::new(); + for (k, v) in in_headers.iter() { + let kn = k.as_str().to_ascii_lowercase(); + if HOP_BY_HOP_REQ.contains(&kn.as_str()) { + continue; + } + up_headers.insert(k.clone(), v.clone()); + } + up_headers.insert(axum::http::header::ACCEPT_ENCODING, HeaderValue::from_static("identity")); + // A translated Anthropic upstream needs the anthropic-version header; OpenAI-family clients + // (Codex) never send one. + if translate.as_ref().map(|t| t.1) == Some(crate::protocol::Wire::Anthropic) + && !up_headers.contains_key("anthropic-version") + { + up_headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); + } + if !auth_token.is_empty() { + // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips + // providers that reject having the two auth headers present at once (matches provider_test). + // Both inbound auth headers are already stripped by HOP_BY_HOP_REQ above. + if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", auth_token)) { + up_headers.insert(axum::http::header::AUTHORIZATION, val); + } + } + up_headers +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn prepare_translation( + st: &Arc, + p: &Value, + routing: &Routing, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + base_url: &str, + is_gemini_upstream: bool, + request_session: Option<&str>, + prepared_responses_request: &Option, + codex_history_scope: &str, + out_body: &mut Bytes, + target: &mut String, + v1_fallback_target: &mut Option, + translate: &mut Option<(crate::protocol::Wire, crate::protocol::Wire, String, bool, bool, + crate::protocol::openai_responses::CodexToolContext, + Value, + String)>, +) -> Option { + let wanted_stream = p.get("stream").and_then(|v| v.as_bool()).unwrap_or(false); + let incremental = wanted_stream + && crate::protocol::can_transcode_stream(provider_wire, client_wire); + let client_model = routing.client_facing_model.clone().unwrap_or_default(); + let outgoing = routing.outgoing_model.clone().unwrap_or_default(); + let request_for_translation = prepared_responses_request + .clone() + .unwrap_or_else(|| p.clone()); + let decoded = if client_wire == crate::protocol::Wire::OpenAiResponses { + crate::protocol::openai_responses::decode_request_with_context( + &request_for_translation, + ) + } else { + crate::protocol::decode_client_request(client_wire, &request_for_translation).map( + |request| { + ( + request, + crate::protocol::openai_responses::CodexToolContext::default(), + ) + }, + ) + }; + let (mut ir, tool_context) = match decoded { + Ok(decoded) => decoded, + Err(e) => { + st.log( + "warn", + format!("client protocol decode ({:?}) failed: {}", client_wire, e), + ); + return Some(error_response( + StatusCode::BAD_REQUEST, + &format!("CC Buddy invalid client request: {}", e), + "invalid_request_error", + )); + } + }; + // Neither the Anthropic nor the Responses client wire round-trips Gemini's thought + // signature, so every translated client (Claude Code AND Codex) needs the session-cache + // restore + documented fallback sentinel — Gemini 3 rejects current-turn function calls + // without a signature (400). + if is_gemini_upstream { + st.thought_signatures.lock().await.restore( + &routing.provider_id, + request_session.as_deref(), + &mut ir, + ); + } + // Repair after signature restoration so any call whose provider-visible payload changes + // cannot accidentally regain a cached signature that authenticated different bytes. + if provider_wire == crate::protocol::Wire::OpenAiChat { + sanitize_provider_history_tool_arguments(&mut ir); + } + if is_gemini_upstream { + apply_gemini_signature_fallback(&mut ir); + } + let translated_body = crate::protocol::encode_upstream_request(provider_wire, &ir, &outgoing, incremental); + match translated_body { + Ok(mut body) => { + if client_wire == crate::protocol::Wire::OpenAiResponses + && provider_wire == crate::protocol::Wire::OpenAiChat + { + apply_responses_chat_request_controls(&mut body, &request_for_translation); + } + // Ask OpenAI-family upstreams to include usage in the final stream chunk. + if incremental && provider_wire == crate::protocol::Wire::OpenAiChat { + body["stream_options"] = json!({ "include_usage": true }); + } + if let Ok(b) = serde_json::to_vec(&body) { + *out_body = Bytes::from(b); + } + // Send to the provider protocol's endpoint (drop the inbound path/query). + *target = provider_wire.upstream_url(base_url); + *v1_fallback_target = provider_wire.v1_fallback_url(base_url); + *translate = Some((client_wire, provider_wire, client_model, wanted_stream, incremental, + tool_context, request_for_translation, codex_history_scope.to_string())); + } + Err(e) => { + st.log("error", format!("protocol translate ({:?}→{:?}) failed: {}", client_wire, provider_wire, e)); + return Some(error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy protocol translation failed: {}", e), "api_error")); + } + } + None +} diff --git a/src-tauri/src/gateway/redact.rs b/src-tauri/src/gateway/redact.rs new file mode 100644 index 0000000..6abf855 --- /dev/null +++ b/src-tauri/src/gateway/redact.rs @@ -0,0 +1,43 @@ +use axum::http::HeaderMap; +use serde_json::{json, Value}; + +fn redact_value(key: &str, val: &str) -> String { + let k = key.to_ascii_lowercase(); + if matches!(k.as_str(), "authorization" | "x-api-key" | "cookie" | "set-cookie" | "proxy-authorization" | "x-goog-api-key") { + "••••••(已隐藏)".to_string() + } else { + val.to_string() + } +} +pub(super) fn redact_headers(h: &HeaderMap) -> Value { + let mut o = serde_json::Map::new(); + for (k, v) in h.iter() { + o.insert(k.as_str().to_string(), Value::String(redact_value(k.as_str(), v.to_str().unwrap_or("")))); + } + Value::Object(o) +} +pub(super) fn vec_headers(pairs: &[(String, String)]) -> Value { + let mut o = serde_json::Map::new(); + for (k, v) in pairs { + o.insert(k.clone(), Value::String(redact_value(k, v))); + } + Value::Object(o) +} +pub(super) fn cap_text(bytes: &[u8], cap: usize) -> Value { + let total = bytes.len(); + let end = total.min(cap); + json!({ "text": String::from_utf8_lossy(&bytes[..end]), "bytes": total, "truncated": total.saturating_sub(cap) }) +} + +pub(super) fn now_ms() -> i64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) +} + +pub(super) const HOP_BY_HOP_REQ: &[&str] = &[ + "host", "content-length", "authorization", "x-api-key", "accept-encoding", "cookie", + "proxy-authorization", "connection", "proxy-connection", "transfer-encoding", +]; +pub(super) const HOP_BY_HOP_RES: &[&str] = &[ + "content-length", "transfer-encoding", "content-encoding", "connection", "keep-alive", + "proxy-authenticate", "proxy-connection", "set-cookie", +]; diff --git a/src-tauri/src/gateway/responses_history.rs b/src-tauri/src/gateway/responses_history.rs new file mode 100644 index 0000000..0a03977 --- /dev/null +++ b/src-tauri/src/gateway/responses_history.rs @@ -0,0 +1,112 @@ +use bytes::Bytes; +use serde_json::{json, Value}; + +use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesForwardMode { + Original, + Materialized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ResponsesHistoryDecision { + pub(super) forward: ResponsesForwardMode, + pub(super) descendant_materializable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesHistoryError { + Unavailable, +} + +#[derive(Clone)] +pub(super) struct NativeResponsesHistoryContext { + pub(super) scope: String, + pub(super) request: Value, + pub(super) provider_id: String, + pub(super) materializable: bool, +} + +pub(super) fn decide_responses_history( + provider_wire: crate::protocol::Wire, + provider_id: &str, + resolution: &HistoryResolution, +) -> Result { + if !resolution.had_previous_response_id { + return Ok(ResponsesHistoryDecision { + forward: if resolution.changed > 0 { + ResponsesForwardMode::Materialized + } else { + ResponsesForwardMode::Original + }, + descendant_materializable: true, + }); + } + + if provider_wire != crate::protocol::Wire::OpenAiResponses { + return (resolution.previous_found && resolution.previous_materialized) + .then_some(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + .ok_or(ResponsesHistoryError::Unavailable); + } + + if !resolution.previous_found { + // Restart compatibility: the selected native provider may still own this id even though + // the gateway cache does not. Keep the id intact, but do not make descendants portable. + return Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }); + } + + let same_native_owner = matches!( + resolution.previous_origin.as_ref(), + Some(ResponseOrigin::Native(owner)) if owner == provider_id + ); + if same_native_owner { + return Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: resolution.previous_materialized, + }); + } + + resolution + .previous_materialized + .then_some(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + .ok_or(ResponsesHistoryError::Unavailable) +} + +pub(super) fn decide_responses_compact_history( + provider_id: &str, + resolution: &HistoryResolution, +) -> Result { + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + provider_id, + resolution, + ) + .map(|decision| decision.forward) +} + +pub(super) fn request_body_with_model(request: &Value, outgoing_model: Option<&str>) -> Option { + let mut request = request.clone(); + if let (Some(object), Some(model)) = (request.as_object_mut(), outgoing_model) { + object.insert("model".to_string(), Value::String(model.to_string())); + } + serde_json::to_vec(&request).ok().map(Bytes::from) +} + +pub(super) fn apply_responses_chat_request_controls(body: &mut Value, request: &Value) { + if let Some(parallel_tool_calls) = request + .get("parallel_tool_calls") + .and_then(Value::as_bool) + { + body["parallel_tool_calls"] = json!(parallel_tool_calls); + } +} diff --git a/src-tauri/src/gateway/retry.rs b/src-tauri/src/gateway/retry.rs new file mode 100644 index 0000000..1f70cb6 --- /dev/null +++ b/src-tauri/src/gateway/retry.rs @@ -0,0 +1,122 @@ +use axum::{ + body::Body, + http::{HeaderMap, Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::models::synthesize_models; +use super::routing::Routing; +use super::state::GatewayState; +use super::targets::{error_response, json_response, retry_delay}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn forward_with_retry( + st: &Arc, + client: &reqwest::Client, + method: &Method, + mut target: String, + mut v1_fallback_target: Option, + up_headers: &HeaderMap, + out_body: &Bytes, + original_target: &str, + mut ex_url: String, + retry_enabled: bool, + retry_max: i64, + retry_base: i64, + provider_name: &str, + is_models_list: bool, + is_count_tokens: bool, + config: &Value, + client_codex: bool, + parsed: &Option, + ex_id: u64, + started: std::time::Instant, + req_path: &str, + routing: &Routing, +) -> Result<(reqwest::Response, bool, String), Response> { + // Forward with the existing 429 retry plus one compatibility attempt at `/v1`. The first + // response is retained until the fallback succeeds, so a failed fallback never masks the + // upstream's original error. + let mut attempt = 0i64; + let mut tried_v1_fallback = false; + let mut used_v1_fallback = false; + let mut first_path_error: Option = None; + let resp = loop { + let r = client + .request(method.clone(), &target) + .headers(up_headers.clone()) + .body(out_body.clone()) + .send() + .await; + match r { + Ok(resp) => { + if !tried_v1_fallback + && retry_enabled + && resp.status().as_u16() == 429 + && attempt < retry_max + { + let ra = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).map(|s| s.to_string()); + let delay = retry_delay(ra.as_deref(), attempt, retry_base); + st.log("warn", format!("upstream 429 — retry {}/{} in {}ms ({})", attempt + 1, retry_max, delay, provider_name)); + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + attempt += 1; + continue; + } + if !tried_v1_fallback + && crate::protocol::should_try_v1_fallback(resp.status().as_u16()) + { + if let Some(fallback) = v1_fallback_target.take() { + first_path_error = Some(resp); + target = fallback; + ex_url = target.clone(); + tried_v1_fallback = true; + attempt = 0; + continue; + } + } + if tried_v1_fallback { + if resp.status().is_success() { + used_v1_fallback = true; + ex_url = target.clone(); + break resp; + } + ex_url = original_target.to_string(); + break first_path_error.take().expect("v1 fallback keeps the first response"); + } + break resp; + } + Err(e) => { + if tried_v1_fallback { + if let Some(first) = first_path_error.take() { + ex_url = original_target.to_string(); + st.log("info", format!("/v1 compatibility retry failed: {} ({})", e, provider_name)); + break first; + } + } + if is_models_list { + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return Err(json_response(StatusCode::OK, &synthesize_models(&config, client_codex))); + } + if is_count_tokens { + let est = crate::counttokens::estimate_input_tokens(parsed.as_ref().unwrap_or(&Value::Null)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 200, None); + return Err(Response::builder() + .status(200) + .header("content-type", "application/json") + .header("x-ccbud-tokens", "estimated") + .header("x-ccbud-upstream-status", "error") + .body(Body::from(serde_json::to_vec(&json!({ "input_tokens": est })).unwrap_or_default())) + .unwrap()); + } + st.log("error", format!("upstream error: {}", e)); + st.emit_request(ex_id, started, &method, &req_path, &provider_name, &routing, 502, None); + return Err(error_response(StatusCode::BAD_GATEWAY, &format!("CC Buddy upstream error: {}", e), "api_error")); + } + } + }; + + Ok((resp, used_v1_fallback, ex_url)) +} diff --git a/src-tauri/src/gateway/routing.rs b/src-tauri/src/gateway/routing.rs new file mode 100644 index 0000000..ab326e8 --- /dev/null +++ b/src-tauri/src/gateway/routing.rs @@ -0,0 +1,178 @@ +use axum::http::HeaderMap; +use serde_json::Value; +use std::collections::HashSet; + +/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code). +pub const CLAUDE_TIER_MODELS: &[&str] = &[ + "claude-fable-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", +]; + +/// Stable Codex model identities advertised by the gateway. These names are understood by the +/// current Codex CLI and keep its ordinary function/custom tool registry enabled; synthetic +/// `gpt-5.6-sol*` identities select Codex's code-mode metadata and produce an empty Responses +/// `tools` array against a generic custom provider. +pub const CODEX_TIER_MODELS: &[&str] = &["gpt-5.4", "gpt-5.4-mini"]; + +/// Which coding-agent family a model name belongs to. Claude Code sends `claude-*`, +/// Codex sends `gpt-*`; each names its primary vs fast tier differently. +enum ModelFamily { + Claude, + Codex, + Other, +} +fn model_family(name: &str) -> ModelFamily { + let n = name.to_ascii_lowercase(); + if n.starts_with("claude-") || n.starts_with("claude_") { + ModelFamily::Claude + } else if n.starts_with("gpt-") || n.starts_with("gpt_") { + ModelFamily::Codex + } else { + ModelFamily::Other + } +} +/// Claude fast/light tier = the haiku models; fable/opus/sonnet (and any other +/// claude-*) route to the primary model. +fn is_claude_fast(name: &str) -> bool { + name.to_ascii_lowercase().contains("haiku") +} +/// The stable auto-connect identity and legacy `sol` / `terra` aliases route to primary. Explicit +/// small-model identities route to fast; other foreign `gpt-*` names retain the historical fast +/// fallback instead of unexpectedly consuming the primary provider model. +fn is_codex_primary(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if lower == "gpt-5.4" { + return true; + } + let segments = lower + .split(|c| c == '-' || c == '_') + .collect::>(); + !segments + .iter() + .any(|seg| matches!(*seg, "mini" | "nano" | "luna" | "spark")) + && segments.iter().any(|seg| matches!(*seg, "sol" | "terra")) +} +/// True if the request comes from a Codex/OpenAI-family client (vs Claude), detected by +/// the client's self-reported identity — User-Agent, or Codex's `originator` header. +pub(super) fn client_is_codex(h: &HeaderMap) -> bool { + let field = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase(); + field("user-agent").contains("codex") || field("originator").contains("codex") +} + +#[derive(Debug, Clone)] +pub struct Routing { + pub provider_id: String, + pub outgoing_model: Option, + pub client_facing_model: Option, +} + +/// Decide how to route a request and translate its model name. Mirrors proxy.js `resolveRouting`. +pub fn resolve_routing( + requested_model: Option<&str>, + config: &Value, + known_models: Option<&HashSet>, +) -> Option { + let providers = config.get("providers")?.as_array()?; + if providers.is_empty() { + return None; + } + let active_id = config.get("activeProviderId").and_then(|v| v.as_str()); + let active = providers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == active_id) + .or_else(|| providers.first())?; + let pid = active.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + let pass = |m: &str| { + Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(m.to_string()), + client_facing_model: Some(m.to_string()), + }) + }; + + let requested = match requested_model { + None => { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: None, + client_facing_model: None, + }) + } + Some(m) => m, + }; + + let primary = active.get("defaultModel").and_then(|v| v.as_str()).unwrap_or(""); + let light = active.get("smallFastModel").and_then(|v| v.as_str()).unwrap_or(""); + let models = active.get("models").and_then(|v| v.as_array()); + + if let Some(ms) = models { + for m in ms { + let alias = m.get("alias").and_then(|v| v.as_str()).unwrap_or(""); + let upstream = m.get("upstream").and_then(|v| v.as_str()).unwrap_or(""); + if !alias.is_empty() && alias == requested && !upstream.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(upstream.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + } + } + if requested == primary || requested == light { + return pass(requested); + } + if let Some(ms) = models { + for m in ms { + if m.get("upstream").and_then(|v| v.as_str()) == Some(requested) { + return pass(requested); + } + } + } + if let Some(known) = known_models { + if known.contains(requested) { + return pass(requested); + } + } + // Codex connects with the sentinel model "gpt-5.5-ccbud" — a name Codex's model-family + // detection accepts (gpt-5.5 prefix), so it doesn't warn about an unknown model. Route the + // sentinel to the active provider's PRIMARY model (never the lightweight fallback). + if requested.ends_with("-ccbud") { + let target = if !primary.is_empty() { primary } else { light }; + if !target.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(target.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + } + let map_default = active + .get("mapDefaultModels") + .map(|v| v.as_bool().unwrap_or(true)) + .unwrap_or(true); + if !map_default { + return pass(requested); + } + let big = if !primary.is_empty() { primary } else { light }; + let small = if !light.is_empty() { light } else { primary }; + // Claude and Codex name their primary vs fast tiers differently, so classify by + // family: claude-haiku* → fast, other claude-* → primary; gpt-*-sol / gpt-*-terra + // → primary, other gpt-* → fast; anything else → fast. + let target = match model_family(requested) { + ModelFamily::Claude => if is_claude_fast(requested) { small } else { big }, + ModelFamily::Codex => if is_codex_primary(requested) { big } else { small }, + ModelFamily::Other => small, + }; + if !target.is_empty() { + return Some(Routing { + provider_id: pid.clone(), + outgoing_model: Some(target.to_string()), + client_facing_model: Some(requested.to_string()), + }); + } + pass(requested) +} diff --git a/src-tauri/src/gateway/selftest.rs b/src-tauri/src/gateway/selftest.rs new file mode 100644 index 0000000..f376705 --- /dev/null +++ b/src-tauri/src/gateway/selftest.rs @@ -0,0 +1,118 @@ +use serde_json::{json, Value}; + +use crate::store; + +use super::mock::start_mock_upstream; +use super::selftest_xlate::{selftest_reverse_and_codex, selftest_translation}; + +/// End-to-end gateway test against the mock upstream: routing + response model rewrite for both +/// buffered JSON and streaming SSE. Mutates CCBUD_HOME config (only called in a throwaway run). +pub async fn gateway_selftest(gport: u16) -> Value { + if gport == 0 { + return json!({ "err": "gateway not running" }); + } + let mock = match start_mock_upstream().await { + Some(p) => p, + None => return json!({ "err": "mock failed to start" }), + }; + let cfg = json!({ "port": gport, "activeProviderId":"mock", "providers":[ + { "id":"mock","name":"Mock","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","defaultModel":"upstream-model","smallFastModel":"upstream-model","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"upstream-model"}] } + ]}); + store::write_config(cfg); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{}/v1/messages", gport); + + let ns = client + .post(&base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (ns_status, ns_model) = match ns { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + (s, j.get("model").and_then(|m| m.as_str()).unwrap_or("").to_string()) + } + Err(e) => (0, format!("ERR:{}", e)), + }; + + let stm = client + .post(&base) + .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (st_status, st_text) = match stm { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // count_tokens — mock 404s, so the gateway must estimate locally + let ct = client + .post(format!("http://127.0.0.1:{}/v1/messages/count_tokens", gport)) + .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hello world this is a token counting test"}] })) + .send() + .await; + let (ct_status, ct_tokens, ct_estimated) = match ct { + Ok(r) => { + let s = r.status().as_u16(); + let estimated = r.headers().get("x-ccbud-tokens").and_then(|v| v.to_str().ok()) == Some("estimated"); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + (s, j.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(-1), estimated) + } + Err(_) => (0, -1, false), + }; + let (cfg2, tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model, tx_st_status, tx_st_text, + rx_status, rx_anthropic, rx_text) = selftest_translation(&client, &base, gport, mock).await; + + let (rev_status, rev_is_chat, rev_text, cdx_status, cdx_is_response, cdx_text, cdx_st_status, + cdx_st_text, cdx_chat_status, cdx_chat_text) = + selftest_reverse_and_codex(&client, gport, mock, &cfg2).await; + + json!({ + "nonStreamStatus": ns_status, + "nonStreamModel": ns_model, + "nonStreamRewritten": ns_model == "test-alias", + "xlateResponsesStatus": rx_status, + "xlateResponsesIsAnthropic": rx_anthropic, + "xlateResponsesText": rx_text, + "revChatStatus": rev_status, + "revChatIsChatCompletion": rev_is_chat, + "revChatText": rev_text, + "streamStatus": st_status, + "streamHasStart": st_text.contains("message_start"), + "streamRewritten": st_text.contains("\"test-alias\"") && !st_text.contains("upstream-model"), + "countTokensStatus": ct_status, + "countTokensEstimated": ct_estimated, + "countTokens": ct_tokens, + // protocol translation (messages→chat) + "xlateNonStreamStatus": tx_ns_status, + "xlateNonStreamIsAnthropic": tx_ns_anthropic, + "xlateNonStreamText": tx_ns_text, + "xlateNonStreamModel": tx_ns_model, + "xlateStreamStatus": tx_st_status, + "xlateStreamHasStart": tx_st_text.contains("message_start"), + "xlateStreamHasStop": tx_st_text.contains("message_stop"), + // incremental transcode: OpenAI chunks → Anthropic text_delta events (text split across + // chunks), a real content_block_delta, and end_turn stop. + "xlateStreamIncremental": tx_st_text.contains("content_block_delta") && tx_st_text.contains("text_delta"), + "xlateStreamText": tx_st_text.contains("from chat"), + "xlateStreamStop": tx_st_text.contains("\"stop_reason\":\"end_turn\""), + // Codex (Responses client): buffered translate + incremental stream transcoders. Codex + // materializes items from response.output_item.done and requires response.completed. + "codexNonStreamStatus": cdx_status, + "codexNonStreamIsResponse": cdx_is_response, + "codexNonStreamText": cdx_text, + "codexAnthropicStreamStatus": cdx_st_status, + "codexAnthropicStreamDelta": cdx_st_text.contains("response.output_text.delta"), + "codexAnthropicStreamItemDone": cdx_st_text.contains("response.output_item.done"), + "codexAnthropicStreamCompleted": cdx_st_text.contains("response.completed"), + "codexAnthropicStreamText": cdx_st_text.contains("hi from anthropic"), + "codexChatStreamStatus": cdx_chat_status, + "codexChatStreamDelta": cdx_chat_text.contains("response.output_text.delta"), + "codexChatStreamItemDone": cdx_chat_text.contains("response.output_item.done"), + "codexChatStreamCompleted": cdx_chat_text.contains("response.completed"), + "codexChatStreamText": cdx_chat_text.contains("from chat"), + }) +} diff --git a/src-tauri/src/gateway/selftest_routing.rs b/src-tauri/src/gateway/selftest_routing.rs new file mode 100644 index 0000000..e9766bb --- /dev/null +++ b/src-tauri/src/gateway/selftest_routing.rs @@ -0,0 +1,57 @@ +use serde_json::{json, Value}; +use std::collections::HashSet; + +use super::routing::{resolve_routing, Routing}; + +/// In-binary equivalent of test/selftest.js's 8 routing unit checks. +pub fn routing_selftest() -> Value { + let config = json!({ "port":0, "activeProviderId":"glm", "providers":[ + { "id":"glm","name":"GLM","baseUrl":"https://x","authToken":"","defaultModel":"glm-5.1","smallFastModel":"glm-5.1","mapDefaultModels":true,"models":[{"alias":"claude-opus-4.8[1m]","upstream":"glm-5.1"}] } + ]}); + let cfg2 = json!({ "port":0, "activeProviderId":"main", "providers":[ + { "id":"main","name":"Main","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big-model","smallFastModel":"small-model","mapDefaultModels":true,"models":[{"alias":"my-alias","upstream":"aliased-up"}] }, + { "id":"other","name":"Other","baseUrl":"http://127.0.0.1:2","authToken":"k","defaultModel":"other-big","smallFastModel":"other-small","mapDefaultModels":true,"models":[{"alias":"other-alias","upstream":"other-up"}] } + ]}); + let off = json!({ "port":0, "activeProviderId":"m", "providers":[ + { "id":"m","name":"M","baseUrl":"http://127.0.0.1:1","authToken":"k","defaultModel":"big","smallFastModel":"small","mapDefaultModels":false,"models":[] } + ]}); + + let out = |r: &Option| r.as_ref().and_then(|x| x.outgoing_model.clone()); + let cf = |r: &Option| r.as_ref().and_then(|x| x.client_facing_model.clone()); + let pidf = |r: &Option| r.as_ref().map(|x| x.provider_id.clone()); + + let mut fails: Vec = vec![]; + let mut n = 0; + let mut chk = |name: &str, cond: bool| { + n += 1; + if !cond { + fails.push(name.to_string()); + } + }; + + let r = resolve_routing(Some("claude-opus-4.8[1m]"), &config, None); + chk("1 alias→upstream", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("claude-opus-4.8[1m]")); + let r = resolve_routing(Some("glm-5.1"), &config, None); + chk("2 real passthrough", out(&r).as_deref() == Some("glm-5.1") && cf(&r).as_deref() == Some("glm-5.1")); + let r = resolve_routing(Some("claude-3-5-haiku-20241022"), &cfg2, None); + chk("3 haiku→light", out(&r).as_deref() == Some("small-model")); + let r = resolve_routing(Some("claude-sonnet-4-6"), &cfg2, None); + chk("4 sonnet→primary", out(&r).as_deref() == Some("big-model")); + let r = resolve_routing(Some("gpt-4-turbo"), &cfg2, None); + chk("5 foreign→light", out(&r).as_deref() == Some("small-model")); + let mut known = HashSet::new(); + known.insert("glm-5.2".to_string()); + let r = resolve_routing(Some("glm-5.2"), &cfg2, Some(&known)); + chk("6 known passthrough", out(&r).as_deref() == Some("glm-5.2")); + let r = resolve_routing(Some("other-alias"), &cfg2, None); + chk("7 stays on active", pidf(&r).as_deref() == Some("main") && out(&r).as_deref() == Some("small-model")); + let r = resolve_routing(Some("whatever-x"), &off, None); + chk("8 mapoff passthrough", out(&r).as_deref() == Some("whatever-x")); + let r = resolve_routing(Some("gpt-5.5-ccbud"), &cfg2, None); + chk( + "9 codex sentinel→primary", + out(&r).as_deref() == Some("big-model") && cf(&r).as_deref() == Some("gpt-5.5-ccbud"), + ); + + json!({ "total": n, "passed": n - fails.len(), "failed": fails.len(), "fails": fails }) +} diff --git a/src-tauri/src/gateway/selftest_xlate.rs b/src-tauri/src/gateway/selftest_xlate.rs new file mode 100644 index 0000000..8d551ca --- /dev/null +++ b/src-tauri/src/gateway/selftest_xlate.rs @@ -0,0 +1,154 @@ +use serde_json::{json, Value}; + +use crate::store; + +#[allow(clippy::type_complexity)] +pub(super) async fn selftest_translation( + client: &reqwest::Client, + base: &str, + gport: u16, + mock: u16, +) -> (Value, u16, bool, String, String, u16, String, u16, bool, String) { + // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Chat provider ---- + // Reconfigure the mock provider to speak openai-chat, then hit /v1/messages and prove the + // response comes back Anthropic-shaped (non-stream) and as a valid Anthropic SSE (stream). + let cfg2 = json!({ "port": gport, "activeProviderId":"mockoa", "providers":[ + { "id":"mockoa","name":"MockOpenAI","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-chat","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } + ]}); + store::write_config(cfg2.clone()); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let tx_ns = client + .post(base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model) = match tx_ns { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); + let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let model = j.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_msg, text, model) + } + Err(e) => (0, false, format!("ERR:{}", e), String::new()), + }; + + let tx_st = client + .post(base) + .json(&json!({ "model":"test-alias","stream":true,"max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (tx_st_status, tx_st_text) = match tx_st { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // ---- protocol translation: Claude Code (Anthropic /v1/messages) → an OpenAI-Responses provider ---- + let cfg3 = json!({ "port": gport, "activeProviderId":"mockre", "providers":[ + { "id":"mockre","name":"MockResponses","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"openai-responses","defaultModel":"gpt-mock","smallFastModel":"gpt-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"gpt-mock"}] } + ]}); + store::write_config(cfg3); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let rx = client + .post(base) + .json(&json!({ "model":"test-alias","max_tokens":8,"messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (rx_status, rx_anthropic, rx_text) = match rx { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_msg = j.get("type").and_then(|v| v.as_str()) == Some("message"); + let text = j.get("content").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|b| b.get("text")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_msg, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + (cfg2, tx_ns_status, tx_ns_anthropic, tx_ns_text, tx_ns_model, tx_st_status, tx_st_text, + rx_status, rx_anthropic, rx_text) +} + +#[allow(clippy::type_complexity)] +pub(super) async fn selftest_reverse_and_codex( + client: &reqwest::Client, + gport: u16, + mock: u16, + cfg2: &Value, +) -> (u16, bool, String, u16, bool, String, u16, String, u16, String) { + // ---- reverse: an OpenAI-Chat client (/v1/chat/completions) → an Anthropic provider ---- + let cfg4 = json!({ "port": gport, "activeProviderId":"mockan", "providers":[ + { "id":"mockan","name":"MockAnthropic","baseUrl":format!("http://127.0.0.1:{}", mock),"authToken":"k","protocol":"anthropic","defaultModel":"claude-mock","smallFastModel":"claude-mock","mapDefaultModels":true,"models":[{"alias":"test-alias","upstream":"claude-mock"}] } + ]}); + store::write_config(cfg4); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let rev = client + .post(format!("http://127.0.0.1:{}/v1/chat/completions", gport)) + .json(&json!({ "model":"test-alias","messages":[{"role":"user","content":"hi"}] })) + .send() + .await; + let (rev_status, rev_is_chat, rev_text) = match rev { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_chat = j.get("object").and_then(|v| v.as_str()) == Some("chat.completion"); + let text = j.get("choices").and_then(|c| c.as_array()).and_then(|a| a.first()) + .and_then(|c| c.get("message")).and_then(|m| m.get("content")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_chat, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + + // ---- Codex (OpenAI-Responses client, /v1/responses) → an Anthropic provider ---- + // The shape Codex sends with wire_api="responses": instructions + item-based input + flattened + // function tools. Non-stream proves the buffered translate; stream proves the incremental + // Anthropic→Responses transcoder (item done events + terminal response.completed). + let codex_body = json!({ "model":"test-alias", "instructions":"be nice", + "input":[{ "type":"message","role":"user","content":[{ "type":"input_text","text":"hi" }] }], + "tools":[{ "type":"function","name":"shell","description":"run","parameters":{ "type":"object" } }], + "tool_choice":"auto", "store": false }); + let cdx = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_body) + .send() + .await; + let (cdx_status, cdx_is_response, cdx_text) = match cdx { + Ok(r) => { + let s = r.status().as_u16(); + let j: Value = r.json().await.unwrap_or_else(|_| json!({})); + let is_resp = j.get("object").and_then(|v| v.as_str()) == Some("response"); + let text = j.get("output_text").and_then(|v| v.as_str()).unwrap_or("").to_string(); + (s, is_resp, text) + } + Err(e) => (0, false, format!("ERR:{}", e)), + }; + let mut codex_stream_body = codex_body.clone(); + codex_stream_body["stream"] = json!(true); + let cdx_st = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_stream_body) + .send() + .await; + let (cdx_st_status, cdx_st_text) = match cdx_st { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + + // ---- Codex → an OpenAI-Chat provider (incremental chat→Responses transcoding) ---- + store::write_config(cfg2.clone()); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let cdx_chat = client + .post(format!("http://127.0.0.1:{}/v1/responses", gport)) + .json(&codex_stream_body) + .send() + .await; + let (cdx_chat_status, cdx_chat_text) = match cdx_chat { + Ok(r) => (r.status().as_u16(), r.text().await.unwrap_or_default()), + Err(e) => (0, format!("ERR:{}", e)), + }; + (rev_status, rev_is_chat, rev_text, cdx_status, cdx_is_response, cdx_text, cdx_st_status, + cdx_st_text, cdx_chat_status, cdx_chat_text) +} diff --git a/src-tauri/src/gateway/session.rs b/src-tauri/src/gateway/session.rs new file mode 100644 index 0000000..8c4ae4a --- /dev/null +++ b/src-tauri/src/gateway/session.rs @@ -0,0 +1,61 @@ +use serde_json::Value; + +pub(super) fn request_session_id(body: &Value) -> Option { + // Claude Code: metadata.user_id is a JSON string carrying session_id. + if let Some(raw) = body.pointer("/metadata/user_id").and_then(Value::as_str) { + if let Ok(metadata) = serde_json::from_str::(raw.trim()) { + if let Some(session) = metadata.get("session_id").and_then(Value::as_str) + .filter(|session| !session.is_empty()) + { + return Some(session.to_string()); + } + } + } + // Codex (Responses client): prompt_cache_key carries the conversation id. + body.get("prompt_cache_key").and_then(Value::as_str) + .map(str::trim) + .filter(|session| !session.is_empty()) + .map(str::to_string) +} + +pub(super) fn codex_history_scope_for_session(request_session: Option<&str>) -> String { + request_session.unwrap_or("").to_string() +} + +pub(super) fn response_tool_calls( + response: &llm_connector::types::ChatResponse, +) -> Vec { + response.choices.first().and_then(|choice| choice.message.tool_calls.as_ref()) + .map(|calls| calls.iter().map(|call| { + crate::protocol::stream::CapturedToolCall { + call_id: call.id.clone(), + name: call.function.name.clone(), + arguments: call.function.arguments.clone(), + thought_signature: crate::protocol::tool_call_thought_signature(call), + } + }).collect()) + .unwrap_or_default() +} + +pub(super) fn response_tool_calls_with_client_ids( + response: &llm_connector::types::ChatResponse, + encoded_response: &Value, +) -> Vec { + let mut captured = response_tool_calls(response); + let client_ids = encoded_response + .get("output") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| { + matches!( + item.get("type").and_then(Value::as_str), + Some("function_call" | "custom_tool_call" | "tool_search_call") + ) + }) + .filter_map(|item| item.get("call_id").and_then(Value::as_str)); + for (call, client_id) in captured.iter_mut().zip(client_ids) { + call.call_id = client_id.to_string(); + } + captured +} diff --git a/src-tauri/src/gateway/signatures.rs b/src-tauri/src/gateway/signatures.rs new file mode 100644 index 0000000..be51d21 --- /dev/null +++ b/src-tauri/src/gateway/signatures.rs @@ -0,0 +1,145 @@ +use std::collections::HashMap; + +use super::history_args::{canonical_tool_arguments, current_tool_turn_start}; +use super::redact::now_ms; + +// Claude Code rebuilds assistant tool_use history from its known fields and drops provider +// metadata, so Gemini's signature cannot round-trip through the Anthropic wire. Keep a bounded, +// session-scoped server-side copy and restore it before the next Google/OpenAI-compatible request. +pub(super) const THOUGHT_SIGNATURE_TTL_MS: i64 = 6 * 60 * 60 * 1000; +const THOUGHT_SIGNATURE_CACHE_MAX: usize = 2048; +pub(super) const GEMINI_SIGNATURE_FALLBACK: &str = "skip_thought_signature_validator"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct CachedToolCall { + call_id: String, + name: String, + arguments: String, + signature: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct ThoughtSignatureBatch { + pub(super) calls: Vec, + pub(super) touched_at: i64, +} + +#[derive(Default)] +pub(super) struct ThoughtSignatureCache { + pub(super) batches: HashMap<(String, String), ThoughtSignatureBatch>, +} + +impl ThoughtSignatureCache { + fn prune(&mut self, now: i64) { + self.batches.retain(|_, batch| { + now.saturating_sub(batch.touched_at) <= THOUGHT_SIGNATURE_TTL_MS + }); + } + + pub(super) fn remember( + &mut self, + provider_id: &str, + session_id: Option<&str>, + captured_calls: &[crate::protocol::stream::CapturedToolCall], + ) { + let now = now_ms(); + self.prune(now); + let Some(session_id) = session_id else { return }; + let calls: Vec = captured_calls.iter().map(|call| CachedToolCall { + call_id: call.call_id.clone(), + name: call.name.clone(), + arguments: canonical_tool_arguments(&call.arguments), + signature: call.thought_signature.as_deref() + .filter(|signature| !signature.is_empty()) + .map(str::to_string), + }).collect(); + let key = (provider_id.to_string(), session_id.to_string()); + if !calls.iter().any(|call| call.signature.is_some()) { + if !calls.is_empty() { + self.batches.remove(&key); + } + return; + } + + if self.batches.len() >= THOUGHT_SIGNATURE_CACHE_MAX && !self.batches.contains_key(&key) { + if let Some(oldest) = self.batches.iter() + .min_by_key(|(_, batch)| batch.touched_at) + .map(|(key, _)| key.clone()) + { + self.batches.remove(&oldest); + } + } + // Replacing the latest batch also makes terminal/EOF observations idempotent. + self.batches.insert(key, ThoughtSignatureBatch { + calls, + touched_at: now, + }); + } + + pub(super) fn restore( + &mut self, + provider_id: &str, + session_id: Option<&str>, + request: &mut llm_connector::types::ChatRequest, + ) -> usize { + let now = now_ms(); + self.prune(now); + let Some(session_id) = session_id else { return 0 }; + let current_turn = current_tool_turn_start(request); + let Some(message_index) = request.messages.iter() + .enumerate() + .skip(current_turn) + .rev() + .find_map(|(message_index, message)| { + message.tool_calls.as_ref() + .filter(|calls| !calls.is_empty()) + .map(|_| message_index) + }) + else { return 0 }; + let Some(calls) = request.messages[message_index].tool_calls.as_mut() else { return 0 }; + let key = (provider_id.to_string(), session_id.to_string()); + let Some(batch) = self.batches.get_mut(&key) else { return 0 }; + if batch.calls.len() != calls.len() + || !batch.calls.iter().zip(calls.iter()).all(|(cached, current)| { + cached.call_id == current.id + && cached.name == current.function.name + && cached.arguments == canonical_tool_arguments(¤t.function.arguments) + }) + { + return 0; + } + batch.touched_at = now; + let mut restored = 0usize; + for (call, cached) in calls.iter_mut().zip(&batch.calls) { + if crate::protocol::tool_call_thought_signature(call).is_none() { + if let Some(signature) = &cached.signature { + call.thought_signature = Some(signature.clone()); + restored += 1; + } + } + } + restored + } +} + +/// Google documents this sentinel for function-call history that did not originate from the +/// current API response (transferred/synthetic history). We use it only when Claude stripped the +/// real signature and the session cache cannot recover it. For parallel calls, only the first call +/// in a model step gets a signature, matching Gemini's validation contract. +pub(super) fn apply_gemini_signature_fallback(request: &mut llm_connector::types::ChatRequest) -> usize { + let mut applied = 0usize; + // Gemini validates only the current turn: everything after the most recent ordinary user + // message. Tool results decode as Role::Tool, so sequential tool steps remain in this slice. + let current_turn = current_tool_turn_start(request); + for message in request.messages.iter_mut().skip(current_turn) { + let Some(calls) = message.tool_calls.as_mut() else { continue }; + if calls.is_empty() + || crate::protocol::tool_call_thought_signature(&calls[0]).is_some() + { + continue; + } + calls[0].thought_signature = Some(GEMINI_SIGNATURE_FALLBACK.to_string()); + applied += 1; + } + applied +} diff --git a/src-tauri/src/gateway/sse.rs b/src-tauri/src/gateway/sse.rs new file mode 100644 index 0000000..7c832f6 --- /dev/null +++ b/src-tauri/src/gateway/sse.rs @@ -0,0 +1,102 @@ +use serde_json::Value; + +use super::monitor::UsageAcc; + +fn model_rewrite_re() -> &'static regex::Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + RE.get_or_init(|| regex::Regex::new(r#"("model"\s*:\s*")[^"]*(")"#).unwrap()) +} + +fn absorb_usage_sse(obj: &Value, usage: &mut UsageAcc) { + match obj.get("type").and_then(|v| v.as_str()) { + Some("message_start") => { + if let Some(u) = obj.get("message").and_then(|m| m.get("usage")) { + usage.input += u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_read += u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.cache_creation += u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + usage.saw = true; + } + } + Some("message_delta") => { + if let Some(o) = obj.get("usage").and_then(|u| u.get("output_tokens")).and_then(|v| v.as_i64()) { + usage.output = o; + usage.saw = true; + } + } + _ => {} + } +} + +pub(super) fn process_sse_line(line: &str, rewrite_model: Option<&str>, usage: &mut UsageAcc) -> String { + if line.contains("\"usage\"") { + if let Some(i) = line.find('{') { + if let Ok(obj) = serde_json::from_str::(line[i..].trim()) { + absorb_usage_sse(&obj, usage); + } + } + } + if let Some(m) = rewrite_model { + if line.contains("\"model\"") { + return model_rewrite_re() + .replace_all(line, |caps: ®ex::Captures| format!("{}{}{}", &caps[1], m, &caps[2])) + .into_owned(); + } + } + line.to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResponsesTerminalKind { + Completed, + Incomplete, + Failed, +} + +impl ResponsesTerminalKind { + pub(super) fn is_resumable(self) -> bool { + matches!(self, Self::Completed | Self::Incomplete) + } +} + +#[derive(Debug, Clone)] +pub(super) struct ResponsesTerminal { + pub(super) kind: ResponsesTerminalKind, + pub(super) response: Option, +} + +pub(super) fn responses_terminal_event(sse: &str) -> Option { + sse.lines().rev().find_map(|line| { + let payload = line.trim().strip_prefix("data:")?.trim(); + let event: Value = serde_json::from_str(payload).ok()?; + let kind = match event.get("type").and_then(Value::as_str)? { + "response.completed" => ResponsesTerminalKind::Completed, + "response.incomplete" => ResponsesTerminalKind::Incomplete, + "response.failed" => ResponsesTerminalKind::Failed, + _ => return None, + }; + Some(ResponsesTerminal { + kind, + response: event.get("response").cloned(), + }) + }) +} + +pub(super) fn responses_terminal_object(response: &Value) -> Option { + let kind = match response.get("status").and_then(Value::as_str)? { + "completed" => ResponsesTerminalKind::Completed, + "incomplete" => ResponsesTerminalKind::Incomplete, + "failed" => ResponsesTerminalKind::Failed, + _ => return None, + }; + Some(ResponsesTerminal { + kind, + response: Some(response.clone()), + }) +} + +pub(super) fn is_responses_compact_path(path: &str) -> bool { + matches!( + path.trim_end_matches('/'), + "/responses/compact" | "/v1/responses/compact" + ) +} diff --git a/src-tauri/src/gateway/state.rs b/src-tauri/src/gateway/state.rs new file mode 100644 index 0000000..3786483 --- /dev/null +++ b/src-tauri/src/gateway/state.rs @@ -0,0 +1,148 @@ +use axum::Router; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tauri::Emitter; +use tokio::sync::{oneshot, Mutex}; + +use super::handler::handle; +use super::signatures::ThoughtSignatureCache; + +// ---------------- gateway runtime ---------------- + +pub struct GatewayState { + pub(super) app: tauri::AppHandle, + pub(super) known: Mutex>>, + pub(super) thought_signatures: Mutex, + pub(super) codex_history: crate::protocol::codex_history::CodexHistoryStore, + pub(super) seq: AtomicU64, + running: Mutex>, + // Sync mirror of the bound port (0 = stopped) for callers that can't await (tray refresh). + running_port: std::sync::atomic::AtomicU32, + pub(super) exchanges: Mutex>, + pub(super) client: reqwest::Client, + pub(super) client_insecure: reqwest::Client, + // Ring buffer of recent gateway log lines (seq+ts stamped) so the settings Logs panel can + // backfill on open — mirrors main.js gatewayLogs (cap 80). std Mutex: log() is sync. + logs: std::sync::Mutex>, + log_seq: AtomicU64, +} +struct RunningServer { + port: u16, + shutdown: oneshot::Sender<()>, +} + +impl GatewayState { + pub fn new(app: tauri::AppHandle) -> Arc { + let client = reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let client_insecure = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Arc::new(Self { + app, + known: Mutex::new(HashMap::new()), + thought_signatures: Mutex::new(ThoughtSignatureCache::default()), + codex_history: crate::protocol::codex_history::CodexHistoryStore::default(), + seq: AtomicU64::new(0), + running: Mutex::new(None), + running_port: std::sync::atomic::AtomicU32::new(0), + exchanges: Mutex::new(VecDeque::new()), + client, + client_insecure, + logs: std::sync::Mutex::new(VecDeque::new()), + log_seq: AtomicU64::new(0), + }) + } + + pub fn log(&self, level: &str, msg: impl AsRef) { + let seq = self.log_seq.fetch_add(1, Ordering::Relaxed) + 1; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let entry = json!({ "seq": seq, "ts": ts, "level": level, "msg": msg.as_ref() }); + if let Ok(mut buf) = self.logs.lock() { + buf.push_back(entry.clone()); + while buf.len() > 80 { + buf.pop_front(); + } + } + let _ = self.app.emit("gateway:log", entry); + } + + /// Snapshot of the recent-log ring, oldest→newest (logs_get backfill). + pub fn logs_snapshot(&self) -> Value { + self.logs + .lock() + .map(|b| Value::Array(b.iter().cloned().collect())) + .unwrap_or_else(|_| json!([])) + } + pub fn logs_clear(&self) { + if let Ok(mut b) = self.logs.lock() { + b.clear(); + } + } + + pub async fn status(&self) -> Value { + match self.running.lock().await.as_ref() { + Some(rs) => json!({ "running": true, "port": rs.port }), + None => json!({ "running": false, "port": Value::Null }), + } + } + + pub async fn current_port(&self) -> Option { + self.running.lock().await.as_ref().map(|r| r.port) + } + + /// Sync view of the running state (tray menu refresh runs on the main thread, no await). + pub fn port_sync(&self) -> Option { + match self.running_port.load(Ordering::Relaxed) { + 0 => None, + p => Some(p as u16), + } + } + + pub fn emit(&self, event: &str, payload: Value) { + let _ = self.app.emit(event, payload); + } + + pub async fn start(self: &Arc, port: u16) -> Result { + if let Some(rs) = self.running.lock().await.as_ref() { + return Ok(rs.port); + } + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .map_err(|e| e.to_string())?; + let actual = listener.local_addr().map_err(|e| e.to_string())?.port(); + let (tx, rx) = oneshot::channel::<()>(); + let router = Router::new().fallback(handle).with_state(self.clone()); + tauri::async_runtime::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + *self.running.lock().await = Some(RunningServer { port: actual, shutdown: tx }); + self.running_port.store(actual as u32, Ordering::Relaxed); + self.log("info", format!("gateway listening on http://127.0.0.1:{}", actual)); + let status = self.status().await; + let _ = self.app.emit("gateway:status", status); + Ok(actual) + } + + pub async fn stop(self: &Arc) { + self.running_port.store(0, Ordering::Relaxed); + let taken = self.running.lock().await.take(); + if let Some(rs) = taken { + let _ = rs.shutdown.send(()); + self.log("info", "gateway stopped"); + } + let status = self.status().await; + let _ = self.app.emit("gateway:status", status); + } +} diff --git a/src-tauri/src/gateway/stream_passthrough.rs b/src-tauri/src/gateway/stream_passthrough.rs new file mode 100644 index 0000000..e6933c6 --- /dev/null +++ b/src-tauri/src/gateway/stream_passthrough.rs @@ -0,0 +1,166 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::capture::StreamAbortGuard; +use super::monitor::UsageAcc; +use super::redact::{now_ms, vec_headers}; +use super::responses_history::NativeResponsesHistoryContext; +use super::routing::Routing; +use super::sse::{process_sse_line, responses_terminal_event}; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) fn stream_passthrough( + st: &Arc, + resp: reqwest::Response, + need_rewrite: bool, + routing: Routing, + method: Method, + req_path: String, + provider_name: String, + status: StatusCode, + ex_id: u64, + started: std::time::Instant, + out_headers: Vec<(String, String)>, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, + native_responses_history: Option, + client_wire: crate::protocol::Wire, +) -> Response { + let rewrite_model = if need_rewrite { routing.client_facing_model.clone() } else { None }; + let st2 = st.clone(); + let status_code = status.as_u16(); + let ex_id2 = ex_id; + let started2 = started; + let res_headers = vec_headers(&out_headers); + let mut guard = StreamAbortGuard::new( + st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), + routing.clone(), status_code, + json!({ + "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": res_headers, + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + }), + None, + ); + let method2 = method.clone(); + let path2 = req_path.clone(); + let pname2 = provider_name.clone(); + let routing2 = routing.clone(); + let native_history = native_responses_history.clone(); + let native_responses_stream = client_wire == crate::protocol::Wire::OpenAiResponses; + let body_stream = async_stream::stream! { + let mut s = resp.bytes_stream(); + let mut buf = String::new(); + let mut usage = UsageAcc::default(); + let mut history_recorded = false; + while let Some(chunk) = s.next().await { + match chunk { + Ok(bytes) => { + buf.push_str(&String::from_utf8_lossy(&bytes)); + let mut out = String::new(); + while let Some(idx) = buf.find('\n') { + let line: String = buf.drain(..=idx).collect(); + out.push_str(&process_sse_line(&line, rewrite_model.as_deref(), &mut usage)); + } + let terminal = native_responses_stream + .then(|| responses_terminal_event(&out)) + .flatten(); + guard.push_res(&out); + if let Some(terminal) = terminal { + guard.finished = true; + if !history_recorded + && (200..300).contains(&status_code) + && terminal.kind.is_resumable() + { + if let (Some(history), Some(response)) = + (native_history.as_ref(), terminal.response.as_ref()) + { + st2.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + response, + ) + .await; + } + } + history_recorded = true; + } + guard.usage = Some(usage.clone()); + if !out.is_empty() { + yield Ok::(Bytes::from(out)); + } + } + Err(error) => { + let message = format!("upstream stream transport error: {}", error); + st2.log("error", format!("{} ({})", message, pname2)); + yield Err(std::io::Error::new(std::io::ErrorKind::Other, message)); + return; + } + } + } + if !buf.is_empty() { + let line = process_sse_line(&buf, rewrite_model.as_deref(), &mut usage); + let terminal = native_responses_stream + .then(|| responses_terminal_event(&line)) + .flatten(); + guard.push_res(&line); + if let Some(terminal) = terminal { + guard.finished = true; + if !history_recorded + && (200..300).contains(&status_code) + && terminal.kind.is_resumable() + { + if let (Some(history), Some(response)) = + (native_history.as_ref(), terminal.response.as_ref()) + { + st2.codex_history + .record_response_scoped_with_metadata( + &history.scope, + ResponseOrigin::Native(history.provider_id.clone()), + history.materializable, + &history.request, + response, + ) + .await; + } + } + } + yield Ok(Bytes::from(line)); + } + if native_responses_stream && !guard.finished { + let message = "upstream Responses stream ended before a terminal event"; + st2.log("error", format!("{} ({})", message, pname2)); + yield Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, message)); + return; + } + st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); + let mut ex = guard.complete(); + ex["ms"] = json!(started2.elapsed().as_millis() as u64); + st2.record_exchange(ex).await; + }; + let mut builder = Response::builder().status(status.as_u16()); + for (k, v) in &out_headers { + builder = builder.header(k, v); + } + return builder.body(Body::from_stream(body_stream)).unwrap(); +} diff --git a/src-tauri/src/gateway/stream_transcode.rs b/src-tauri/src/gateway/stream_transcode.rs new file mode 100644 index 0000000..0ae964d --- /dev/null +++ b/src-tauri/src/gateway/stream_transcode.rs @@ -0,0 +1,206 @@ +use axum::{ + body::Body, + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use futures_util::StreamExt; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::protocol::codex_history::ResponseOrigin; + +use super::capture::StreamAbortGuard; +use super::monitor::UsageAcc; +use super::redact::{now_ms, vec_headers}; +use super::routing::Routing; +use super::sse::responses_terminal_event; +use super::state::GatewayState; + +#[allow(clippy::too_many_arguments)] +pub(super) fn stream_transcoded( + st: &Arc, + resp: reqwest::Response, + mut tc: crate::protocol::stream::Transcoder, + client_wire: crate::protocol::Wire, + provider_wire: crate::protocol::Wire, + history_request: Value, + history_scope: String, + routing: Routing, + request_session: Option, + is_gemini_upstream: bool, + status: StatusCode, + ex_id: u64, + started: std::time::Instant, + method: Method, + req_path: String, + provider_name: String, + out_headers: Vec<(String, String)>, + ex_req_headers: Value, + ex_req_body: Value, + ex_client_req: Value, + ex_translated: Option, + ex_url: String, +) -> Response { + let st2 = st.clone(); + let signature_provider_id = routing.provider_id.clone(); + let signature_session = request_session.clone(); + // Any Gemini-backed transcoded stream (Claude Code or Codex client) feeds the + // signature cache; transcoders that don't track calls return an empty capture. + let capture_thought_signatures = is_gemini_upstream; + let status_code = status.as_u16(); + let ex_id2 = ex_id; + let started2 = started; + let xlabel = format!("{:?}->{:?}", provider_wire, client_wire); + let up_res_headers = vec_headers(&out_headers); + let mut guard = StreamAbortGuard::new( + st.clone(), ex_id, started, method.clone(), req_path.clone(), provider_name.clone(), + routing.clone(), status_code, + json!({ + "id": ex_id, "ts": now_ms(), "method": method.as_str(), "path": req_path, "url": ex_url, + "provider": provider_name, "requestedModel": routing.client_facing_model, + "outgoingModel": routing.outgoing_model, "clientFacingModel": routing.client_facing_model, + "status": status_code, "reqHeaders": ex_req_headers, "reqBody": ex_req_body, + "clientReq": ex_client_req, "translated": ex_translated, + "resHeaders": json!({ "content-type": "text/event-stream", "x-ccbud-translated": xlabel }), + "resBody": json!({ "text": "", "bytes": 0, "truncated": 0 }), + }), + // raw upstream capture (pre-translation), so the monitor can show the exact + // upstream stream next to the translated one the client received + Some((status_code, up_res_headers)), + ); + let method2 = method.clone(); + let path2 = req_path.clone(); + let pname2 = provider_name.clone(); + let routing2 = routing.clone(); + let body_stream = async_stream::stream! { + let mut s = resp.bytes_stream(); + let mut buf = String::new(); + let mut history_recorded = false; + while let Some(chunk) = s.next().await { + match chunk { + Ok(bytes) => { + let raw = String::from_utf8_lossy(&bytes); + guard.push_up(&raw); + buf.push_str(&raw); + let mut out = String::new(); + while let Some(idx) = buf.find('\n') { + let line: String = buf.drain(..=idx).collect(); + out.push_str(&tc.push(&line)); + } + if capture_thought_signatures && tc.succeeded() { + let captured_calls = tc.captured_tool_calls(); + st2.thought_signatures.lock().await.remember( + &signature_provider_id, + signature_session.as_deref(), + &captured_calls, + ); + } + // Keep the guard current BEFORE suspending: once the terminal event is + // out, Codex closes the socket and the generator is dropped mid-await. + guard.push_res(&out); + guard.finished = tc.done(); + if client_wire == crate::protocol::Wire::OpenAiResponses + && !history_recorded + { + if let Some(terminal) = responses_terminal_event(&out) { + history_recorded = true; + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st2.codex_history + .record_response_scoped_with_metadata( + &history_scope, + ResponseOrigin::Local, + true, + &history_request, + response, + ) + .await; + } + } + } + } + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if !out.is_empty() { + yield Ok::(Bytes::from(out)); + } + } + Err(error) => { + let message = format!("upstream stream transport error: {}", error); + st2.log("error", format!("{} ({})", message, pname2)); + buf.clear(); + let out = tc.fail(&message); + guard.push_res(&out); + guard.finished = tc.done(); + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if !out.is_empty() { + yield Ok(Bytes::from(out)); + } + break; + } + } + } + let mut tail = String::new(); + if !buf.is_empty() { tail.push_str(&tc.push(&buf)); } + tail.push_str(&tc.finish()); + guard.finished = tc.done(); + guard.usage = Some(UsageAcc { + input: tc.input_tokens(), output: tc.output_tokens(), saw: true, ..Default::default() + }); + if capture_thought_signatures && tc.succeeded() { + let captured_calls = tc.captured_tool_calls(); + st2.thought_signatures.lock().await.remember( + &signature_provider_id, + signature_session.as_deref(), + &captured_calls, + ); + } + if !tail.is_empty() { + guard.push_res(&tail); + if client_wire == crate::protocol::Wire::OpenAiResponses + && !history_recorded + { + if let Some(terminal) = responses_terminal_event(&tail) { + if terminal.kind.is_resumable() { + if let Some(response) = terminal.response.as_ref() { + st2.codex_history + .record_response_scoped_with_metadata( + &history_scope, + ResponseOrigin::Local, + true, + &history_request, + response, + ) + .await; + } + } + } + } + yield Ok(Bytes::from(tail)); + } + let mut usage = UsageAcc::default(); + usage.input = tc.input_tokens(); + usage.output = tc.output_tokens(); + usage.saw = true; + st2.emit_request(ex_id2, started2, &method2, &path2, &pname2, &routing2, status_code, Some(&usage)); + let mut ex = guard.complete(); + ex["ms"] = json!(started2.elapsed().as_millis() as u64); + st2.record_exchange(ex).await; + }; + let mut builder = Response::builder() + .status(status.as_u16()) + .header("content-type", "text/event-stream") + .header("x-ccbud-translated", format!("{:?}->{:?}", provider_wire, client_wire)); + // Forward the upstream request id — clients (Claude Code) persist it as `requestId`, + // which usage analytics use as half of the de-dup key. + for (k, v) in &out_headers { + if k == "request-id" || k == "x-request-id" { + builder = builder.header(k, v); + } + } + return builder.body(Body::from_stream(body_stream)).unwrap(); +} diff --git a/src-tauri/src/gateway/targets.rs b/src-tauri/src/gateway/targets.rs new file mode 100644 index 0000000..731d405 --- /dev/null +++ b/src-tauri/src/gateway/targets.rs @@ -0,0 +1,110 @@ +use axum::{ + body::Body, + http::{StatusCode, Uri}, + response::Response, +}; +use serde_json::{json, Value}; + +use super::sse::is_responses_compact_path; + +pub(super) fn retry_delay(retry_after: Option<&str>, attempt: i64, base: i64) -> u64 { + let cap = 30_000u64; + if let Some(ra) = retry_after { + let s = ra.trim(); + if let Ok(n) = s.parse::() { + return (n.saturating_mul(1000)).min(cap); + } + // HTTP-date form (RFC 7231 IMF-fixdate) — honor the absolute time the upstream named + // (proxy.js parity). chrono is already a dep, so no extra crate is pulled in for this. + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%a, %d %b %Y %H:%M:%S GMT") { + let ms = (dt.and_utc() - chrono::Utc::now()).num_milliseconds().max(0) as u64; + return ms.min(cap); + } + } + let base = if base > 0 { base as u64 } else { 500 }; + base.saturating_mul(2u64.saturating_pow(attempt.clamp(0, 20) as u32)) + .min(8000) +} + +pub(super) fn build_target(base_url: &str, uri: &Uri) -> Option { + if base_url.is_empty() { + return None; + } + let base = base_url.trim_end_matches('/'); + let path = uri.path(); + let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); + // If the provider baseUrl already carries a path prefix (e.g. ".../v1") and the + // inbound path repeats it (e.g. "/v1/responses"), collapse the overlap so we don't + // forward to ".../v1/v1/responses". This is what bites an openai-* provider whose + // baseUrl ends in /v1 (incl. the sidecar plugins) on same-protocol passthrough. + // Segment-aware so a "/v1" base won't eat a "/v1beta" path. + let base_path = base_url_path(base).trim_end_matches('/'); + let path_out: &str = if base_path.is_empty() || base_path == "/" { + path + } else if path == base_path { + "" + } else { + match path.strip_prefix(base_path) { + Some(rest) if rest.starts_with('/') => rest, + _ => path, + } + }; + Some(format!("{}{}{}", base, path_out, query)) +} + +/// Resolve one of the three primary API endpoints against the configured base URL. The base is +/// authoritative: an inbound `/v1/...` path does not cause ccbud to insert `/v1` upstream. +pub(super) fn endpoint_targets(base_url: &str, uri: &Uri) -> Option<(String, Option)> { + if base_url.trim().is_empty() { + return None; + } + let wire = crate::protocol::Wire::from_request_endpoint(uri.path())?; + let with_query = |mut url: String| { + if let Some(query) = uri.query() { + url.push('?'); + url.push_str(query); + } + url + }; + Some(( + with_query(wire.upstream_url_for_request(base_url, uri.path())), + wire.v1_fallback_url_for_request(base_url, uri.path()).map(with_query), + )) +} + +/// Standalone Responses compaction returns a distinct `response.compaction` object whose output +/// is the canonical replacement context window. Chat and Anthropic upstreams cannot provide that +/// contract through the ordinary response transcoder, so fail explicitly instead of turning a +/// compact request into an unrelated model turn. Responses providers keep the passthrough path. +pub(super) fn cross_wire_compact_error(path: &str, provider_wire: crate::protocol::Wire) -> Option { + (is_responses_compact_path(path) + && provider_wire != crate::protocol::Wire::OpenAiResponses) + .then(|| { + error_response( + StatusCode::NOT_IMPLEMENTED, + "CC Buddy: /v1/responses/compact requires an openai-responses provider; cross-protocol compaction is not supported", + "invalid_request_error", + ) + }) +} + +/// The path component of a base URL (everything after scheme://authority), or "". +fn base_url_path(base: &str) -> &str { + let after_scheme = base.split_once("://").map(|(_, rest)| rest).unwrap_or(base); + match after_scheme.find('/') { + Some(i) => &after_scheme[i..], + None => "", + } +} + +pub(super) fn error_response(status: StatusCode, msg: &str, etype: &str) -> Response { + json_response(status, &json!({ "type": "error", "error": { "type": etype, "message": msg } })) +} +pub(super) fn json_response(status: StatusCode, body: &Value) -> Response { + let bytes = serde_json::to_vec(body).unwrap_or_default(); + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(bytes)) + .unwrap() +} diff --git a/src-tauri/src/gateway/tests.rs b/src-tauri/src/gateway/tests.rs new file mode 100644 index 0000000..00f74c1 --- /dev/null +++ b/src-tauri/src/gateway/tests.rs @@ -0,0 +1,124 @@ +use axum::body::to_bytes; +use axum::http::{StatusCode, Uri}; +use serde_json::{json, Value}; + +use super::models::synthesize_models; +use super::responses_history::apply_responses_chat_request_controls; +use super::selftest_routing::routing_selftest; +use super::targets::{build_target, cross_wire_compact_error, endpoint_targets}; + +#[test] +fn routing_parity_with_proxy_js() { + let r = routing_selftest(); + assert_eq!(r.get("failed").and_then(|v| v.as_i64()), Some(0), "routing mismatch: {:?}", r); + assert_eq!(r.get("passed").and_then(|v| v.as_i64()), Some(9)); +} +#[test] +fn synthesize_models_includes_claude_tiers() { + let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); + let s = synthesize_models(&cfg, false); + let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert!(ids.contains(&"claude-sonnet-5")); + assert!(ids.contains(&"claude-fable-5")); + assert!(!ids.iter().any(|id| id.starts_with("gpt-"))); +} +#[test] +fn synthesize_models_codex_returns_gpt_tiers() { + let cfg = json!({ "providers": [{ "id": "p", "defaultModel": "m", "smallFastModel": "m" }], "activeProviderId": "p" }); + let s = synthesize_models(&cfg, true); + let ids: Vec<&str> = s["data"].as_array().unwrap().iter().filter_map(|m| m["id"].as_str()).collect(); + assert!(ids.contains(&"gpt-5.4")); + assert!(ids.contains(&"gpt-5.4-mini")); + assert!(!ids.iter().any(|id| id.starts_with("claude-"))); +} +#[test] +fn responses_chat_translation_preserves_parallel_tool_calls() { + let mut body = json!({ "model": "upstream", "messages": [] }); + apply_responses_chat_request_controls( + &mut body, + &json!({ "parallel_tool_calls": false }), + ); + assert_eq!(body["parallel_tool_calls"], false); + + let mut absent = json!({ "model": "upstream", "messages": [] }); + apply_responses_chat_request_controls(&mut absent, &json!({})); + assert!(absent.get("parallel_tool_calls").is_none()); +} +#[test] +fn build_target_collapses_path_overlap() { + let u = |s: &str| s.parse::().unwrap(); + // openai-* provider / sidecar plugin: base ends in /v1 and the client path + // repeats /v1 → collapse (was ".../v1/v1/responses" → 404). + assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/responses")).unwrap(), "http://127.0.0.1:57085/v1/responses"); + assert_eq!(build_target("http://127.0.0.1:57085/v1", &u("/v1/models?x=1")).unwrap(), "http://127.0.0.1:57085/v1/models?x=1"); + // non-overlapping prefix (anthropic providers) → plain concat, unchanged. + assert_eq!(build_target("https://api.deepseek.com/anthropic", &u("/v1/messages")).unwrap(), "https://api.deepseek.com/anthropic/v1/messages"); + // base without a path → unchanged. + assert_eq!(build_target("http://127.0.0.1:9", &u("/v1/responses")).unwrap(), "http://127.0.0.1:9/v1/responses"); + // segment-aware: a /v1 base must NOT eat a /v1beta path. + assert_eq!(build_target("http://h/v1", &u("/v1beta/x")).unwrap(), "http://h/v1/v1beta/x"); +} +#[test] +fn primary_endpoints_use_the_configured_base_and_offer_one_v1_fallback() { + let u = |s: &str| s.parse::().unwrap(); + assert_eq!( + endpoint_targets("https://example.com/api", &u("/v1/messages?x=1")), + Some(( + "https://example.com/api/messages?x=1".to_string(), + Some("https://example.com/api/v1/messages?x=1".to_string()), + )) + ); + assert_eq!( + endpoint_targets("https://example.com/v4", &u("/v1/chat/completions")), + Some(("https://example.com/v4/chat/completions".to_string(), None)) + ); + assert_eq!( + endpoint_targets("https://example.com/v1", &u("/v1/responses")), + Some(("https://example.com/v1/responses".to_string(), None)) + ); + assert_eq!( + endpoint_targets("https://example.com/v1", &u("/v1/responses/compact")), + Some(("https://example.com/v1/responses/compact".to_string(), None)) + ); + assert_eq!( + endpoint_targets( + "https://generativelanguage.googleapis.com/v1beta/openai", + &u("/v1/chat/completions"), + ), + Some(( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions".to_string(), + None, + )) + ); + assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/models")), None); + assert_eq!(endpoint_targets("https://example.com/api", &u("/v1/messages/count_tokens")), None); +} +#[tokio::test] +async fn compact_rejects_cross_wire_and_allows_responses_passthrough() { + for provider_wire in [ + crate::protocol::Wire::OpenAiChat, + crate::protocol::Wire::Anthropic, + ] { + let response = cross_wire_compact_error("/v1/responses/compact/", provider_wire) + .expect("cross-wire compact must be rejected locally"); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + let body = to_bytes(response.into_body(), 4096).await.unwrap(); + let error: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(error["error"]["type"], "invalid_request_error"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("cross-protocol compaction is not supported")); + } + + assert!(cross_wire_compact_error( + "/v1/responses/compact", + crate::protocol::Wire::OpenAiResponses, + ) + .is_none()); + assert!(cross_wire_compact_error( + "/v1/responses", + crate::protocol::Wire::OpenAiChat, + ) + .is_none()); +} diff --git a/src-tauri/src/gateway/tests_history.rs b/src-tauri/src/gateway/tests_history.rs new file mode 100644 index 0000000..60624ea --- /dev/null +++ b/src-tauri/src/gateway/tests_history.rs @@ -0,0 +1,189 @@ +use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; + +use super::responses_history::{ + decide_responses_compact_history, decide_responses_history, ResponsesForwardMode, + ResponsesHistoryDecision, ResponsesHistoryError, +}; +use super::sse::{responses_terminal_event, ResponsesTerminalKind}; + +#[test] +fn responses_history_policy_covers_native_and_translated_provider_switches() { + let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { + changed: 3, + had_previous_response_id: true, + previous_found: true, + previous_materialized: materializable, + previous_origin: Some(origin), + }; + let portable = ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }; + + // Native Responses A → translated chat/Anthropic. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiChat, + "provider-chat", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(portable) + ); + // Translated/local → native Responses. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Local, true), + ), + Ok(portable) + ); + // Native Responses A → native Responses B. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(portable) + ); + // Same native owner keeps the provider-side id while the materialized local copy is retained + // for recording its descendant. + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), true), + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: true, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), false), + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }) + ); + + let missing = HistoryResolution { + had_previous_response_id: true, + ..HistoryResolution::default() + }; + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &missing, + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Original, + descendant_materializable: false, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::Anthropic, + "provider-anthropic", + &missing, + ), + Err(ResponsesHistoryError::Unavailable) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-a", + &HistoryResolution { + changed: 2, + ..HistoryResolution::default() + }, + ), + Ok(ResponsesHistoryDecision { + forward: ResponsesForwardMode::Materialized, + descendant_materializable: true, + }) + ); + assert_eq!( + decide_responses_history( + crate::protocol::Wire::OpenAiResponses, + "provider-b", + &known(ResponseOrigin::Local, false), + ), + Err(ResponsesHistoryError::Unavailable) + ); +} +#[test] +fn responses_compact_localizes_portable_foreign_history_and_allows_owner_or_cache_miss() { + let known = |origin: ResponseOrigin, materializable: bool| HistoryResolution { + changed: 2, + had_previous_response_id: true, + previous_found: true, + previous_materialized: materializable, + previous_origin: Some(origin), + }; + let missing = HistoryResolution { + had_previous_response_id: true, + ..HistoryResolution::default() + }; + assert_eq!( + decide_responses_compact_history("provider-a", &missing), + Ok(ResponsesForwardMode::Original) + ); + assert_eq!( + decide_responses_compact_history( + "provider-a", + &known(ResponseOrigin::Native("provider-a".to_string()), false), + ), + Ok(ResponsesForwardMode::Original) + ); + for origin in [ + ResponseOrigin::Local, + ResponseOrigin::Native("provider-b".to_string()), + ] { + assert_eq!( + decide_responses_compact_history("provider-a", &known(origin, true)), + Ok(ResponsesForwardMode::Materialized) + ); + } + assert_eq!( + decide_responses_compact_history( + "provider-a", + &known(ResponseOrigin::Local, false), + ), + Err(ResponsesHistoryError::Unavailable) + ); +} +#[test] +fn responses_terminal_parser_keeps_completed_and_incomplete_but_marks_failed() { + for (event_type, status, expected) in [ + ( + "response.completed", + "completed", + ResponsesTerminalKind::Completed, + ), + ( + "response.incomplete", + "incomplete", + ResponsesTerminalKind::Incomplete, + ), + ("response.failed", "failed", ResponsesTerminalKind::Failed), + ] { + let sse = format!( + "event: {event_type}\ndata: {{\"type\":\"{event_type}\",\"response\":{{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"{status}\",\"output\":[]}}}}\n\n" + ); + let terminal = responses_terminal_event(&sse).unwrap(); + assert_eq!(terminal.kind, expected); + assert_eq!(terminal.kind.is_resumable(), status != "failed"); + assert_eq!(terminal.response.as_ref().unwrap()["status"], status); + } + assert!(responses_terminal_event( + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + ) + .is_none()); +} diff --git a/src-tauri/src/gateway/tests_routing.rs b/src-tauri/src/gateway/tests_routing.rs new file mode 100644 index 0000000..c19ab18 --- /dev/null +++ b/src-tauri/src/gateway/tests_routing.rs @@ -0,0 +1,102 @@ +use serde_json::json; + +use super::routing::{resolve_routing, Routing}; +use super::session::{codex_history_scope_for_session, request_session_id}; +use super::signatures::{apply_gemini_signature_fallback, ThoughtSignatureCache, GEMINI_SIGNATURE_FALLBACK}; +use super::targets::retry_delay; + +#[test] +fn routing_classifies_by_family() { + let cfg = json!({ "providers": [{ "id": "p", "baseUrl": "http://127.0.0.1:1", "authToken": "k", + "defaultModel": "big", "smallFastModel": "small", "mapDefaultModels": true, "models": [] }], "activeProviderId": "p" }); + let out = |r: Option| r.and_then(|x| x.outgoing_model); + // Claude: haiku → fast, fable/opus/sonnet → primary. + assert_eq!(out(resolve_routing(Some("claude-haiku-4-5"), &cfg, None)).as_deref(), Some("small")); + assert_eq!(out(resolve_routing(Some("claude-fable-5"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("claude-opus-4-8"), &cfg, None)).as_deref(), Some("big")); + // Codex: stable/default identities → primary; explicit small tiers → fast. Legacy + // sol/terra names remain primary for existing configs. + assert_eq!( + out(resolve_routing(Some("gpt-5.4"), &cfg, None)).as_deref(), + Some("big") + ); + assert_eq!( + out(resolve_routing(Some("gpt-5.4-mini"), &cfg, None)).as_deref(), + Some("small") + ); + assert_eq!(out(resolve_routing(Some("gpt-5.6-sol"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-terra"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-sol-pro"), &cfg, None)).as_deref(), Some("big")); + assert_eq!(out(resolve_routing(Some("gpt-5.6-luna"), &cfg, None)).as_deref(), Some("small")); +} +#[test] +fn retry_delay_honors_seconds_and_backoff() { + assert_eq!(retry_delay(Some("2"), 0, 500), 2000); + assert_eq!(retry_delay(None, 0, 500), 500); + assert_eq!(retry_delay(None, 1, 500), 1000); + // HTTP-date in the past → no wait (clamped to 0), NOT a fall-through to backoff. + assert_eq!(retry_delay(Some("Wed, 21 Oct 2015 07:28:00 GMT"), 3, 500), 0); + // Unparseable Retry-After → exponential backoff (base * 2^attempt). + assert_eq!(retry_delay(Some("soon"), 2, 500), 2000); +} +#[test] +fn extracts_claude_session_id_from_metadata() { + let nested = json!({ "metadata": { "user_id": "{\"session_id\":\"session-123\",\"account_id\":\"a\"}" } }); + assert_eq!(request_session_id(&nested).as_deref(), Some("session-123")); + assert!(request_session_id(&json!({ "metadata": { "user_id": "user-123" } })).is_none()); + // Codex (Responses client) identifies its conversation via prompt_cache_key. + assert_eq!( + request_session_id(&json!({ "prompt_cache_key": "conv-42" })).as_deref(), + Some("conv-42") + ); + assert!(request_session_id(&json!({ "prompt_cache_key": " " })).is_none()); + assert_eq!(codex_history_scope_for_session(Some("conv-42")), "conv-42"); + assert_eq!(codex_history_scope_for_session(None), ""); +} + +// The full Codex ⇄ Gemini(chat) signature round-trip: what ChatToResponses captured last turn +// is restored onto the function_call history Codex echoes back, and earlier steps get the +// documented fallback sentinel — without it Gemini 3 rejects the request with a 400. +#[test] +fn restores_signatures_for_codex_responses_requests() { + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("conv-42"), &[ + crate::protocol::stream::CapturedToolCall { + call_id: "call_9".to_string(), + name: "shell".to_string(), + arguments: "{\"command\":[\"ls\"]}".to_string(), + thought_signature: Some("sig-codex".to_string()), + }, + ]); + let codex = json!({ + "model": "gpt-5.5-ccbud", + "instructions": "You are Codex.", + "prompt_cache_key": "conv-42", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list, then read" }] }, + { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"pwd\"]}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "/tmp" }, + { "type": "function_call", "call_id": "call_9", "name": "shell", "arguments": "{ \"command\": [\"ls\"] }" }, + { "type": "function_call_output", "call_id": "call_9", "output": "a.txt" } + ], + "store": false, "stream": true + }); + let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::OpenAiResponses, &codex).unwrap(); + assert_eq!(request_session_id(&codex).as_deref(), Some("conv-42")); + assert_eq!(cache.restore("google", Some("conv-42"), &mut ir), 1); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK)); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), + Some("sig-codex")); + // …and the encoded Gemini chat body carries them where Gemini validates them. + let body = crate::protocol::encode_upstream_request( + crate::protocol::Wire::OpenAiChat, &ir, "gemini-3-flash-preview", true, + ).unwrap(); + let signatures: Vec<_> = body["messages"].as_array().unwrap().iter() + .filter(|message| message["role"] == "assistant") + .map(|message| message["tool_calls"][0]["extra_content"]["google"]["thought_signature"].clone()) + .collect(); + assert_eq!(signatures, vec![json!(GEMINI_SIGNATURE_FALLBACK), json!("sig-codex")]); +} diff --git a/src-tauri/src/gateway/tests_signatures.rs b/src-tauri/src/gateway/tests_signatures.rs new file mode 100644 index 0000000..3e414ea --- /dev/null +++ b/src-tauri/src/gateway/tests_signatures.rs @@ -0,0 +1,196 @@ +use serde_json::{json, Value}; + +use super::history_args::{ + provider_safe_history_tool_arguments, sanitize_provider_history_tool_arguments, +}; +use super::redact::now_ms; +use super::signatures::{ + apply_gemini_signature_fallback, ThoughtSignatureBatch, ThoughtSignatureCache, + GEMINI_SIGNATURE_FALLBACK, THOUGHT_SIGNATURE_TTL_MS, +}; + +#[test] +fn repairs_malformed_history_arguments_before_strict_chat_forwarding() { + let body = json!({ + "model": "gpt-5.4", + "input": [ + { "type": "message", "role": "user", "content": [{ + "type": "input_text", "text": "Use the helper and continue" + }] }, + { "type": "function_call", "call_id": "call_bad", "name": "helper", + "arguments": "{\"value\":1} trailing-garbage" }, + { "type": "function_call_output", "call_id": "call_bad", + "output": "failed to parse function arguments" } + ], + "tools": [{ "type": "function", "name": "helper", "description": "test", + "parameters": { "type": "object", "properties": { "value": { "type": "number" } } } }] + }); + let mut ir = crate::protocol::decode_client_request( + crate::protocol::Wire::OpenAiResponses, + &body, + ) + .unwrap(); + let call = ir.messages[1].tool_calls.as_mut().unwrap().first_mut().unwrap(); + call.thought_signature = Some("stale-signature".to_string()); + + assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); + let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; + assert_eq!(serde_json::from_str::(&call.function.arguments).unwrap()["value"], 1); + assert!(crate::protocol::tool_call_thought_signature(call).is_none()); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + + let encoded = crate::protocol::encode_upstream_request( + crate::protocol::Wire::OpenAiChat, + &ir, + "gemini-3.5-flash", + false, + ) + .unwrap(); + let outgoing = &encoded["messages"][1]["tool_calls"][0]; + assert_eq!( + serde_json::from_str::(outgoing["function"]["arguments"].as_str().unwrap()) + .unwrap()["value"], + 1 + ); + assert_eq!( + outgoing["extra_content"]["google"]["thought_signature"], + GEMINI_SIGNATURE_FALLBACK + ); +} + +#[test] +fn history_argument_repair_preserves_valid_objects_and_wraps_unrecoverable_text() { + assert_eq!(provider_safe_history_tool_arguments(" { \"value\": 1 } "), None); + let scalar = provider_safe_history_tool_arguments("42").unwrap(); + assert_eq!(serde_json::from_str::(&scalar).unwrap()["_ccbuddy_value"], 42); + let raw = provider_safe_history_tool_arguments("not json at all").unwrap(); + assert_eq!( + serde_json::from_str::(&raw).unwrap()["_ccbuddy_raw_arguments"], + "not json at all" + ); + + for arguments in ["{\"value\":1}\u{00a0}", "\u{000b}{\"value\":1}"] { + let repaired = provider_safe_history_tool_arguments(arguments).unwrap(); + assert_eq!( + serde_json::from_str::(&repaired).unwrap(), + json!({ "value": 1 }) + ); + } +} + +#[test] +fn history_argument_repair_clears_a_signature_restored_for_different_bytes() { + let captured = crate::protocol::stream::CapturedToolCall { + call_id: "call_empty".to_string(), + name: "helper".to_string(), + arguments: String::new(), + thought_signature: Some("real-signature".to_string()), + }; + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("session-empty"), &[captured]); + let body = json!({ + "model": "gpt-5.4", + "input": [ + { "role": "user", "content": "Call helper" }, + { "type": "function_call", "call_id": "call_empty", "name": "helper", + "arguments": "" }, + { "type": "function_call_output", "call_id": "call_empty", "output": "invalid" } + ] + }); + let mut ir = crate::protocol::decode_client_request( + crate::protocol::Wire::OpenAiResponses, + &body, + ) + .unwrap(); + + assert_eq!(cache.restore("google", Some("session-empty"), &mut ir), 1); + assert_eq!(sanitize_provider_history_tool_arguments(&mut ir), 1); + let call = &ir.messages[1].tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.function.arguments, "{}"); + assert!(crate::protocol::tool_call_thought_signature(call).is_none()); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + assert_eq!( + crate::protocol::tool_call_thought_signature( + &ir.messages[1].tool_calls.as_ref().unwrap()[0] + ) + .as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK) + ); +} + +#[test] +fn restores_latest_batch_and_falls_back_for_prior_steps() { + let call = |id: &str, name: &str, arguments: &str, signature: Option<&str>| { + crate::protocol::stream::CapturedToolCall { + call_id: id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + thought_signature: signature.map(str::to_string), + } + }; + let mut cache = ThoughtSignatureCache::default(); + cache.remember("google", Some("session-1"), &[ + call("default_api:Bash", "default_api:Bash", "{\"command\":\"pwd\"}", Some("sig-old")), + ]); + cache.remember("google", Some("session-1"), &[ + call("call_paris", "weather", "{ \"city\": \"Paris\" }", Some("sig-latest")), + call("call_london", "weather", "{\"city\":\"London\"}", None), + ]); + let claude = json!({ + "model": "claude-sonnet-5", "max_tokens": 1024, + "messages": [ + { "role": "user", "content": "Run pwd, then check Paris and London" }, + { "role": "assistant", "content": [{ + "type": "tool_use", "id": "default_api:Bash", "name": "default_api:Bash", + "input": { "command": "pwd" } + }] }, + { "role": "user", "content": [{ + "type": "tool_result", "tool_use_id": "default_api:Bash", "content": "/tmp" + }] }, + { "role": "assistant", "content": [ + { "type": "tool_use", "id": "call_paris", "name": "weather", + "input": { "city": "Paris" } }, + { "type": "tool_use", "id": "call_london", "name": "weather", + "input": { "city": "London" } } + ] }, + { "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": "call_paris", "content": "15C" }, + { "type": "tool_result", "tool_use_id": "call_london", "content": "12C" } + ] } + ] + }); + let mut ir = crate::protocol::decode_client_request(crate::protocol::Wire::Anthropic, &claude).unwrap(); + assert_eq!(cache.restore("google", Some("session-1"), &mut ir), 1); + assert_eq!(apply_gemini_signature_fallback(&mut ir), 1); + let steps: Vec<_> = ir.messages.iter().filter_map(|message| message.tool_calls.as_ref()).collect(); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[0][0]).as_deref(), + Some(GEMINI_SIGNATURE_FALLBACK)); + assert_eq!(crate::protocol::tool_call_thought_signature(&steps[1][0]).as_deref(), + Some("sig-latest")); + assert!(crate::protocol::tool_call_thought_signature(&steps[1][1]).is_none()); +} + +#[test] +fn sessionless_cache_access_prunes_expired_batches() { + let stale = ThoughtSignatureBatch { + calls: vec![], + touched_at: now_ms().saturating_sub(THOUGHT_SIGNATURE_TTL_MS + 1), + }; + let mut cache = ThoughtSignatureCache::default(); + cache.batches.insert(("google".into(), "stale".into()), stale.clone()); + cache.remember("google", None, &[]); + assert!(cache.batches.is_empty()); + + cache.batches.insert(("google".into(), "stale".into()), stale); + let body = json!({ + "model": "claude-sonnet-5", + "max_tokens": 1, + "messages": [{ "role": "user", "content": "ping" }] + }); + let mut request = crate::protocol::decode_client_request( + crate::protocol::Wire::Anthropic, + &body, + ).unwrap(); + assert_eq!(cache.restore("google", None, &mut request), 0); + assert!(cache.batches.is_empty()); +} diff --git a/src-tauri/src/grok.rs b/src-tauri/src/grok.rs deleted file mode 100644 index 6217dd6..0000000 --- a/src-tauri/src/grok.rs +++ /dev/null @@ -1,537 +0,0 @@ -// Grok Build CLI session support — reads xAI Grok's on-disk sessions -// (`~/.grok/sessions///chat_history.jsonl`, sibling `summary.json` -// carrying id/cwd/title/model/git/timestamps) and normalizes them into the SAME session/message -// shape the renderer consumes (see history::Norm), so the 对话 view browses Grok sessions -// without renderer forks. -// -// A chat_history line is one of: `system` (harness prompt — skipped), `user` (content blocks of -// text / data-URL image; the human prose is wrapped in tags, harness wrappers like -// / are dropped), `reasoning` ({summary:[{summary_text}]} → thinking), -// `assistant` ({content, tool_calls:[{id,name,arguments-json}]}), and `tool_result` -// ({tool_call_id, content, images?}). Tool names are mapped onto the renderer's native -// vocabulary (both grok tool-name generations: read_file/Read → Read, Shell → Bash, …). -// -// The same uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only -// chat_history.jsonl is the conversation; walkers must never sweep the rest. -// -// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) -// keyed `grok:` — chat_history stems aren't unique, and the files belong to another tool. - -#![allow(dead_code)] - -use crate::history::{image_block, Norm}; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Grok's DEFAULT config dir as a history-dir entry string (`~/.grok`). Honors GROK_HOME the way -/// the grok CLI does (summary.json echoes it as `grok_home`). Only the auto-add migration keys -/// off this — browsing walks every configured dir's `sessions/` tree. -pub fn default_root() -> PathBuf { - match std::env::var("GROK_HOME") { - Ok(h) if !h.trim().is_empty() => PathBuf::from(h), - _ => home().join(".grok"), - } -} - -pub fn grok_label() -> String { - crate::store::collapse_home(&default_root().to_string_lossy()) -} - -/// A grok install exists when its sessions tree holds at least one percent-encoded cwd dir. -pub fn root_exists() -> bool { - let sessions = default_root().join("sessions"); - fs::read_dir(&sessions) - .map(|entries| { - entries - .flatten() - .any(|e| is_cwd_dir_name(&e.file_name().to_string_lossy()) && e.path().is_dir()) - }) - .unwrap_or(false) -} - -/// Grok encodes each workspace cwd as a percent-encoded absolute path dir ("%2FUsers%2F…") — -/// the marker that distinguishes a grok sessions/ child from Codex's YYYY date shards. -pub fn is_cwd_dir_name(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - lower.starts_with("%2f") || lower.starts_with("%3a%5c") // unix "/", windows "X:\" oddity-proof -} - -/// Session files under one encoded-cwd dir: `//chat_history.jsonl`. -pub fn walk_cwd_dir(dir: &Path, cb: &mut F) { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_dir() { - continue; - } - let chat = p.join("chat_history.jsonl"); - if chat.is_file() { - cb(chat); - } - } -} - -/// Container-shape test for detail/edit routing: `…/sessions///chat_history.jsonl`. -pub fn looks_grok_path(file: &Path) -> bool { - if file.file_name().and_then(|n| n.to_str()) != Some("chat_history.jsonl") { - return false; - } - file.parent() - .and_then(|uuid_dir| uuid_dir.parent()) - .and_then(|enc| enc.file_name()) - .map(|n| is_cwd_dir_name(&n.to_string_lossy())) - .unwrap_or(false) -} - -/// The session uuid (its dir name) — sidecar key and renderer id both build on it. -fn session_uuid(file: &Path) -> String { - file.parent() - .and_then(|d| d.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default() -} - -fn sidecar_key(file: &Path) -> String { - format!("grok:{}", session_uuid(file)) -} - -fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "grok:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -/// Sibling summary.json of a chat_history.jsonl (grok's own session metadata). -fn summary_of(file: &Path) -> Option { - let p = file.parent()?.join("summary.json"); - serde_json::from_str(&fs::read_to_string(p).ok()?).ok() -} - -/// Minimal percent-decoding for grok's encoded-cwd dir names (fallback when summary.json -/// is missing; the record cwd wins when present). Also used on Antigravity's file:// uris. -pub(crate) fn percent_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out: Vec = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { - out.push(b); - i += 3; - continue; - } - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -fn rfc3339_ms(s: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.timestamp_millis() as f64) -} - -/// Harness-injected user text (environment wrappers) — hidden from the timeline. -fn is_meta_user_text(t: &str) -> bool { - let t = t.trim_start(); - ["", "", "", "…` (the human prose envelope grok writes). -fn unwrap_user_query(t: &str) -> String { - match t.split_once("") { - Some((_, rest)) => rest.split("").next().unwrap_or(rest).trim().to_string(), - None => t.trim().to_string(), - } -} - -/// Grok tool name + parsed arguments → (renderer tool name, renderer input). Covers both grok -/// tool-name generations (snake_case and CamelCase). -fn map_tool(name: &str, args: &Value) -> (String, Value) { - let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; - match name { - "run_terminal_command" | "Shell" => { - let mut input = json!({ "command": s("command") }); - if !s("description").is_empty() { - input["description"] = json!(s("description")); - } - ("Bash".into(), input) - } - "read_file" | "Read" => { - let path = if !s("target_file").is_empty() { s("target_file") } else { s("path") }; - let mut input = json!({ "file_path": path }); - for k in ["offset", "limit"] { - if let Some(v) = args.get(k) { - if !v.is_null() { - input[k] = v.clone(); - } - } - } - ("Read".into(), input) - } - "grep" | "Grep" | "grep_search" => { - let mut input = json!({ "pattern": if !s("pattern").is_empty() { s("pattern") } else { s("query") } }); - if !s("path").is_empty() { - input["path"] = json!(s("path")); - } - ("Grep".into(), input) - } - "search_replace" => ("Edit".into(), keep(args)), - "StrReplace" => ( - "Edit".into(), - json!({ "file_path": s("path"), "old_string": s("old_string"), "new_string": s("new_string") }), - ), - "write" => ("Write".into(), keep(args)), - "Write" => ("Write".into(), json!({ "file_path": s("path"), "content": s("contents") })), - "list_dir" => ("LS".into(), json!({ "path": s("target_directory") })), - "Glob" => ("Glob".into(), json!({ "pattern": s("glob_pattern"), "path": s("target_directory") })), - "todo_write" | "TodoWrite" => ("TodoWrite".into(), keep(args)), - "web_fetch" | "WebFetch" => ("WebFetch".into(), json!({ "url": s("url") })), - "WebSearch" => ("WebSearch".into(), json!({ "query": s("search_term") })), - _ => (name.to_string(), keep(args)), - } -} - -/// Normalize parsed chat_history records (+ the sibling summary) into the renderer's message -/// model. Lines carry no timestamps — session-level times come from summary.json. -pub fn normalize(recs: &[Value], summary: Option<&Value>) -> Norm { - let mut n = Norm::default(); - let sum = summary.cloned().unwrap_or(Value::Null); - n.model = sum.get("current_model_id").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.cwd = sum - .get("info") - .and_then(|i| i.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - n.session_id = sum - .get("info") - .and_then(|i| i.get("id")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - n.git_branch = sum.get("head_branch").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.first_ts = sum.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()); - n.last_ts = sum - .get("last_active_at") - .or_else(|| sum.get("updated_at")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - for rec in recs { - let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match ty { - "user" => { - let mut blocks: Vec = vec![]; - if let Some(arr) = rec.get("content").and_then(|c| c.as_array()) { - for b in arr { - match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { - "text" => { - let raw = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); - if is_meta_user_text(raw) && !raw.contains("") { - continue; - } - let text = unwrap_user_query(raw); - if !text.is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - } - "image" => { - if let Some(img) = - b.get("url").and_then(|u| u.as_str()).and_then(image_block) - { - blocks.push(img); - } - } - _ => {} - } - } - } else if let Some(t) = rec.get("content").and_then(|c| c.as_str()) { - let text = unwrap_user_query(t); - if !text.is_empty() && !is_meta_user_text(t) { - blocks.push(json!({ "type": "text", "text": text })); - } - } - if !blocks.is_empty() { - n.messages.push(json!({ "role": "user", "content": blocks })); - } - } - "reasoning" => { - let txt = rec - .get("summary") - .and_then(|s| s.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - if !txt.trim().is_empty() { - let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(m); - } - } - "assistant" => { - let mut blocks: Vec = vec![]; - let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or(""); - if !text.trim().is_empty() { - blocks.push(json!({ "type": "text", "text": text })); - } - if let Some(calls) = rec.get("tool_calls").and_then(|c| c.as_array()) { - for call in calls { - let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); - let args: Value = call - .get("arguments") - .and_then(|v| v.as_str()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_else(|| call.get("arguments").cloned().unwrap_or(json!({}))); - let (tname, input) = map_tool(name, &args); - let id = call.get("id").and_then(|v| v.as_str()).unwrap_or(""); - blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); - } - } - if !blocks.is_empty() { - let mut m = json!({ "role": "assistant", "content": blocks }); - if let Some(md) = &n.model { - m["modelActual"] = json!(md); - } - n.messages.push(m); - } - } - "tool_result" => { - let id = rec.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); - let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string(); - let images: Vec = rec - .get("images") - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|b| b.get("url").and_then(|u| u.as_str()).and_then(image_block)) - .collect() - }) - .unwrap_or_default(); - let content: Value = if images.is_empty() { - json!(text) - } else { - let mut blocks = vec![json!({ "type": "text", "text": text })]; - blocks.extend(images); - json!(blocks) - }; - n.messages - .push(json!({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": id, "content": content }] })); - } - _ => {} // system / unknown: harness plumbing, not conversation - } - } - n -} - -/// List-row meta: summary.json carries everything cheap (title/cwd/model/times); the file head -/// is only parsed when grok didn't store a title yet (fallback to first user prose). -pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { - let meta = fs::metadata(file).ok()?; - let sum = summary_of(file); - let uuid = session_uuid(file); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); - let sum_title = sum - .as_ref() - .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| { - let recs = crate::history::parse_lines(&crate::history::read_head(file, 131072)); - let n = normalize(&recs, sum.as_ref()); - crate::history::first_user_text(&n.messages) - }); - let cwd = sum - .as_ref() - .and_then(|s| s.get("info")) - .and_then(|i| i.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| { - file.parent() - .and_then(|d| d.parent()) - .and_then(|enc| enc.file_name()) - .map(|nm| percent_decode(&nm.to_string_lossy())) - }); - let created = sum - .as_ref() - .and_then(|s| s.get("created_at")) - .and_then(|v| v.as_str()) - .and_then(rfc3339_ms) - .unwrap_or_else(|| crate::history::created_ms(file)); - let mt = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0); - Some(json!({ - "id": format!("grok:{}", uuid), - "file": file.to_string_lossy(), - "source": "grok", - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": sum - .as_ref() - .and_then(|s| s.get("info")) - .and_then(|i| i.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(&uuid), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": sum.as_ref().and_then(|s| s.get("head_branch")).cloned().unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": sum.as_ref().and_then(|s| s.get("current_model_id")).cloned().unwrap_or(Value::Null), - "isSubagent": false, - "imported": false, - "deleted": cc_deleted, - "createdAt": created, - "lastActivity": mt, - "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, - })) -} - -/// Full-detail shape (history.rs get_session routes here). -pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { - let path = Path::new(file); - let sum = summary_of(path); - let n = normalize(recs, sum.as_ref()); - let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); - let sum_title = sum - .as_ref() - .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); - let uuid = session_uuid(path); - json!({ - "meta": { - "id": format!("grok:{}", uuid), - "file": file, - "source": "grok", - "assistant": "Grok", - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": Value::Null, - "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), - "cwd": n.cwd.clone(), - "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), - "gitBranch": n.git_branch.clone(), - "version": Value::Null, - "isSubagent": false, - "deleted": cc_deleted, - "imported": false, - "importedFrom": Value::Null, - "importedAt": Value::Null, - "model": n.model, - "totals": n.totals, - "messages": n.messages.len(), - "subagentCount": 0, - "firstTs": n.first_ts, - "lastTs": n.last_ts, - }, - "messages": n.messages, - "subagents": {}, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn recs() -> Vec { - vec![ - json!({ "type": "system", "content": "You are Grok…" }), - json!({ "type": "user", "content": [{ "type": "text", "text": "\nOS: macos\n\n\n\nclean\n\n" }] }), - json!({ "type": "user", "content": [ - { "type": "text", "text": "\n修复登录 bug\n" }, - { "type": "image", "url": "data:image/png;base64,QUJD" } - ] }), - json!({ "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "Scanning the repo" }] }), - json!({ "type": "assistant", "content": "先看下目录。", "tool_calls": [ - { "id": "call-1", "name": "run_terminal_command", "arguments": "{\"command\":\"ls\",\"description\":\"List files\"}" }, - { "id": "call-2", "name": "read_file", "arguments": "{\"target_file\":\"src/app.js\"}" } - ] }), - json!({ "type": "tool_result", "tool_call_id": "call-1", "content": "a.txt\nb.txt" }), - json!({ "type": "tool_result", "tool_call_id": "call-2", "content": "console.log(1)", "images": [{ "type": "image", "url": "data:image/png;base64,REVG" }] }), - ] - } - - fn summary() -> Value { - json!({ - "info": { "id": "0199-aaaa", "cwd": "/tmp/proj" }, - "generated_title": "Fix login bug", - "created_at": "2026-06-18T06:27:07.777809Z", - "last_active_at": "2026-06-18T06:57:37.242478Z", - "current_model_id": "grok-build", - "head_branch": "main", - }) - } - - #[test] - fn normalizes_conversation() { - let s = summary(); - let n = normalize(&recs(), Some(&s)); - // harness wrapper user turn dropped; real turns: user, thinking, assistant+tools, 2 results - assert_eq!(n.messages.len(), 5); - assert_eq!(n.messages[0]["role"], "user"); - assert_eq!(n.messages[0]["content"][0]["text"], "修复登录 bug"); - assert_eq!(n.messages[0]["content"][1]["type"], "image"); - assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); - let a = &n.messages[2]; - assert_eq!(a["content"][0]["text"], "先看下目录。"); - assert_eq!(a["content"][1]["name"], "Bash"); - assert_eq!(a["content"][1]["input"]["command"], "ls"); - assert_eq!(a["content"][2]["name"], "Read"); - assert_eq!(a["content"][2]["input"]["file_path"], "src/app.js"); - assert_eq!(n.messages[3]["content"][0]["tool_use_id"], "call-1"); - // image-carrying result becomes a block array - assert_eq!(n.messages[4]["content"][0]["content"][1]["type"], "image"); - assert_eq!(n.model.as_deref(), Some("grok-build")); - assert_eq!(n.cwd.as_deref(), Some("/tmp/proj")); - } - - #[test] - fn detects_cwd_dirs_and_paths() { - assert!(is_cwd_dir_name("%2FUsers%2Fme%2Fcode")); - assert!(is_cwd_dir_name("%2fusers%2fme")); - assert!(!is_cwd_dir_name("2026")); - assert_eq!(percent_decode("%2FUsers%2Fme"), "/Users/me"); - let p = Path::new("/x/sessions/%2FUsers%2Fme/0199-aaaa/chat_history.jsonl"); - assert!(looks_grok_path(p)); - assert!(!looks_grok_path(Path::new("/x/sessions/2026/01/01/rollout-1.jsonl"))); - assert_eq!(session_uuid(p), "0199-aaaa"); - } -} diff --git a/src-tauri/src/grok/meta.rs b/src-tauri/src/grok/meta.rs new file mode 100644 index 0000000..b44260b --- /dev/null +++ b/src-tauri/src/grok/meta.rs @@ -0,0 +1,60 @@ +// Per-session customization (title / tags / soft delete) via the shared foreign-CLI sidecar, +// plus the sibling summary.json that carries id/cwd/title/model/git/timestamps. + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// The session uuid (its dir name) — sidecar key and renderer id both build on it. +pub(super) fn session_uuid(file: &Path) -> String { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +pub(super) fn sidecar_key(file: &Path) -> String { + format!("grok:{}", session_uuid(file)) +} + +pub(super) fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "grok:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling summary.json of a chat_history.jsonl (grok's own session metadata). +pub(super) fn summary_of(file: &Path) -> Option { + let p = file.parent()?.join("summary.json"); + serde_json::from_str(&fs::read_to_string(p).ok()?).ok() +} + +/// Minimal percent-decoding for grok's encoded-cwd dir names (fallback when summary.json +/// is missing; the record cwd wins when present). Also used on Antigravity's file:// uris. +pub fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} diff --git a/src-tauri/src/grok/mod.rs b/src-tauri/src/grok/mod.rs new file mode 100644 index 0000000..51d1d3e --- /dev/null +++ b/src-tauri/src/grok/mod.rs @@ -0,0 +1,32 @@ +// Grok Build CLI session support — reads xAI Grok's on-disk sessions +// (`~/.grok/sessions///chat_history.jsonl`, sibling `summary.json` +// carrying id/cwd/title/model/git/timestamps) and normalizes them into the SAME session/message +// shape the renderer consumes (see history::Norm), so the 对话 view browses Grok sessions +// without renderer forks. +// +// A chat_history line is one of: `system` (harness prompt — skipped), `user` (content blocks of +// text / data-URL image; the human prose is wrapped in tags, harness wrappers like +// / are dropped), `reasoning` ({summary:[{summary_text}]} → thinking), +// `assistant` ({content, tool_calls:[{id,name,arguments-json}]}), and `tool_result` +// ({tool_call_id, content, images?}). Tool names are mapped onto the renderer's native +// vocabulary (both grok tool-name generations: read_file/Read → Read, Shell → Bash, …). +// +// The same uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only +// chat_history.jsonl is the conversation; walkers must never sweep the rest. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `grok:` — chat_history stems aren't unique, and the files belong to another tool. + +#![allow(dead_code)] + +mod meta; +mod normalize; +mod roots; +mod session; +#[cfg(test)] +mod tests; + +pub use meta::{is_deleted, percent_decode, set_meta}; +pub use normalize::normalize; +pub use roots::{grok_label, is_cwd_dir_name, looks_grok_path, root_exists, walk_cwd_dir}; +pub use session::{session_from_recs, session_meta_from}; diff --git a/src-tauri/src/grok/normalize.rs b/src-tauri/src/grok/normalize.rs new file mode 100644 index 0000000..1479440 --- /dev/null +++ b/src-tauri/src/grok/normalize.rs @@ -0,0 +1,211 @@ +// chat_history.jsonl → history::Norm: harness-wrapper stripping, tool-name mapping onto the +// renderer's native vocabulary, and the per-line walk that builds the message timeline. + +use crate::history::{image_block, Norm}; +use serde_json::{json, Value}; + +pub(super) fn rfc3339_ms(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.timestamp_millis() as f64) +} + +/// Harness-injected user text (environment wrappers) — hidden from the timeline. +fn is_meta_user_text(t: &str) -> bool { + let t = t.trim_start(); + ["", "", "", "` (the human prose envelope grok writes). +fn unwrap_user_query(t: &str) -> String { + match t.split_once("") { + Some((_, rest)) => rest.split("").next().unwrap_or(rest).trim().to_string(), + None => t.trim().to_string(), + } +} + +/// Grok tool name + parsed arguments → (renderer tool name, renderer input). Covers both grok +/// tool-name generations (snake_case and CamelCase). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "run_terminal_command" | "Shell" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "read_file" | "Read" => { + let path = if !s("target_file").is_empty() { s("target_file") } else { s("path") }; + let mut input = json!({ "file_path": path }); + for k in ["offset", "limit"] { + if let Some(v) = args.get(k) { + if !v.is_null() { + input[k] = v.clone(); + } + } + } + ("Read".into(), input) + } + "grep" | "Grep" | "grep_search" => { + let mut input = json!({ "pattern": if !s("pattern").is_empty() { s("pattern") } else { s("query") } }); + if !s("path").is_empty() { + input["path"] = json!(s("path")); + } + ("Grep".into(), input) + } + "search_replace" => ("Edit".into(), keep(args)), + "StrReplace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_string"), "new_string": s("new_string") }), + ), + "write" => ("Write".into(), keep(args)), + "Write" => ("Write".into(), json!({ "file_path": s("path"), "content": s("contents") })), + "list_dir" => ("LS".into(), json!({ "path": s("target_directory") })), + "Glob" => ("Glob".into(), json!({ "pattern": s("glob_pattern"), "path": s("target_directory") })), + "todo_write" | "TodoWrite" => ("TodoWrite".into(), keep(args)), + "web_fetch" | "WebFetch" => ("WebFetch".into(), json!({ "url": s("url") })), + "WebSearch" => ("WebSearch".into(), json!({ "query": s("search_term") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed chat_history records (+ the sibling summary) into the renderer's message +/// model. Lines carry no timestamps — session-level times come from summary.json. +pub fn normalize(recs: &[Value], summary: Option<&Value>) -> Norm { + let mut n = Norm::default(); + let sum = summary.cloned().unwrap_or(Value::Null); + n.model = sum.get("current_model_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.cwd = sum + .get("info") + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.session_id = sum + .get("info") + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.git_branch = sum.get("head_branch").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.first_ts = sum.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = sum + .get("last_active_at") + .or_else(|| sum.get("updated_at")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match ty { + "user" => { + let mut blocks: Vec = vec![]; + if let Some(arr) = rec.get("content").and_then(|c| c.as_array()) { + for b in arr { + match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + let raw = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if is_meta_user_text(raw) && !raw.contains("") { + continue; + } + let text = unwrap_user_query(raw); + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + "image" => { + if let Some(img) = + b.get("url").and_then(|u| u.as_str()).and_then(image_block) + { + blocks.push(img); + } + } + _ => {} + } + } + } else if let Some(t) = rec.get("content").and_then(|c| c.as_str()) { + let text = unwrap_user_query(t); + if !text.is_empty() && !is_meta_user_text(t) { + blocks.push(json!({ "type": "text", "text": text })); + } + } + if !blocks.is_empty() { + n.messages.push(json!({ "role": "user", "content": blocks })); + } + } + "reasoning" => { + let txt = rec + .get("summary") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if !txt.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "assistant" => { + let mut blocks: Vec = vec![]; + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = rec.get("tool_calls").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args: Value = call + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| call.get("arguments").cloned().unwrap_or(json!({}))); + let (tname, input) = map_tool(name, &args); + let id = call.get("id").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "tool_result" => { + let id = rec.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string(); + let images: Vec = rec + .get("images") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("url").and_then(|u| u.as_str()).and_then(image_block)) + .collect() + }) + .unwrap_or_default(); + let content: Value = if images.is_empty() { + json!(text) + } else { + let mut blocks = vec![json!({ "type": "text", "text": text })]; + blocks.extend(images); + json!(blocks) + }; + n.messages + .push(json!({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": id, "content": content }] })); + } + _ => {} // system / unknown: harness plumbing, not conversation + } + } + n +} diff --git a/src-tauri/src/grok/roots.rs b/src-tauri/src/grok/roots.rs new file mode 100644 index 0000000..0b52d95 --- /dev/null +++ b/src-tauri/src/grok/roots.rs @@ -0,0 +1,73 @@ +// Where Grok keeps its sessions (`~/.grok/sessions///`) and how to +// walk them. The uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only +// chat_history.jsonl is the conversation, so walkers must never sweep the rest. + +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Grok's DEFAULT config dir as a history-dir entry string (`~/.grok`). Honors GROK_HOME the way +/// the grok CLI does (summary.json echoes it as `grok_home`). Only the auto-add migration keys +/// off this — browsing walks every configured dir's `sessions/` tree. +pub fn default_root() -> PathBuf { + match std::env::var("GROK_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h), + _ => home().join(".grok"), + } +} + +pub fn grok_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +/// A grok install exists when its sessions tree holds at least one percent-encoded cwd dir. +pub fn root_exists() -> bool { + let sessions = default_root().join("sessions"); + fs::read_dir(&sessions) + .map(|entries| { + entries + .flatten() + .any(|e| is_cwd_dir_name(&e.file_name().to_string_lossy()) && e.path().is_dir()) + }) + .unwrap_or(false) +} + +/// Grok encodes each workspace cwd as a percent-encoded absolute path dir ("%2FUsers%2F…") — +/// the marker that distinguishes a grok sessions/ child from Codex's YYYY date shards. +pub fn is_cwd_dir_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("%2f") || lower.starts_with("%3a%5c") // unix "/", windows "X:\" oddity-proof +} + +/// Session files under one encoded-cwd dir: `//chat_history.jsonl`. +pub fn walk_cwd_dir(dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_dir() { + continue; + } + let chat = p.join("chat_history.jsonl"); + if chat.is_file() { + cb(chat); + } + } +} + +/// Container-shape test for detail/edit routing: `…/sessions///chat_history.jsonl`. +pub fn looks_grok_path(file: &Path) -> bool { + if file.file_name().and_then(|n| n.to_str()) != Some("chat_history.jsonl") { + return false; + } + file.parent() + .and_then(|uuid_dir| uuid_dir.parent()) + .and_then(|enc| enc.file_name()) + .map(|n| is_cwd_dir_name(&n.to_string_lossy())) + .unwrap_or(false) +} diff --git a/src-tauri/src/grok/session.rs b/src-tauri/src/grok/session.rs new file mode 100644 index 0000000..27542ab --- /dev/null +++ b/src-tauri/src/grok/session.rs @@ -0,0 +1,124 @@ +// Session list rows and the full session payload the renderer's 对话 view consumes. + +use super::meta::{sidecar_meta, session_uuid, summary_of}; +use super::meta::percent_decode; +use super::normalize::{normalize, rfc3339_ms}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +/// List-row meta: summary.json carries everything cheap (title/cwd/model/times); the file head +/// is only parsed when grok didn't store a title yet (fallback to first user prose). +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let sum = summary_of(file); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| { + let recs = crate::history::parse_lines(&crate::history::read_head(file, 131072)); + let n = normalize(&recs, sum.as_ref()); + crate::history::first_user_text(&n.messages) + }); + let cwd = sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + file.parent() + .and_then(|d| d.parent()) + .and_then(|enc| enc.file_name()) + .map(|nm| percent_decode(&nm.to_string_lossy())) + }); + let created = sum + .as_ref() + .and_then(|s| s.get("created_at")) + .and_then(|v| v.as_str()) + .and_then(rfc3339_ms) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("grok:{}", uuid), + "file": file.to_string_lossy(), + "source": "grok", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(&uuid), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": sum.as_ref().and_then(|s| s.get("head_branch")).cloned().unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": sum.as_ref().and_then(|s| s.get("current_model_id")).cloned().unwrap_or(Value::Null), + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let sum = summary_of(path); + let n = normalize(recs, sum.as_ref()); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("grok:{}", uuid), + "file": file, + "source": "grok", + "assistant": "Grok", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} diff --git a/src-tauri/src/grok/tests.rs b/src-tauri/src/grok/tests.rs new file mode 100644 index 0000000..9c5a8da --- /dev/null +++ b/src-tauri/src/grok/tests.rs @@ -0,0 +1,69 @@ +use super::meta::{percent_decode, session_uuid}; +use super::normalize::normalize; +use super::roots::{is_cwd_dir_name, looks_grok_path}; +use serde_json::{json, Value}; +use std::path::Path; + +fn recs() -> Vec { + vec![ + json!({ "type": "system", "content": "You are Grok…" }), + json!({ "type": "user", "content": [{ "type": "text", "text": "\nOS: macos\n\n\n\nclean\n\n" }] }), + json!({ "type": "user", "content": [ + { "type": "text", "text": "\n修复登录 bug\n" }, + { "type": "image", "url": "data:image/png;base64,QUJD" } + ] }), + json!({ "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "Scanning the repo" }] }), + json!({ "type": "assistant", "content": "先看下目录。", "tool_calls": [ + { "id": "call-1", "name": "run_terminal_command", "arguments": "{\"command\":\"ls\",\"description\":\"List files\"}" }, + { "id": "call-2", "name": "read_file", "arguments": "{\"target_file\":\"src/app.js\"}" } + ] }), + json!({ "type": "tool_result", "tool_call_id": "call-1", "content": "a.txt\nb.txt" }), + json!({ "type": "tool_result", "tool_call_id": "call-2", "content": "console.log(1)", "images": [{ "type": "image", "url": "data:image/png;base64,REVG" }] }), + ] +} + +fn summary() -> Value { + json!({ + "info": { "id": "0199-aaaa", "cwd": "/tmp/proj" }, + "generated_title": "Fix login bug", + "created_at": "2026-06-18T06:27:07.777809Z", + "last_active_at": "2026-06-18T06:57:37.242478Z", + "current_model_id": "grok-build", + "head_branch": "main", + }) +} + +#[test] +fn normalizes_conversation() { + let s = summary(); + let n = normalize(&recs(), Some(&s)); + // harness wrapper user turn dropped; real turns: user, thinking, assistant+tools, 2 results + assert_eq!(n.messages.len(), 5); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录 bug"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); + let a = &n.messages[2]; + assert_eq!(a["content"][0]["text"], "先看下目录。"); + assert_eq!(a["content"][1]["name"], "Bash"); + assert_eq!(a["content"][1]["input"]["command"], "ls"); + assert_eq!(a["content"][2]["name"], "Read"); + assert_eq!(a["content"][2]["input"]["file_path"], "src/app.js"); + assert_eq!(n.messages[3]["content"][0]["tool_use_id"], "call-1"); + // image-carrying result becomes a block array + assert_eq!(n.messages[4]["content"][0]["content"][1]["type"], "image"); + assert_eq!(n.model.as_deref(), Some("grok-build")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/proj")); +} + +#[test] +fn detects_cwd_dirs_and_paths() { + assert!(is_cwd_dir_name("%2FUsers%2Fme%2Fcode")); + assert!(is_cwd_dir_name("%2fusers%2fme")); + assert!(!is_cwd_dir_name("2026")); + assert_eq!(percent_decode("%2FUsers%2Fme"), "/Users/me"); + let p = Path::new("/x/sessions/%2FUsers%2Fme/0199-aaaa/chat_history.jsonl"); + assert!(looks_grok_path(p)); + assert!(!looks_grok_path(Path::new("/x/sessions/2026/01/01/rollout-1.jsonl"))); + assert_eq!(session_uuid(p), "0199-aaaa"); +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs deleted file mode 100644 index f6aacec..0000000 --- a/src-tauri/src/history.rs +++ /dev/null @@ -1,3112 +0,0 @@ -// Conversation history. -// -// Reads Claude Code and Codex on-disk sessions across configured dirs, imported snapshots, and -// the app-managed recycle bin. Shapes list/detail payloads for the renderer, including subagents, -// custom title/tags/delete metadata, bundle import/export helpers, and live-watch roots. - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -/// Synthetic "recycle bin" bucket id. Not a real projects tree (never in all_dirs / -/// each_session_file) — a cross-cutting view of soft-deleted sessions across every dir. -pub const TRASH_ID: &str = "__trash__"; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} - -fn expand_tilde(p: &str) -> PathBuf { - if let Some(rest) = p.strip_prefix("~/") { - home().join(rest) - } else if p == "~" { - home() - } else { - PathBuf::from(p) - } -} - -/// Configured dirs → (id, label, projects_dir). id == the dir string (historyActive matches it). -fn config_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { - let mut out = vec![]; - if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { - for d in arr { - if let Some(s) = d.as_str() { - out.push((s.to_string(), s.to_string(), expand_tilde(s).join("projects"))); - } - } - } - out -} - -pub(crate) fn base_name(p: &str) -> String { - p.split('/').filter(|s| !s.is_empty()).last().unwrap_or(p).to_string() -} - -/// Best-effort decode of an encoded project dir name → cwd (record cwd wins when present). -fn decode_dir_name(name: &str) -> Option { - if name.is_empty() { - return None; - } - let trimmed = name.trim_start_matches('-'); - Some(format!("/{}", trimmed.replace('-', "/"))) -} - -pub(crate) fn parse_lines(text: &str) -> Vec { - let mut out = vec![]; - for line in text.split('\n') { - let s = line.trim(); - if s.is_empty() { - continue; - } - if let Ok(v) = serde_json::from_str::(s) { - out.push(v); - } - } - out -} - -fn read_head_result(file: &Path, max: usize) -> std::io::Result { - use std::io::{BufRead, BufReader, Read}; - // Qoder data can be protected as "Other Application Data" on macOS. Its reader first - // attempts the normal filesystem path and uses the installed Qoder CLI only for EPERM; - // keep the same bounded-head contract used by list metadata after that read succeeds. - if crate::qoder::looks_qoder_path(file) { - let mut bytes = crate::qoder::read_bytes(file)?; - bytes.truncate(max); - return Ok(String::from_utf8_lossy(&bytes).into_owned()); - } - let mut file = fs::File::open(file)?; - let mut buf = vec![0u8; max]; - let read = file.read(&mut buf)?; - buf.truncate(read); - // SessionMeta may exceed the ordinary list window because it can embed base instructions and - // dynamic tools. Extend ONLY when the first record itself has no newline yet; a later partial - // record can be ignored, avoiding an accidental multi-megabyte image/tool-result read. - let prefix_len = buf.len().min(4096); - let compact_prefix: String = String::from_utf8_lossy(&buf[..prefix_len]) - .chars() - .filter(|value| !value.is_ascii_whitespace()) - .collect(); - let codex_session_meta = compact_prefix - .find("\"type\":\"session_meta\"") - .is_some_and(|position| position < 512); - if codex_session_meta && read == max && !buf.contains(&b'\n') { - let mut reader = BufReader::new(file); - let _ = reader.read_until(b'\n', &mut buf)?; - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -pub(crate) fn read_head(file: &Path, max: usize) -> String { - read_head_result(file, max).unwrap_or_default() -} - -fn read_session_text(file: &Path) -> std::io::Result { - if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_text(file) - } else { - fs::read_to_string(file) - } -} - -fn read_session_bytes(file: &Path) -> std::io::Result> { - if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_bytes(file) - } else { - fs::read(file) - } -} - -/// Verbatim bytes for raw export. Qoder sessions may require the guarded Qoder CLI fallback on -/// macOS; all other sources retain the ordinary filesystem read used before Qoder support. -pub(crate) fn raw_session_bytes(file: &str) -> std::io::Result> { - read_session_bytes(Path::new(file)) -} - -pub(crate) fn session_read_error(file: &Path, error: &std::io::Error) -> Value { - let kind = match error.kind() { - std::io::ErrorKind::NotFound => "notFound", - std::io::ErrorKind::PermissionDenied => "permissionDenied", - _ => "readFailed", - }; - json!({ - "error": { - "kind": kind, - "file": file.to_string_lossy(), - "message": error.to_string(), - } - }) -} - -fn usage_of(u: &Value) -> Value { - let mut usage = json!({ - "inputTokens": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "outputTokens": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }); - let object = usage.as_object_mut().unwrap(); - // Qoder supplies billing/context facts alongside its zeroed token counters. Keep them on the - // per-turn usage object without adding empty fields to ordinary Claude Code messages. - for (source, target) in [ - ("credits", "credits"), - ("original_credits", "originalCredits"), - ("context_usage_ratio", "contextUsageRatio"), - ] { - if let Some(value) = u.get(source).filter(|value| value.is_number()) { - object.insert(target.to_string(), value.clone()); - } - } - usage -} - -fn content_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - if let Some(arr) = content.as_array() { - return arr - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::>() - .join(" "); - } - String::new() -} - -fn command_label(raw: &str) -> String { - let name = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(n, _)| n.trim().to_string()) - .unwrap_or_default(); - if name.is_empty() { - return String::new(); - } - let args = raw - .split_once("") - .and_then(|(_, r)| r.split_once("")) - .map(|(a, _)| a.trim().to_string()) - .unwrap_or_default(); - format!("{} {}", name, args).trim().to_string() -} - -/// First human prose turn (skips slash-command XML / meta / interrupt notices), capped at 90 chars. -pub(crate) fn first_user_text(messages: &[Value]) -> String { - let mut fallback_cmd = String::new(); - for m in messages { - if m.get("role").and_then(|r| r.as_str()) != Some("user") { - continue; - } - if m.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - let content = m.get("content").cloned().unwrap_or(Value::Null); - let raw = content_text(&content); - let raw = raw.trim(); - if raw.is_empty() { - continue; - } - if raw.starts_with('<') { - if fallback_cmd.is_empty() { - fallback_cmd = command_label(raw); - } - continue; - } - let t: String = raw.split_whitespace().collect::>().join(" "); - if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { - continue; - } - return t.chars().take(90).collect(); - } - fallback_cmd.chars().take(90).collect() -} - -/// __ccbud__ customization (custom title + tags + soft-delete flag) from any record carrying it. -pub(crate) fn read_ccbud(recs: &[Value]) -> (Option, Vec, bool) { - let c = recs.iter().find_map(|r| r.get("__ccbud__")); - let title = c - .and_then(|c| c.get("title")) - .and_then(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let tags = c - .and_then(|c| c.get("tagList")) - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); - let deleted = c.and_then(|c| c.get("delete")).and_then(|v| v.as_bool()).unwrap_or(false); - (title, tags, deleted) -} - -/// The foreign-CLI session sources routed by CONTAINER SHAPE (their path layouts are -/// distinctive per tool, and one of them isn't even jsonl) — content sniffing stays reserved -/// for the historical Claude-vs-Codex jsonl split. -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum Foreign { - Grok, - Copilot, - Antigravity, -} - -pub(crate) fn foreign_kind(file: &Path) -> Option { - if crate::grok::looks_grok_path(file) { - return Some(Foreign::Grok); - } - if crate::copilot::looks_copilot_path(file) { - return Some(Foreign::Copilot); - } - if crate::antigravity::looks_agy_path(file) { - return Some(Foreign::Antigravity); - } - None -} - -/// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's -/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / Qoder session / -/// foreign source, whose flag lives in a sidecar and can flip WITHOUT touching the file — so only -/// the format verdict is cached, never the flag). -#[derive(Clone, Copy)] -enum DelKind { - Claude(bool), - Codex, - Qoder, - Foreign(Foreign), -} - -/// Process-lifetime memo of soft-delete status, keyed `path -> (mtime, kind)`. mtime is the -/// invalidation signal: set_ccbud rewrites a Claude file (bumping mtime) whenever the flag flips, -/// so a matching mtime means the cached answer is still valid. This lets dir_stats *stat* -/// unchanged sessions on each refresh instead of re-reading them. -fn deleted_cache() -> &'static std::sync::Mutex> { - static CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) -} - -/// Cheap soft-delete probe for counting, memoized by mtime. A Claude session's `__ccbud__.delete` -/// rides on the first parseable line, so a small head read suffices; a Codex rollout's flag is -/// re-read from the sidecar every time (itself mtime-cached and cheap). -fn is_session_deleted(file: &Path) -> bool { - let mt = mtime_ms(file); - let cached: Option = deleted_cache() - .lock() - .ok() - .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); - let kind = cached.unwrap_or_else(|| { - // Foreign sources are recognized by path shape alone — no read needed. Qoder is - // Claude-FORMAT but another tool's file, so its flag lives in the sidecar too. - let kind = if let Some(fk) = foreign_kind(file) { - DelKind::Foreign(fk) - } else if crate::qoder::looks_qoder_path(file) { - DelKind::Qoder - } else { - // Read the same window session_meta uses: a Codex rollout's first (session_meta) line - // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields - // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. - let recs = parse_lines(&read_head(file, 131072)); - // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — - // only live rollouts (no .import.json) use the sidecar. - if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { - DelKind::Codex - } else { - DelKind::Claude(read_ccbud(&recs).2) - } - }; - if let Ok(mut cache) = deleted_cache().lock() { - cache.insert(file.to_path_buf(), (mt, kind)); - } - kind - }); - match kind { - DelKind::Claude(del) => del, - DelKind::Codex => crate::codex::is_deleted(file), - DelKind::Qoder => crate::qoder::is_deleted(file), - DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), - DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), - DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), - } -} - -fn line_to_message(rec: &Value) -> Option { - let t = rec.get("type").and_then(|v| v.as_str())?; - if t != "user" && t != "assistant" { - return None; - } - let m = rec.get("message")?; - let role = m.get("role").and_then(|v| v.as_str())?; - let mut out = json!({ - "role": role, - "content": m.get("content").cloned().unwrap_or(Value::Null), - "_ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), - "_sidechain": rec.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), - "_meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), - }); - if t == "assistant" { - let o = out.as_object_mut().unwrap(); - o.insert("_model".into(), m.get("model").cloned().unwrap_or(Value::Null)); - o.insert("_usage".into(), m.get("usage").map(usage_of).unwrap_or(Value::Null)); - o.insert("_stopReason".into(), m.get("stop_reason").cloned().unwrap_or(Value::Null)); - } - Some(out) -} - -struct Shaped { - messages: Vec, - totals: Value, - model: Option, - first_ts: Option, - last_ts: Option, -} - -/// The renderer's normalized session shape shared by every non-Claude source (Codex, Grok, -/// Copilot, Antigravity): Anthropic-style messages (`role` + content blocks of -/// text/thinking/tool_use/tool_result) plus the session-level facts each format can recover. -pub struct Norm { - pub messages: Vec, - pub totals: Value, - pub model: Option, - pub first_ts: Option, - pub last_ts: Option, - pub cwd: Option, - pub session_id: Option, - pub thread_id: Option, - pub parent_thread_id: Option, - pub forked_from_id: Option, - pub is_subagent: bool, - pub agent_path: Option, - pub agent_nickname: Option, - pub agent_role: Option, - pub agent_depth: Option, - pub git_branch: Option, - pub version: Option, -} - -impl Default for Norm { - fn default() -> Self { - Norm { - messages: vec![], - totals: json!({ "in": 0, "out": 0, "cacheRead": 0, "cacheCreation": 0, "turns": 0 }), - model: None, - first_ts: None, - last_ts: None, - cwd: None, - session_id: None, - thread_id: None, - parent_thread_id: None, - forked_from_id: None, - is_subagent: false, - agent_path: None, - agent_nickname: None, - agent_role: None, - agent_depth: None, - git_branch: None, - version: None, - } - } -} - -/// data-URL image → Claude-style image source block, else None. -pub(crate) fn image_block(url: &str) -> Option { - let rest = url.strip_prefix("data:")?; - let (mime, b64) = rest.split_once(";base64,")?; - Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) -} - -fn shape_messages(recs: &[Value]) -> Shaped { - let mut messages = vec![]; - let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut credits = 0.0f64; - let mut has_credits = false; - let mut model: Option = None; - let mut first_ts: Option = None; - let mut last_ts: Option = None; - for r in recs { - let lm = match line_to_message(r) { - Some(m) => m, - None => continue, - }; - if lm.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { - continue; - } - let ts = lm.get("_ts").and_then(|v| v.as_str()).map(|s| s.to_string()); - if let Some(t) = &ts { - if first_ts.is_none() { - first_ts = Some(t.clone()); - } - last_ts = Some(t.clone()); - } - let mut msg = json!({ "role": lm.get("role").cloned().unwrap_or(Value::Null), "content": lm.get("content").cloned().unwrap_or(Value::Null) }); - let mo = msg.as_object_mut().unwrap(); - if lm.get("_sidechain").and_then(|v| v.as_bool()).unwrap_or(false) { - mo.insert("isSidechain".into(), json!(true)); - } - if let Some(t) = &ts { - mo.insert("ts".into(), json!(t)); - } - if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { - if let Some(md) = lm.get("_model").and_then(|v| v.as_str()) { - mo.insert("modelActual".into(), json!(md)); - model = Some(md.to_string()); - } - let u = lm.get("_usage").cloned().unwrap_or(Value::Null); - if u.is_object() { - mo.insert("usage".into(), u.clone()); - tin += u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - tout += u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); - tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); - tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); - if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { - credits += value; - has_credits = true; - } - turns += 1; - } - if let Some(sr) = lm.get("_stopReason").and_then(|v| v.as_str()) { - mo.insert("stopReason".into(), json!(sr)); - } - } - messages.push(msg); - } - let mut totals = json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }); - if has_credits { - let totals = totals.as_object_mut().unwrap(); - totals.insert("credits".into(), json!(credits)); - // Qoder's source log may omit usable token accounting while still providing real credits. - // Flag that state so the UI does not misrepresent unavailable token counts as zero usage. - if tin == 0 && tout == 0 && tcr == 0 && tcc == 0 { - totals.insert("tokenUsageAvailable".into(), json!(false)); - } - } - Shaped { - messages, - totals, - model, - first_ts, - last_ts, - } -} - -fn mtime_ms(file: &Path) -> f64 { - fs::metadata(file) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) -} - -/// File creation (birth) time in ms; mtime on filesystems that don't record one. NOT stable -/// across a title/tag edit — set_ccbud rewrites via tmp+rename, which gives the path the tmp -/// file's (fresh) birth time — so this is only the FALLBACK sort key when a session's records -/// carry no timestamp; record_created_ms is the real one. -pub(crate) fn created_ms(file: &Path) -> f64 { - fs::metadata(file) - .and_then(|m| m.created()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .filter(|v| *v > 0.0) - .unwrap_or_else(|| mtime_ms(file)) -} - -/// Session creation time for ORDERING: the first record's timestamp, i.e. content-derived and -/// therefore immune to file rewrites — renaming/tagging a conversation (tmp+rename resets the -/// fs birth time) must never reshuffle the list. Falls back to fs times when no record carries -/// a timestamp. Claude records and Codex rollout lines both put `timestamp` at the top level. -pub(crate) fn record_created_ms(recs: &[Value], file: &Path) -> f64 { - for r in recs { - if let Some(ts) = r.get("timestamp").and_then(|v| v.as_str()) { - if let Ok(d) = chrono::DateTime::parse_from_rfc3339(ts) { - return d.timestamp_millis() as f64; - } - } - } - created_ms(file) -} - -fn imports_root() -> PathBuf { - crate::store::ccbud_home().join("imports") -} -/// Configured dirs + the synthetic imported-transcripts store (id `__imported__`). -fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { - let mut dirs = config_dirs(config); - dirs.push(("__imported__".to_string(), "导入".to_string(), imports_root().join("projects"))); - dirs -} -/// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for -/// EVERY layout (Claude Code AND Qoder write `/projects/…`, Codex and Grok -/// `/sessions/…`, Copilot `/session-state/…`, Antigravity `/conversations/*.db`), -/// so `~/.codex`, `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli`, `~/.qoder` are just -/// configured dirs rather than special cases. -fn sibling_dir(projects_dir: &Path, name: &str) -> Option { - projects_dir.parent().map(|b| b.join(name)) -} - -fn sessions_dir(projects_dir: &Path) -> Option { - sibling_dir(projects_dir, "sessions") -} - -/// Dirs to watch for live history changes — each work dir's data trees (all four layouts). -pub fn watch_roots(config: &Value) -> Vec { - let mut roots: Vec = vec![]; - for (_, _, pd) in all_dirs(config) { - for name in ["sessions", "session-state", "conversations"] { - if let Some(sd) = sibling_dir(&pd, name) { - roots.push(sd); - } - } - roots.push(pd); - } - roots -} - -/// Walk every session .jsonl across the configured dirs (+ imports), invoking -/// `cb(file, dir_name, dir_id, dir_label)` — both the Claude projects/ tree and the -/// Codex sessions/ tree of each dir. -fn each_session_file(config: &Value, mut cb: F) { - for (id, label, root) in all_dirs(config) { - if let Ok(entries) = fs::read_dir(&root) { - for ent in entries.flatten() { - if !ent.path().is_dir() { - continue; - } - let dir_name = ent.file_name().to_string_lossy().into_owned(); - let pfiles = match fs::read_dir(ent.path()) { - Ok(f) => f, - Err(_) => continue, - }; - for f in pfiles.flatten() { - let p = f.path(); - if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p, dir_name.clone(), &id, &label); - } - } - } - } - // Codex rollouts live in a date-sharded sessions/ tree; Grok shares the same sessions/ - // root but keys children by percent-encoded cwd (and stuffs sidecar jsonl — events/ - // updates/rewind — beside each chat_history.jsonl), so children are routed one by one - // rather than letting the codex walker sweep grok trees into garbage rows. - if let Some(sd) = sessions_dir(&root) { - if let Ok(children) = fs::read_dir(&sd) { - for ent in children.flatten() { - let p = ent.path(); - let name = ent.file_name().to_string_lossy().into_owned(); - if p.is_dir() && crate::grok::is_cwd_dir_name(&name) { - crate::grok::walk_cwd_dir(&p, &mut |f| cb(f, String::new(), &id, &label)); - } else if p.is_dir() { - crate::codex::walk_sessions(&p, |f| cb(f, String::new(), &id, &label)); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - cb(p, String::new(), &id, &label); - } - } - } - } - // Copilot session logs (flat .jsonl + /events.jsonl). - if let Some(ss) = sibling_dir(&root, "session-state") { - crate::copilot::walk(&ss, &mut |f| cb(f, String::new(), &id, &label)); - } - // Antigravity conversations (one SQLite per session). - if let Some(cd) = sibling_dir(&root, "conversations") { - crate::antigravity::walk(&cd, &mut |f| cb(f, String::new(), &id, &label)); - } - } -} - -/// Mtime+size-keyed memo of session_meta list rows (mirrors the JS metaCache). List refreshes -/// fire on every watched write during a live session and previously re-read every candidate's -/// file head each time — with the memo, unchanged sessions cost a stat. Pruned in list_sessions -/// against the live file set; a live Codex rollout's sidecar edit (which does NOT touch the -/// file) is invalidated explicitly by set_ccbud. -fn meta_cache() -> &'static std::sync::Mutex> { - static CACHE: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) -} - -/// Freshness stamp for the list-meta / search caches: plain mtime, except Antigravity DBs -/// where a live agy writes into the WAL without touching the main file. -fn cache_stamp_ms(file: &Path) -> f64 { - match foreign_kind(file) { - Some(Foreign::Antigravity) => crate::antigravity::wal_mtime_ms(file), - _ => mtime_ms(file), - } -} - -fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { - let (mt, size) = (cache_stamp_ms(file), fs::metadata(file).ok()?.len()); - if let Ok(cache) = meta_cache().lock() { - if let Some((cmt, csz, v)) = cache.get(file) { - if *cmt == mt && *csz == size { - return Some(v.clone()); - } - } - } - let built = build_session_meta(file, dir_name, dir_id, dir_label)?; - // A permission failure is recoverable without changing the transcript's mtime/size (for - // example after the user grants macOS App Data access) — never memoize that placeholder row, - // otherwise it would stay "(conversation)" until the process restarts. Every OTHER read - // error is memoized like a normal row: the mtime/size key already invalidates it when the - // file changes, and skipping the memo would re-read a broken transcript on every refresh. - let awaiting_grant = built - .get("readError") - .and_then(|e| e.get("kind")) - .and_then(|k| k.as_str()) - == Some("permissionDenied"); - if !awaiting_grant { - if let Ok(mut cache) = meta_cache().lock() { - cache.insert(file.to_path_buf(), (mt, size, built.clone())); - } - } - Some(built) -} - -fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { - // Foreign sources first — routed by container shape BEFORE any content read (one of them - // isn't even text), each through its own shaper. - match foreign_kind(file) { - Some(Foreign::Grok) => return crate::grok::session_meta_from(file, dir_id, dir_label), - Some(Foreign::Copilot) => { - let recs = parse_lines(&read_head(file, 131072)); - return crate::copilot::session_meta_from(file, &recs, dir_id, dir_label); - } - Some(Foreign::Antigravity) => return crate::antigravity::session_meta_from(file, dir_id, dir_label), - None => {} - } - let meta = fs::metadata(file).ok()?; - let size = meta.len(); - let qoder = crate::qoder::looks_qoder_path(file); - // Qoder stores title/workspace/runtime records throughout the transcript, and its first JSON - // line can itself exceed the ordinary 128 KiB list window. Read the full file once (the row - // cache makes this a one-time cost per mtime/size); other formats retain the bounded head. - let raw = if qoder { - crate::qoder::read_text(file) - } else { - read_head_result(file, 131072) - }; - let (parsed_recs, read_error) = match raw { - Ok(raw) => (parse_lines(&raw), Value::Null), - Err(error) => ( - vec![], - session_read_error(file, &error) - .get("error") - .cloned() - .unwrap_or(Value::Null), - ), - }; - let qoder_title = if qoder { - crate::qoder::session_title_from(&parsed_recs) - } else { - None - }; - let qoder_cwd = if qoder { - crate::qoder::working_dir_from(&parsed_recs) - } else { - None - }; - let qoder_model = if qoder { - crate::qoder::model_from(&parsed_recs) - } else { - None - }; - let recs = if qoder { - crate::qoder::normalize_records(&parsed_recs) - } else { - parsed_recs - }; - // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list - // through the codex shaper — the record format shares nothing with Claude's. - if crate::codex::looks_codex(&recs) { - return crate::codex::session_meta_from(file, &recs, dir_id, dir_label); - } - // Qoder sessions use Claude-like user/assistant envelopes plus inline title/workspace/runtime - // records and atomic assistant content wrappers. normalize_records makes the message stream - // Claude-shaped; Qoder-specific metadata remains app-sidecar + inline JSONL data. - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); - let msgs: Vec = recs.iter().filter_map(line_to_message).collect(); - let (cc_title, cc_tags, cc_deleted) = - if qoder { crate::qoder::sidecar_meta(file) } else { read_ccbud(&recs) }; - let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&msgs)); - let mut model: Option = None; - for r in &recs { - if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { - if let Some(md) = r.get("message").and_then(|m| m.get("model")).and_then(|v| v.as_str()) { - model = Some(md.to_string()); - } - } - } - if qoder_model.is_some() { - model = qoder_model; - } - let subagent = agent_rec.is_some(); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let cwd = (if qoder { - qoder_cwd.or(top_level_cwd) - } else { - top_level_cwd - }) - .or_else(|| decode_dir_name(dir_name)); - let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); - let mt = mtime_ms(file); - Some(json!({ - "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, - "file": file.to_string_lossy(), - "source": if qoder { "qoder" } else { "disk" }, - "dirId": dir_id, - "dirLabel": dir_label, - "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(&stem), - "cwd": cwd.clone(), - "project": cwd.as_deref().map(base_name).unwrap_or_default(), - "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "model": model, - "isSubagent": subagent, - "imported": dir_id == "__imported__", - "deleted": cc_deleted, - "readError": read_error, - "createdAt": record_created_ms(&recs, file), - "lastActivity": mt, - "sizeKB": (size as f64 / 1024.0).round() as i64, - })) -} - -fn canonical_codex_key(session: &Value) -> Option { - if session.get("source").and_then(Value::as_str) != Some("codex") { - return None; - } - if session.get("canonicalThreadIdValid").and_then(Value::as_bool) != Some(true) { - return None; - } - let thread_id = session.get("threadId").and_then(Value::as_str)?; - let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - Some(format!("{dir_id}\0{thread_id}")) -} - -fn codex_canonical_filename(session: &Value) -> bool { - let Some(thread_id) = session.get("threadId").and_then(Value::as_str) else { - return false; - }; - let Some(file) = session.get("file").and_then(Value::as_str) else { - return false; - }; - let stem = Path::new(file).file_stem().and_then(|value| value.to_str()).unwrap_or(""); - stem == thread_id || stem.strip_suffix(thread_id).is_some_and(|prefix| prefix.ends_with('-')) -} - -fn codex_candidate_preferred(candidate: &Value, current: &Value) -> bool { - let candidate_file = candidate.get("file").and_then(Value::as_str).unwrap_or(""); - let current_file = current.get("file").and_then(Value::as_str).unwrap_or(""); - let thread_id = candidate - .get("threadId") - .and_then(Value::as_str) - .or_else(|| current.get("threadId").and_then(Value::as_str)) - .unwrap_or(""); - - // Codex's completed state DB is authoritative when its rollout_path still exists. Both - // candidates already passed ccbud's first-SessionMeta parse, so a matching path also verifies - // that the DB row belongs to this canonical id. - let preferred_path = [candidate_file, current_file] - .into_iter() - .filter(|file| !file.is_empty()) - .find_map(|file| crate::codex::preferred_rollout_path(Path::new(file), thread_id)); - if let Some(preferred) = preferred_path { - let candidate_matches = Path::new(candidate_file) == preferred.as_path(); - let current_matches = Path::new(current_file) == preferred.as_path(); - if candidate_matches != current_matches { - return candidate_matches; - } - } - - let imported = |value: &Value| value.get("imported").and_then(Value::as_bool).unwrap_or(false); - if imported(candidate) != imported(current) { - return !imported(candidate); - } - let archived = |file: &str| { - Path::new(file) - .components() - .any(|part| part.as_os_str().to_str() == Some("archived_sessions")) - }; - if archived(candidate_file) != archived(current_file) { - return !archived(candidate_file); - } - let number = |value: &Value, field: &str| value.get(field).and_then(Value::as_f64).unwrap_or(0.0); - for field in ["lastActivity", "createdAt"] { - let candidate_value = number(candidate, field); - let current_value = number(current, field); - if candidate_value != current_value { - return candidate_value > current_value; - } - } - if codex_canonical_filename(candidate) != codex_canonical_filename(current) { - return codex_canonical_filename(candidate); - } - let candidate_size = number(candidate, "sizeKB"); - let current_size = number(current, "sizeKB"); - if candidate_size != current_size { - return candidate_size > current_size; - } - candidate_file > current_file -} - -fn dedupe_canonical_codex_sessions(sessions: Vec) -> Vec { - let mut out = Vec::with_capacity(sessions.len()); - let mut positions = std::collections::HashMap::::new(); - for session in sessions { - let Some(key) = canonical_codex_key(&session) else { - out.push(session); - continue; - }; - if let Some(index) = positions.get(&key).copied() { - if codex_candidate_preferred(&session, &out[index]) { - out[index] = session; - } - } else { - positions.insert(key, out.len()); - out.push(session); - } - } - out -} - -fn limit_with_codex_ancestors(sessions: Vec, limit: usize) -> Vec { - if sessions.len() <= limit { - return sessions; - } - let mut positions = std::collections::HashMap::::new(); - for (index, session) in sessions.iter().enumerate() { - if let Some(key) = canonical_codex_key(session) { - positions.insert(key, index); - } - } - let mut included: std::collections::HashSet = (0..limit).collect(); - let mut queue: Vec = (0..limit).collect(); - let mut cursor = 0usize; - while cursor < queue.len() { - let index = queue[cursor]; - cursor += 1; - let session = &sessions[index]; - if canonical_codex_key(session).is_none() { - continue; - } - let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - let direct_parent = session.get("parentThreadId").and_then(Value::as_str); - let root_parent = session - .get("isSubagent") - .and_then(Value::as_bool) - .unwrap_or(false) - .then(|| session.get("rootSessionId").and_then(Value::as_str)) - .flatten(); - let parent_index = [direct_parent, root_parent] - .into_iter() - .flatten() - .find_map(|parent_id| positions.get(&format!("{dir_id}\0{parent_id}")).copied()); - let Some(parent_index) = parent_index else { - continue; - }; - if included.insert(parent_index) { - queue.push(parent_index); - } - } - sessions - .into_iter() - .enumerate() - .filter_map(|(index, session)| included.contains(&index).then_some(session)) - .collect() -} - -pub fn list_sessions(config: &Value, active: &str, limit: usize) -> Vec { - // The recycle bin spans every dir and shows only soft-deleted sessions; every other view - // is scoped to its dir and hides them. - let trash = active == TRASH_ID; - // Read (memoized) metas for EVERY candidate, then dedupe/order before the limit cut. Most - // formats use content-derived CreatedAt so title/tag rewrites cannot reshuffle rows; Codex - // uses rollout UpdatedAt because its custom metadata is sidecar-only and Codex defines latest - // that way. The meta cache turns the full walk into stats for unchanged files. - let mut live: std::collections::HashSet = std::collections::HashSet::new(); - let mut candidates: Vec<(PathBuf, String, String, String)> = Vec::new(); - each_session_file(config, |file, dir_name, id, label| { - live.insert(file.clone()); - if !trash && active != "all" && id != active { - return; - } - candidates.push((file, dir_name, id.to_string(), label.to_string())); - }); - // Warm the qoder helper cache in ONE batch before the per-row reads — on a macOS install with - // protected app data, every stale row would otherwise spawn its own helper process. - let qoder_files: Vec = candidates - .iter() - .map(|(file, _, _, _)| file.clone()) - .filter(|file| crate::qoder::looks_qoder_path(file)) - .collect(); - crate::qoder::prefetch(&qoder_files); - let mut out: Vec = Vec::new(); - for (file, dir_name, id, label) in &candidates { - if let Some(m) = session_meta(file, dir_name, id, label) { - out.push(m); - } - } - // Drop memo entries for files that no longer exist, so removed dirs don't pin stale rows. - if let Ok(mut cache) = meta_cache().lock() { - cache.retain(|k, _| live.contains(k)); - } - // Collapse only true physical duplicates (same dir + canonical thread id) BEFORE limit. - // Threads that merely share rootSessionId are distinct root/subagent nodes and remain intact. - let mut out = dedupe_canonical_codex_sessions(out); - // Apply recycle-bin visibility to the selected logical representative, not to each physical - // candidate. Otherwise deleting the authoritative copy could make a stale duplicate reappear - // in the normal list while the same logical thread also sits in the recycle bin. - out.retain(|session| { - session.get("deleted").and_then(Value::as_bool).unwrap_or(false) == trash - }); - let key = |v: &Value| { - // Codex defines "latest" as UpdatedAt (rollout mtime); its title/tags live in a sidecar, - // so this timestamp is not dirtied by ccbud edits. Keep the stable CreatedAt policy for - // formats whose transcript itself is rewritten when metadata changes. - let field = if v.get("source").and_then(Value::as_str) == Some("codex") { - "lastActivity" - } else { - "createdAt" - }; - v.get(field).and_then(Value::as_f64).unwrap_or(0.0) - }; - out.sort_by(|a, b| { - key(b) - .partial_cmp(&key(a)) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| { - let updated = |value: &Value| { - value.get("lastActivity").and_then(Value::as_f64).unwrap_or(0.0) - }; - updated(b) - .partial_cmp(&updated(a)) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .then_with(|| { - let a_id = a - .get("threadId") - .or_else(|| a.get("id")) - .and_then(Value::as_str) - .unwrap_or(""); - let b_id = b - .get("threadId") - .or_else(|| b.get("id")) - .and_then(Value::as_str) - .unwrap_or(""); - b_id.cmp(a_id) - }) - }); - // Soft-cap Codex trees: include the parent/root chain of every selected child so a busy tree - // cannot show orphan subagents merely because its older root fell just below the limit. - limit_with_codex_ancestors(out, limit) -} - -pub fn list_projects(config: &Value, active: &str) -> Vec { - let sessions = list_sessions(config, active, 600); - let mut order: Vec = vec![]; - let mut groups: std::collections::HashMap = std::collections::HashMap::new(); - for s in sessions { - let cwd = s.get("cwd").and_then(|v| v.as_str()).unwrap_or("(unknown)").to_string(); - let la = s.get("lastActivity").and_then(|v| v.as_f64()).unwrap_or(0.0); - let ct = s.get("createdAt").and_then(|v| v.as_f64()).unwrap_or(la); - let sk = if s.get("source").and_then(Value::as_str) == Some("codex") { la } else { ct }; - let g = groups.entry(cwd.clone()).or_insert_with(|| { - order.push(cwd.clone()); - json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0, "sortActivity": 0.0 }) - }); - g["sessions"].as_array_mut().unwrap().push(s.clone()); - if la > g["lastActivity"].as_f64().unwrap_or(0.0) { - g["lastActivity"] = json!(la); - } - if ct > g["createdAt"].as_f64().unwrap_or(0.0) { - g["createdAt"] = json!(ct); - } - if sk > g["sortActivity"].as_f64().unwrap_or(0.0) { - g["sortActivity"] = json!(sk); - } - } - // Codex's latest semantic is rollout UpdatedAt; other formats retain CreatedAt so in-file - // title/tag edits cannot reorder them. Apply the same source-aware rule to rows and projects. - let sort_key = |v: &Value| { - let field = if v.get("source").and_then(Value::as_str) == Some("codex") { - "lastActivity" - } else { - "createdAt" - }; - v.get(field).and_then(Value::as_f64).unwrap_or(0.0) - }; - let mut arr: Vec = order.into_iter().filter_map(|k| groups.remove(&k)).collect(); - for g in &mut arr { - g["sessions"].as_array_mut().unwrap().sort_by(|a, b| { - sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal) - }); - } - arr.sort_by(|a, b| { - let key = |value: &Value| { - value.get("sortActivity").and_then(Value::as_f64).unwrap_or(0.0) - }; - key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) - }); - for group in &mut arr { - if let Some(object) = group.as_object_mut() { - object.remove("sortActivity"); - } - } - arr -} - -pub fn dir_stats(config: &Value) -> Vec { - // Per-dir counts exclude soft-deleted sessions (they're hidden from those views); the deleted - // ones are tallied separately into the synthetic recycle-bin bucket. Reuse list_sessions so - // counts reflect canonical logical rows rather than duplicate physical rollout files. - let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - for session in list_sessions(config, "all", usize::MAX) { - let id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); - *counts.entry(id.to_string()).or_insert(0) += 1; - } - let trash = list_sessions(config, TRASH_ID, usize::MAX).len() as i64; - let mut out: Vec = all_dirs(config) - .into_iter() - .map(|(id, label, pd)| { - // A dir "exists" when ANY data tree is on disk — ~/.codex has only sessions/, - // ~/.copilot only session-state/, ~/.gemini/antigravity-cli only conversations/. - let exists = pd.is_dir() - || ["sessions", "session-state", "conversations"] - .iter() - .any(|n| sibling_dir(&pd, n).map(|s| s.is_dir()).unwrap_or(false)); - let imported = id == "__imported__"; - json!({ - "id": id.clone(), "label": label, "projectsDir": pd.to_string_lossy(), - "sessions": counts.get(&id).copied().unwrap_or(0), "exists": exists, "imported": imported, - }) - }) - .collect(); - out.push(json!({ - "id": TRASH_ID, "label": "回收站", "projectsDir": "", - "sessions": trash, "exists": true, "imported": false, "trash": true, - })); - out -} - -/// A skill-forked subagent transcript opens with a sentinel user line -/// "Base directory for this skill: /" — the last path segment names the skill. -/// Fallback attribution only: the spawning `Skill` tool_use in the parent thread -/// (apply_skill_names) is authoritative and overrides this when present. (history.js skillFromRecs) -const SKILL_BASE_DIR_PREFIX: &str = "Base directory for this skill: "; -pub(crate) fn skill_from_recs(recs: &[Value]) -> Option { - let first = recs.iter().find(|r| { - r.get("type").and_then(|v| v.as_str()) == Some("user") - && r.get("message").is_some() - && !r.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) - })?; - let text = content_text(first.get("message")?.get("content").unwrap_or(&Value::Null)); - // Only the opening prompt carries the sentinel — don't scan further user turns. - let rest = text.trim().strip_prefix(SKILL_BASE_DIR_PREFIX)?; - let line = rest.lines().next().unwrap_or("").trim(); - line.split(['/', '\\']).filter(|s| !s.is_empty()).last().map(|s| s.to_string()) -} - -/// Primary skill attribution (history.js applySkillNames): a subagent spawned by the `Skill` tool -/// is named by the spawning tool_use's input.skill (matched by tool_use id — the subagents map -/// key), in whichever thread the call lives (main or a nested subagent). Overrides the sentinel -/// fallback from skill_from_recs. -pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json::Map) { - if subs.is_empty() { - return; - } - fn scan(msgs: &[Value], subs: &serde_json::Map, out: &mut Vec<(String, String)>) { - for m in msgs { - let blocks = match m.get("content").and_then(|c| c.as_array()) { - Some(b) => b, - None => continue, - }; - for b in blocks { - if b.get("type").and_then(|v| v.as_str()) != Some("tool_use") - || b.get("name").and_then(|v| v.as_str()) != Some("Skill") - { - continue; - } - let id = match b.get("id").and_then(|v| v.as_str()) { - Some(i) if subs.contains_key(i) => i, - _ => continue, - }; - if let Some(s) = b.get("input").and_then(|i| i.get("skill")).and_then(|v| v.as_str()) { - let s = s.trim(); - if !s.is_empty() { - out.push((id.to_string(), s.to_string())); - } - } - } - } - } - let mut named: Vec<(String, String)> = vec![]; - scan(main_messages, subs, &mut named); - for (_, v) in subs.iter() { - if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) { - scan(msgs, subs, &mut named); - } - } - for (id, name) in named { - if let Some(o) = subs.get_mut(&id).and_then(|s| s.as_object_mut()) { - o.insert("skill".into(), json!(name)); - } - } -} - -/// Read a session's child subagent dialogues from `/subagents/agent-*.jsonl` (+ .meta.json), -/// keyed by the spawning tool_use id so the renderer can nest them. {} when none. (history.js readSubagents) -fn read_subagents(file: &str) -> serde_json::Map { - let p = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(p); - let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let dir = match p.parent() { - Some(d) => d.join(stem).join("subagents"), - None => return serde_json::Map::new(), - }; - let mut by_tool = serde_json::Map::new(); - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return by_tool, - }; - let mut agent_files: Vec<(String, PathBuf)> = vec![]; - for ent in entries.flatten() { - let name = ent.file_name().to_string_lossy().to_string(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - agent_files.push((name, ent.path())); - } - } - // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch - // instead of two spawns per agent. - if qoder { - let mut warm: Vec = vec![]; - for (name, path) in &agent_files { - warm.push(path.clone()); - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); - warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); - } - crate::qoder::prefetch(&warm); - } - for (name, transcript_path) in agent_files { - let agent_id = name - .trim_start_matches("agent-") - .trim_end_matches(".jsonl") - .to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if qoder { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - let raw = match read_session_text(&transcript_path) { - Ok(s) => s, - Err(_) => continue, - }; - let parsed = parse_lines(&raw); - let recs = if qoder { - crate::qoder::normalize_records(&parsed) - } else { - parsed - }; - let shaped = shape_messages(&recs); - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - let agent_type = meta - .get("agentType") - .and_then(|v| v.as_str()) - .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) - .unwrap_or("agent"); - by_tool.insert( - key, - json!({ - "agentId": agent_id, - "file": transcript_path.to_string_lossy(), - "type": agent_type, - "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), - "skill": skill_from_recs(&recs), - "count": shaped.messages.len(), - "totals": shaped.totals, - "messages": shaped.messages, - }), - ); - } - by_tool -} - -/// A session's subagents directory: `//subagents`. None when the path has no stem. -fn subagent_dir(file: &Path) -> Option { - let stem = file.file_stem().and_then(|s| s.to_str())?; - file.parent().map(|d| d.join(stem).join("subagents")) -} - -/// The raw subagent sidecar files for a session — `(agent-*.jsonl | agent-*.meta.json, bytes)`. -/// Empty when the session spawned no subagents. Shared by bundle export, import, and replay-merge. -fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { - let dir = match subagent_dir(file) { - Some(d) => d, - None => return vec![], - }; - let qoder = crate::qoder::looks_qoder_path(file); - let mut out = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_file() { - continue; - } - let name = ent.file_name().to_string_lossy().into_owned(); - let lower = name.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - let bytes = if qoder { - crate::qoder::read_bytes(&p) - } else { - fs::read(&p) - }; - if let Ok(bytes) = bytes { - out.push((name, bytes)); - } - } - } - } - out.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic bundle order - out -} - -/// Whether a session has any subagent transcripts (drives export → .zip vs plain .jsonl). -pub fn session_has_subagents(file: &str) -> bool { - !read_subagent_files(Path::new(file)).is_empty() -} - -/// Build a conversation-bundle ZIP: the main session `.jsonl` at the top level and each -/// subagent file under `subagents/`. Caller uses this only when the session actually has subagents -/// (a plain .jsonl export otherwise). Round-trips through import_zip / splitBundle. -pub fn export_bundle(file: &str) -> std::io::Result> { - let path = Path::new(file); - let main = read_session_bytes(path)?; - let main_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("conversation.jsonl") - .to_string(); - let mut entries = vec![crate::ziputil::Entry { name: main_name, data: main }]; - for (name, bytes) in read_subagent_files(path) { - entries.push(crate::ziputil::Entry { name: format!("subagents/{}", name), data: bytes }); - } - Ok(crate::ziputil::build(&entries)) -} - -/// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. -/// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is -/// attached alongside the main session in the Cowork deep link (which takes a repeated `file=` param), -/// so the analysis covers subagent runs — not just the main thread. -pub fn subagent_transcript_paths(file: &str) -> Vec { - let dir = match subagent_dir(Path::new(file)) { - Some(d) => d, - None => return vec![], - }; - let mut out = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let p = ent.path(); - if !p.is_file() { - continue; - } - let name = ent.file_name().to_string_lossy().to_lowercase(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - out.push(p.to_string_lossy().into_owned()); - } - } - } - out.sort(); - out -} - -/// Read the import provenance sidecar (`.import.json`) for an imported transcript. -pub(crate) fn read_import_meta(file: &str) -> Option { - let p = Path::new(file); - let stem = p.file_stem().and_then(|s| s.to_str())?; - let dir = p.parent()?; - let raw = fs::read_to_string(dir.join(format!("{}.import.json", stem))).ok()?; - serde_json::from_str(&raw).ok() -} - -pub fn get_session(file: &str) -> Value { - let path = Path::new(file); - let qoder = crate::qoder::looks_qoder_path(path); - // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are - // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. - match foreign_kind(path) { - Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), - Some(fk) => { - let raw = match read_session_text(path) { - Ok(s) => s, - Err(error) => return session_read_error(path, &error), - }; - let recs = parse_lines(&raw); - return match fk { - Foreign::Grok => crate::grok::session_from_recs(file, &recs), - _ => crate::copilot::session_from_recs(file, &recs), - }; - } - None => {} - } - let raw = match read_session_text(path) { - Ok(s) => s, - Err(error) => return session_read_error(path, &error), - }; - let parsed_recs = parse_lines(&raw); - if crate::codex::looks_codex(&parsed_recs) { - return crate::codex::session_from_recs(file, &parsed_recs); - } - let qoder_title = if qoder { - crate::qoder::session_title_from(&parsed_recs) - } else { - None - }; - let qoder_cwd = if qoder { - crate::qoder::working_dir_from(&parsed_recs) - } else { - None - }; - let qoder_model = if qoder { - crate::qoder::model_from(&parsed_recs) - } else { - None - }; - let recs = if qoder { - crate::qoder::normalize_records(&parsed_recs) - } else { - parsed_recs - }; - let meta_rec = recs - .iter() - .find(|r| r.get("cwd").is_some()) - .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); - let agent_id = agent_rec - .and_then(|r| r.get("agentId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let summary = recs - .iter() - .find(|r| r.get("type").and_then(|v| v.as_str()) == Some("summary") && r.get("summary").is_some()) - .and_then(|r| r.get("summary").cloned()); - // Qoder detail mirrors build_session_meta: normalized atomic wrappers, inline metadata, and - // app-owned title/tags/delete overrides without rewriting another CLI's transcript. - let (cc_title, cc_tags, cc_deleted) = - if qoder { crate::qoder::sidecar_meta(path) } else { read_ccbud(&recs) }; - let shaped = shape_messages(&recs); - let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&shaped.messages)); - let subagent = agent_rec.is_some(); - let top_level_cwd = meta_rec - .and_then(|r| r.get("cwd")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let cwd = if qoder { - qoder_cwd.or(top_level_cwd) - } else { - top_level_cwd - }; - let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); - let base_id = meta_rec - .and_then(|r| r.get("sessionId")) - .and_then(|v| v.as_str()) - .unwrap_or(&stem) - .to_string(); - // A subagent session's id carries the agent suffix; only a top-level session embeds subagents. - let sess_id = match (subagent, &agent_id) { - (true, Some(aid)) => format!("{}-{}", base_id, aid), - _ => base_id.clone(), - }; - let mut subs = if subagent { serde_json::Map::new() } else { read_subagents(file) }; - apply_skill_names(&shaped.messages, &mut subs); - // Live Qoder files are never imported snapshots; avoid probing a protected sibling sidecar. - let import_meta = if qoder { None } else { read_import_meta(file) }; - - json!({ - "meta": { - "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, - "file": file, - "source": if qoder { "qoder" } else { "disk" }, - // Renderer falls back to Claude when null (the app's home turf carries no label). - "assistant": if qoder { json!("Qoder") } else { Value::Null }, - "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), - "autoTitle": auto_title, - "tags": cc_tags, - "summary": summary, - "sessionId": sess_id, - "cwd": cwd.clone(), - "project": cwd.as_deref().map(base_name).unwrap_or_default(), - "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), - "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), - "isSubagent": subagent, - // A standalone subagent transcript self-reports its invoking skill via the sentinel. - "skill": if subagent { skill_from_recs(&recs) } else { None:: }, - "deleted": cc_deleted, - "imported": import_meta.is_some(), - "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), - "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), - "model": qoder_model.or(shaped.model), - "totals": shaped.totals, - "messages": shaped.messages.len(), - "subagentCount": subs.len(), - "firstTs": shaped.first_ts, - "lastTs": shaped.last_ts, - }, - "messages": shaped.messages, - "subagents": subs, - }) -} - -// ---- content search (the session list's "big search") ---- -// -// Scans session CONTENT (message text / thinking / tool calls + results) across every listed -// session — main threads, their subagent transcripts, and Codex rollouts — and reports, per -// matching session, WHERE the first match lives ("main" or a subagent's tool_use key) plus a -// display snippet. The renderer opens the session, switches the panel to that agent, and -// re-finds the query locally, so list hits and in-conversation positioning stay aligned. -// -// Performance model (this runs per keystroke, debounced): -// - extraction cache: path -> (mtime, size, extracted text), so repeated queries pay the JSON -// parse + shaping once per file version; -// - raw prefilter: on a cache miss the raw JSONL bytes are substring-scanned first, and only -// files that could match are parsed at all (JSON escapes quotes/backslashes/control chars, -// so the prefilter is skipped for queries containing those); -// - parallel scan: per-file work fans out over a small thread pool. - -/// ASCII-case-insensitive substring search (byte-wise; non-ASCII must match exactly — CJK has no -/// case). A valid-UTF-8 needle can only match at char boundaries of valid-UTF-8 text (ASCII bytes -/// never equal continuation bytes), so the returned byte offset is safe to slice on. -fn ifind(hay: &str, needle: &str, from: usize) -> Option { - let h = hay.as_bytes(); - let n = needle.as_bytes(); - if n.is_empty() || h.len() < n.len() { - return None; - } - let last = h.len() - n.len(); - let n0 = n[0].to_ascii_lowercase(); - let mut i = from; - while i <= last { - if h[i].to_ascii_lowercase() == n0 { - let mut k = 1; - while k < n.len() && h[i + k].to_ascii_lowercase() == n[k].to_ascii_lowercase() { - k += 1; - } - if k == n.len() { - return Some(i); - } - } - i += 1; - } - None -} - -/// Non-overlapping case-insensitive occurrence count (same fold as ifind). -fn icount(hay: &str, needle: &str) -> usize { - let (mut i, mut c) = (0usize, 0usize); - while let Some(p) = ifind(hay, needle, i) { - c += 1; - i = p + needle.len().max(1); - } - c -} - -/// Mirror of the renderer's formatCodexBootstrap (conversations.js / runtime.js): Codex records -/// its initial AGENTS.md instructions + environment snapshot as one XML-ish user text block, and -/// the panel renders it as compact Markdown — search must index that same Markdown, not the raw -/// transport shape. None = not a bootstrap message (ordinary prose passes through untouched). -fn format_codex_bootstrap(source: &str) -> Option { - static AGENTS_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static ENV_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static ROOT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let agents_re = AGENTS_RE.get_or_init(|| { - regex::Regex::new( - r"(?is)^\s*#\s+AGENTS\.md instructions for ([^\r\n]+).*?]*>(.*?)", - ) - .unwrap() - }); - let env_re = ENV_RE.get_or_init(|| { - regex::Regex::new(r"(?is)]*>(.*?)").unwrap() - }); - let root_re = - ROOT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); - let agents = agents_re.captures(source)?; - - // Dynamic per-name regexes are fine here: at most one bootstrap message exists per session. - let tag = |block: &str, name: &str| -> String { - regex::Regex::new(&format!(r"(?is)<{name}\b[^>]*>(.*?)")) - .ok() - .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) - .unwrap_or_default() - }; - let attr = |block: &str, name: &str, attribute: &str| -> String { - regex::Regex::new(&format!(r#"(?i)<{name}\b[^>]*\b{attribute}=["']([^"']+)["']"#)) - .ok() - .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) - .unwrap_or_default() - }; - let code = |value: &str| -> String { - if value.is_empty() { String::new() } else { format!("`{}`", value) } - }; - - let mut parts: Vec = - vec![format!("# AGENTS.md instructions for {}", agents.get(1).map(|m| m.as_str().trim()).unwrap_or(""))]; - let instructions = agents.get(2).map(|m| m.as_str().trim()).unwrap_or(""); - if !instructions.is_empty() { - let lines: Vec<&str> = instructions.lines().filter(|line| !line.trim().is_empty()).collect(); - parts.push(if lines.len() == 1 { - format!("**INSTRUCTIONS:** {}", lines[0].trim()) - } else { - format!("**INSTRUCTIONS:**\n\n{}", instructions) - }); - } - - let env = env_re.captures(source); - if let Some(env) = &env { - let block = env.get(1).map(|m| m.as_str()).unwrap_or(""); - let roots: Vec = root_re - .captures_iter(block) - .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_string())) - .filter(|r| !r.is_empty()) - .map(|r| code(&r)) - .collect(); - let fields: Vec<(&str, String)> = vec![ - ("environment_context", code(&tag(block, "cwd"))), - ("shell", tag(block, "shell")), - ("current_date", tag(block, "current_date")), - ("timezone", tag(block, "timezone")), - ("workspace_roots", roots.join(", ")), - ("permission_profile", attr(block, "permission_profile", "type")), - ("file_system", attr(block, "file_system", "type")), - ] - .into_iter() - .filter(|(_, value)| !value.is_empty()) - .collect(); - if !fields.is_empty() { - parts.push( - fields - .iter() - .map(|(name, value)| format!("**{}:** {}", name, value)) - .collect::>() - .join(" \n"), - ); - } - } - - let mut rest = source.replacen(agents.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); - if let Some(env) = &env { - rest = rest.replacen(env.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); - } - let rest = rest.trim(); - if !rest.is_empty() { - parts.push(rest.to_string()); - } - Some(parts.join("\n\n").trim().to_string()) -} - -/// Strip harness-injected blocks from user prose — MUST stay rule-for-rule in sync with the -/// renderer's stripInjected (conversations.js) and the export viewer's copy (runtime.js), so what -/// the big search matches is exactly what the in-conversation search (and the panel) will show. -/// The Codex AGENTS bootstrap reformats to the same Markdown the panel renders; task-notification -/// envelopes keep their human-facing body; the transport metadata (ids, status, summary) -/// is dropped and must therefore never be searchable. -fn strip_injected(s: &str) -> String { - static SKILL_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static TASK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static RESULT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - // Same rule order as the JS: the Codex AGENTS bootstrap is reformatted FIRST, then the - // envelope rules run over the (possibly rewritten) text. - let bootstrap = format_codex_bootstrap(s); - let s: &str = bootstrap.as_deref().unwrap_or(s); - // A turn that is nothing but a envelope is Codex's recorded skill-instruction - // injection — runtime context the panel suppresses wholesale, so search must too. Prose that - // merely quotes markup alongside other text stays searchable. - let skill_re = SKILL_RE - .get_or_init(|| regex::Regex::new(r"(?is)^\s*]*>.*\s*$").unwrap()); - if skill_re.is_match(s) { - return String::new(); - } - let task_re = TASK_RE.get_or_init(|| { - regex::Regex::new(r"(?is)]*>.*?").unwrap() - }); - let result_re = - RESULT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); - let re = RE.get_or_init(|| { - regex::Regex::new( - r"(?s).*?|.*?|.*?", - ) - .unwrap() - }); - let unwrapped = task_re.replace_all(s, |caps: ®ex::Captures| { - result_re - .captures(caps.get(0).map(|m| m.as_str()).unwrap_or("")) - .and_then(|c| c.get(1)) - .map(|m| format!("\n{}\n", m.as_str().trim())) - .unwrap_or_default() - }); - re.replace_all(&unwrapped, "").trim().to_string() -} - -fn tool_result_search_text(c: &Value) -> String { - if let Some(s) = c.as_str() { - return s.to_string(); - } - if let Some(arr) = c.as_array() { - return arr - .iter() - .filter_map(|x| x.get("text").and_then(|t| t.as_str())) - .collect::>() - .join("\n"); - } - String::new() -} - -/// One searchable text blob for a shaped message list — the renderer's messagePlainText, flattened: -/// user prose (injected blocks stripped), assistant text, thinking, tool name + input JSON, and -/// tool results. Images and raw structure are skipped so a hit here is findable in the panel. -fn extract_search_text(messages: &[Value]) -> String { - let mut out = String::new(); - let mut push = |t: &str| { - if !t.is_empty() { - out.push_str(t); - out.push('\n'); - } - }; - for m in messages { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or(""); - let content = match m.get("content") { - Some(c) => c, - None => continue, - }; - if let Some(s) = content.as_str() { - if role == "user" { - push(&strip_injected(s)); - } else { - push(s); - } - continue; - } - let arr = match content.as_array() { - Some(a) => a, - None => continue, - }; - for b in arr { - match b.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "text" => { - let t = b.get("text").and_then(|v| v.as_str()).unwrap_or(""); - if role == "user" { - push(&strip_injected(t)); - } else { - push(t); - } - } - "thinking" => push(b.get("thinking").and_then(|v| v.as_str()).unwrap_or("")), - "skill_load" => { - push(b.get("name").and_then(Value::as_str).unwrap_or("")); - push(b.get("path").and_then(Value::as_str).unwrap_or("")); - push(b.get("snapshot").and_then(Value::as_str).unwrap_or("")); - } - "tool_use" => { - let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let input = b.get("input").map(|i| i.to_string()).unwrap_or_default(); - push(&format!("{} {}", name, input)); - } - "tool_result" => { - push(&tool_result_search_text(b.get("content").unwrap_or(&Value::Null))) - } - _ => {} - } - } - } - out -} - -struct SearchCache { - map: std::collections::HashMap)>, - bytes: usize, -} -/// Extracted-text memo, keyed path -> (mtime, size, text). Cleared wholesale past the byte budget -/// (crude but safe — the next search simply re-extracts what it touches). -fn search_cache() -> &'static std::sync::Mutex { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(SearchCache { map: std::collections::HashMap::new(), bytes: 0 })) -} -const SEARCH_CACHE_BUDGET: usize = 128 * 1024 * 1024; - -/// Search one transcript file for `q`: (extracted text, first-match byte offset), or None. -/// Serves from the extraction cache when fresh; otherwise prefilters the raw bytes and only -/// parses candidates — files that can't match are neither parsed nor cached. -fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc, usize)> { - let meta = fs::metadata(path).ok()?; - let (mt, sz) = (cache_stamp_ms(path), meta.len()); - if let Ok(cache) = search_cache().lock() { - if let Some((cmt, csz, text)) = cache.map.get(path) { - if *cmt == mt && *csz == sz { - let t = text.clone(); - drop(cache); - return ifind(&t, q, 0).map(|p| (t, p)); - } - } - } - let fk = foreign_kind(path); - let messages: Vec = if fk == Some(Foreign::Antigravity) { - // SQLite source: no raw-bytes prefilter (the payloads are binary) — extraction is - // cached, so the decode is paid once per file version. - crate::antigravity::normalize_db(path).messages - } else { - let raw = read_session_text(path).ok()?; - if raw_safe && ifind(&raw, q, 0).is_none() { - return None; - } - let parsed = parse_lines(&raw); - let recs = if crate::qoder::looks_qoder_path(path) { - crate::qoder::normalize_records(&parsed) - } else { - parsed - }; - match fk { - Some(Foreign::Grok) => crate::grok::normalize(&recs, None).messages, - Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, - _ => { - if crate::codex::looks_codex(&recs) { - crate::codex::session_from_recs(&path.to_string_lossy(), &recs) - .get("messages") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - } else { - shape_messages(&recs).messages - } - } - } - }; - let text = std::sync::Arc::new(extract_search_text(&messages)); - if let Ok(mut cache) = search_cache().lock() { - if cache.bytes + text.len() > SEARCH_CACHE_BUDGET { - cache.map.clear(); - cache.bytes = 0; - } - if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, sz, text.clone())) { - cache.bytes = cache.bytes.saturating_sub(old.len()); // replaced a stale entry - } - cache.bytes += text.len(); - } - ifind(&text, q, 0).map(|p| (text, p)) -} - -/// Display snippet around the first match: ~56 chars of context either side, whitespace collapsed, -/// ellipsized at cut edges. Slice bounds snap outward/inward to char boundaries. -fn snippet_around(text: &str, pos: usize, match_len: usize) -> String { - const CTX: usize = 56; - let mut start = pos.saturating_sub(CTX); - while start > 0 && !text.is_char_boundary(start) { - start -= 1; - } - let mut end = (pos + match_len + CTX).min(text.len()); - while end < text.len() && !text.is_char_boundary(end) { - end += 1; - } - let body = text[start..end].split_whitespace().collect::>().join(" "); - format!("{}{}{}", if start > 0 { "…" } else { "" }, body, if end < text.len() { "…" } else { "" }) -} - -/// Scan one session — main thread first, then each subagent transcript — and shape the hit the -/// renderer needs to auto-locate: which agent matched, a snippet, and the occurrence count. -fn scan_session(file: &Path, q: &str, raw_safe: bool) -> Option { - if let Some((text, pos)) = thread_scan(file, q, raw_safe) { - return Some(json!({ - "file": file.to_string_lossy(), - "agent": "main", - "snippet": snippet_around(&text, pos, q.len()), - "count": icount(&text, q), - })); - } - let dir = subagent_dir(file)?; - let mut names: Vec = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for ent in entries.flatten() { - let name = ent.file_name().to_string_lossy().into_owned(); - if name.starts_with("agent-") && name.ends_with(".jsonl") { - names.push(name); - } - } - } - names.sort(); - for name in names { - if let Some((text, pos)) = thread_scan(&dir.join(&name), q, raw_safe) { - let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); - let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); - let meta_raw = if crate::qoder::looks_qoder_path(file) { - crate::qoder::read_text(&meta_path) - } else { - fs::read_to_string(&meta_path) - }; - let meta: Value = meta_raw - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_else(|| json!({})); - // Key by the spawning tool_use id — the same key read_subagents uses, so the renderer - // can switch its panel straight to this agent. - let key = meta - .get("toolUseId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("agent:{}", agent_id)); - let agent_type = meta - .get("agentType") - .and_then(|v| v.as_str()) - .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) - .unwrap_or("agent"); - return Some(json!({ - "file": file.to_string_lossy(), - "agent": key, - "agentType": agent_type, - "snippet": snippet_around(&text, pos, q.len()), - "count": icount(&text, q), - })); - } - } - None -} - -/// Content search over the same candidate set (and dir/trash scoping) as the list view, newest -/// first. Returns [{ file, agent, agentType?, snippet, count }] for up to `limit` sessions. -pub fn search_sessions(config: &Value, active: &str, query: &str, limit: usize) -> Vec { - let q = query.trim(); - if q.is_empty() { - return vec![]; - } - let trash = active == TRASH_ID; - // The raw-bytes prefilter only applies to queries whose every byte is guaranteed to appear - // verbatim in the file's JSON encoding: printable ASCII minus the chars JSON escapes - // (quote/backslash/control). Non-ASCII stays OFF the prefilter — some producers (e.g. - // Python's json.dumps default) escape it as \uXXXX, which a byte scan would miss; those - // queries always take the parse+extract path (cached, so paid once per file version). - let raw_safe = q.bytes().all(|b| b.is_ascii() && b != b'"' && b != b'\\' && b >= 0x20); - // Reuse the list's pre-limit canonical-thread dedupe, directory/trash scope, and ordering. - // Otherwise duplicate physical rollouts could consume the 600-file search window even though - // the sidebar shows only their selected representative. - let files: Vec<(PathBuf, f64)> = list_sessions(config, active, 600) - .into_iter() - .filter_map(|session| { - let file = PathBuf::from(session.get("file")?.as_str()?); - let created = session - .get("createdAt") - .and_then(Value::as_f64) - .unwrap_or_else(|| created_ms(&file)); - Some((file, created)) - }) - .collect(); - // One batch helper call instead of a spawn per protected qoder file inside the worker loop - // (repeat scans of unchanged files are then served by the extraction + helper caches). - let qoder_files: Vec = files - .iter() - .map(|(file, _)| file.clone()) - .filter(|file| crate::qoder::looks_qoder_path(file)) - .collect(); - crate::qoder::prefetch(&qoder_files); - let hits = std::sync::Mutex::new(Vec::<(f64, Value)>::new()); - let next = std::sync::atomic::AtomicUsize::new(0); - let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8); - std::thread::scope(|s| { - for _ in 0..workers { - s.spawn(|| loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if i >= files.len() { - break; - } - let (file, ct) = &files[i]; - if is_session_deleted(file) != trash { - continue; - } - if let Some(hit) = scan_session(file, q, raw_safe) { - if let Ok(mut h) = hits.lock() { - h.push((*ct, hit)); - } - } - }); - } - }); - let mut hits = hits.into_inner().unwrap_or_else(|e| e.into_inner()); - hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); - hits.truncate(limit); - hits.into_iter().map(|(_, v)| v).collect() -} - -/// Write per-conversation customization (custom title + tags) onto the FIRST parseable line as a -/// `__ccbud__` field. Atomic (tmp + rename). Guarded to the configured dirs + the imports store -/// (renderer can't drive an arbitrary-path write, but imported sessions must be titleable/taggable -/// too — mirrors history.js setCcbud, whose getDirs() includes the imported dir). -pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { - let target = Path::new(file); - if !within_scope(target, config) { - return json!({ "ok": false, "reason": "out-of-scope" }); - } - // Foreign-CLI sessions are other tools' files (one is SQLite): their title/tags/delete - // flag always live in the app-owned sidecar. Same cache-drop contract as the codex branch. - if let Some(fk) = foreign_kind(target) { - let r = match fk { - Foreign::Grok => crate::grok::set_meta(file, patch), - Foreign::Copilot => crate::copilot::set_meta(file, patch), - Foreign::Antigravity => crate::antigravity::set_meta(file, patch), - }; - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - // Qoder sessions are Claude-format but another tool's live files: title/tags/delete go to - // the shared sidecar (keyed qoder:) instead of an in-file rewrite. The sidecar edit - // doesn't touch the file (no mtime bump), so the list-meta memo is dropped by hand. - if crate::qoder::looks_qoder_path(target) { - let r = crate::qoder::set_meta(file, patch); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - let raw = match fs::read_to_string(file) { - Ok(s) => s, - Err(_) => return json!({ "ok": false, "reason": "read" }), - }; - // Live Codex rollouts are another tool's files — their title/tags/delete flag live in the - // app-owned sidecar instead of being written into the rollout. Imported codex COPIES sit - // inside our store (marked by .import.json) and take the normal in-file path below. - let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); - if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { - let r = crate::codex::set_meta(file, patch); - // A sidecar edit changes the row without touching the rollout file (no mtime bump), so - // the list-meta memo must be dropped by hand. In-file writes below invalidate via mtime. - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - } - return r; - } - let mut lines: Vec = raw.split('\n').map(|s| s.to_string()).collect(); - let mut found: Option<(usize, Value)> = None; - for (i, l) in lines.iter().enumerate() { - let s = l.trim(); - if s.is_empty() { - continue; - } - if let Ok(v) = serde_json::from_str::(s) { - if v.is_object() { - found = Some((i, v)); - break; - } - } - } - let (idx, mut obj) = match found { - Some(x) => x, - None => return json!({ "ok": false, "reason": "empty" }), - }; - let mut next = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - if let Some(t) = patch.get("title") { - let t = t.as_str().unwrap_or("").trim().to_string(); - if !t.is_empty() { - next.insert("title".into(), json!(t)); - } else { - next.remove("title"); - } - } - if let Some(tags) = patch.get("tags") { - let mut arr: Vec = vec![]; - if let Some(ta) = tags.as_array() { - for x in ta { - if let Some(s) = x.as_str() { - let s = s.trim(); - if !s.is_empty() && !arr.iter().any(|y| y == s) { - arr.push(s.to_string()); - } - } - } - } - if !arr.is_empty() { - next.insert("tagList".into(), json!(arr)); - } else { - next.remove("tagList"); - } - } - // Soft delete / restore: `delete: true` marks the session deleted; `delete: false` (restore) - // drops the flag. Restore that empties __ccbud__ removes the field wholesale below. - if let Some(d) = patch.get("delete") { - if d.as_bool().unwrap_or(false) { - next.insert("delete".into(), json!(true)); - } else { - next.remove("delete"); - } - } - let o = obj.as_object_mut().unwrap(); - if !next.is_empty() { - o.insert("__ccbud__".into(), Value::Object(next)); - } else { - o.remove("__ccbud__"); - } - lines[idx] = serde_json::to_string(&obj).unwrap_or_default(); - let out = lines.join("\n"); - let tmp = format!("{}.ccbud.tmp", file); - if fs::write(&tmp, &out).is_err() { - return json!({ "ok": false, "reason": "write" }); - } - if fs::rename(&tmp, file).is_err() { - let _ = fs::remove_file(&tmp); - return json!({ "ok": false, "reason": "write" }); - } - // The rewrite bumps mtime/size, which already invalidates the list-meta memo — dropping the - // entry outright also covers a same-millisecond, same-length rewrite. - if let Ok(mut cache) = meta_cache().lock() { - cache.remove(target); - } - json!({ "ok": true }) -} - -/// Permanently remove a session's .jsonl from disk (recycle-bin "delete forever"). Guarded to the -/// configured dirs + the imports store exactly like set_ccbud, and also drops the session's -/// `/` subagents tree and any import sidecar (mirrors remove_import's cleanup). -/// Renderer-driven writes/deletes are confined to the configured work dirs' data trees -/// (projects/ AND sessions/) plus the imports store. -fn within_scope(target: &Path, config: &Value) -> bool { - all_dirs(config).iter().any(|(_, _, pd)| { - target.starts_with(pd) - || ["sessions", "session-state", "conversations"] - .iter() - .any(|n| sibling_dir(pd, n).map(|sd| target.starts_with(sd)).unwrap_or(false)) - }) -} - -pub fn delete_session_file(file: &str, config: &Value) -> Value { - let target = Path::new(file); - if !within_scope(target, config) { - return json!({ "ok": false, "reason": "out-of-scope" }); - } - if !target.is_file() { - return json!({ "ok": false, "reason": "missing" }); - } - // A LIVE Codex rollout, Qoder session, or foreign-CLI session is another tool's file — the - // app only ever soft-deletes those via the sidecar and never rewrites them (see set_ccbud), - // so "delete forever" must not rm the source either. Imported codex COPIES (marked by an - // .import.json) are our own snapshots and stay hard-deletable, like Claude sessions the app - // manages in the configured dirs. - if foreign_kind(target).is_some() || crate::qoder::looks_qoder_path(target) { - return json!({ "ok": false, "reason": "foreign" }); - } - let head = parse_lines(&read_head(target, 131072)); - if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { - return json!({ "ok": false, "reason": "foreign" }); - } - if fs::remove_file(target).is_err() { - return json!({ "ok": false, "reason": "remove" }); - } - crate::codex::remove_meta(file); // drop any codex sidecar entry (no-op for Claude sessions) - let dir = target.parent().unwrap_or(Path::new(".")); - let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - if !stem.is_empty() { - let _ = fs::remove_dir_all(dir.join(stem)); // /subagents/... - let _ = fs::remove_file(dir.join(format!("{}.import.json", stem))); - } - json!({ "ok": true }) -} - -/// Self-contained round-trip test of set_ccbud + get_session in a throwaway projects tree. -pub fn history_selftest(base_dir: &Path) -> Value { - let proj = base_dir.join("test-claude").join("projects").join("-test-cwd"); - let _ = fs::create_dir_all(&proj); - let file = proj.join("sess1.jsonl"); - let content = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello world from selfcheck\"},\"cwd\":\"/test/cwd\",\"sessionId\":\"sess1\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"; - let _ = fs::write(&file, content); - let config = json!({ "historyDirs": [ base_dir.join("test-claude").to_string_lossy() ] }); - let fpath = file.to_string_lossy().to_string(); - let set = set_ccbud(&fpath, &json!({ "title": "My Title", "tags": ["a", "b", "b"] }), &config); - let sess = get_session(&fpath); - let title = sess.get("meta").and_then(|m| m.get("title")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tags = sess.get("meta").and_then(|m| m.get("tags")).and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - let auto = sess.get("meta").and_then(|m| m.get("autoTitle")).and_then(|v| v.as_str()).unwrap_or("").to_string(); - // Soft-delete round-trip: marked → hidden from "all" but present in trash → restored → back in "all". - let _ = set_ccbud(&fpath, &json!({ "delete": true }), &config); - let after_del = get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); - let hidden_in_all = !list_sessions(&config, "all", 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); - let shown_in_trash = list_sessions(&config, TRASH_ID, 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); - let _ = set_ccbud(&fpath, &json!({ "delete": false }), &config); - let restored = !get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); - json!({ - "setOk": set.get("ok").and_then(|v| v.as_bool()).unwrap_or(false), - "title": title, - "tagCount": tags, - "autoTitle": auto, - "deletedAfterMark": after_del, - "hiddenInAll": hidden_in_all, - "shownInTrash": shown_in_trash, - "restored": restored, - }) -} - -#[cfg(test)] -mod foreign_probe { - use super::*; - - // Diagnostic harness (not an assertion): list + open REAL foreign-CLI sessions so the - // shapers can be eyeballed against live ~/.grok, ~/.copilot, ~/.gemini/antigravity-cli. - // Run: CCBUD_PROBE_FOREIGN="~/.grok,~/.copilot,~/.gemini/antigravity-cli" \ - // cargo test --lib probe_foreign_dirs -- --ignored --nocapture - #[test] - #[ignore] - fn probe_foreign_dirs() { - let Ok(dirs) = std::env::var("CCBUD_PROBE_FOREIGN") else { - eprintln!("set CCBUD_PROBE_FOREIGN=dir1,dir2,…"); - return; - }; - let list: Vec<&str> = dirs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); - let config = json!({ "historyDirs": list }); - let sessions = list_sessions(&config, "all", 500); - eprintln!("== {} sessions across {:?}", sessions.len(), list); - let mut by_source: std::collections::HashMap = std::collections::HashMap::new(); - for s in &sessions { - *by_source - .entry(s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string()) - .or_insert(0) += 1; - } - eprintln!("== by source: {:?}", by_source); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for s in &sessions { - let src = s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string(); - if !seen.insert(src.clone()) { - continue; - } - let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); - eprintln!( - "-- [{}] {} | cwd={} | title={:?}", - src, - file, - s.get("cwd").and_then(|v| v.as_str()).unwrap_or("-"), - s.get("title").and_then(|v| v.as_str()).unwrap_or("-") - ); - let detail = get_session(file); - let meta = detail.get("meta").cloned().unwrap_or(Value::Null); - let msgs = detail.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - eprintln!( - " detail: assistant={:?} messages={} totals={} firstTs={:?}", - meta.get("assistant").and_then(|v| v.as_str()), - msgs, - meta.get("totals").map(|t| t.to_string()).unwrap_or_default(), - meta.get("firstTs").and_then(|v| v.as_str()) - ); - if let Some(arr) = detail.get("messages").and_then(|v| v.as_array()) { - for m in arr.iter().take(4) { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?"); - let kinds: Vec = m - .get("content") - .and_then(|c| c.as_array()) - .map(|a| { - a.iter() - .map(|b| b.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()) - .collect() - }) - .unwrap_or_default(); - eprintln!(" msg {} {:?}", role, kinds); - } - } - } - } -} - -// ---- import (copy someone else's .jsonl into the app-managed store) ---- - -fn encode_cwd(cwd: Option<&str>) -> String { - match cwd { - Some(c) if !c.is_empty() => c.replace(['/', '\\'], "-"), - _ => "-imported".to_string(), - } -} - -fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { - fs::create_dir_all(dst)?; - for e in fs::read_dir(src)? { - let e = e?; - let s = e.path(); - let d = dst.join(e.file_name()); - if s.is_dir() { - copy_dir(&s, &d)?; - } else { - fs::copy(&s, &d)?; - } - } - Ok(()) -} - -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn records_to_jsonl(records: &[Value]) -> String { - let mut text = records - .iter() - .map(|r| serde_json::to_string(r).unwrap_or_default()) - .collect::>() - .join("\n"); - text.push('\n'); - text -} - -/// A qoder transcript imported into the app store loses its container path and would otherwise -/// re-parse as raw Claude records: queued commands vanish, redacted duplicates render, atomic -/// wrappers stay fragmented, and qoder's own title is lost. Sniffed by CONTENT (import copies and -/// bundle zips carry no .qoder path), the copy is rewritten up front — normalized records, with -/// the qoder title carried onto the first line's __ccbud__ so the import keeps its name. -fn qoder_import_raw(recs: &[Value]) -> Option<(String, Vec)> { - if !crate::qoder::looks_qoder_records(recs) { - return None; - } - let mut normalized = crate::qoder::normalize_records(recs); - if let Some(title) = crate::qoder::session_title_from(recs) { - if let Some(first) = normalized.iter_mut().find(|r| r.is_object()) { - let obj = first.as_object_mut().unwrap(); - let mut cc = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - cc.entry("title".to_string()).or_insert_with(|| json!(title)); - obj.insert("__ccbud__".into(), Value::Object(cc)); - } - } - Some((records_to_jsonl(&normalized), normalized)) -} - -/// Snapshot a transcript (already read into `raw`) plus its subagent sidecars into the import store, -/// laid out like a native projects/ tree + a provenance sidecar. `subagents`: (filename, bytes) to -/// drop under `/subagents/` — names are basename-reduced and pattern-checked so a crafted -/// entry can't escape the directory. Returns 1 = imported, 2 = skipped (already present), -/// 0 = failed/not-a-transcript. Shared by the plain-.jsonl and .zip-bundle import paths. -fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents: &[(String, Vec)]) -> i32 { - let recs = parse_lines(raw); - let is_codex = crate::codex::looks_codex(&recs); - // Qoder content is rewritten to Claude shape before storing — the has_msg gate below then - // sees the materialized queued-command user turns too. - let (qoder_text, recs) = match qoder_import_raw(&recs) { - Some((text, normalized)) => (Some(text), normalized), - None => (None, recs), - }; - let raw = qoder_text.as_deref().unwrap_or(raw); - let has_msg = recs.iter().any(|r| { - let t = r.get("type").and_then(|v| v.as_str()); - (t == Some("user") || t == Some("assistant")) && r.get("message").is_some() - }); - if !has_msg && !is_codex { - return 0; - } - let name_stem = || Path::new(original_name).file_stem().and_then(|s| s.to_str()).unwrap_or("import").to_string(); - // Codex rollouts keep cwd/session id inside the session_meta payload, not on the records. - let (cwd_owned, base_id) = if is_codex { - let (c, s) = crate::codex::head_ids(&recs); - (c, s.unwrap_or_else(name_stem)) - } else { - let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); - ( - meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()).map(|s| s.to_string()), - meta_rec - .and_then(|r| r.get("sessionId")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(name_stem), - ) - }; - let cwd = cwd_owned.as_deref(); - let dest_dir = imports_root().join("projects").join(encode_cwd(cwd)); - let dest_file = dest_dir.join(format!("{}.jsonl", base_id)); - if dest_file.exists() { - return 2; - } - if fs::create_dir_all(&dest_dir).is_err() || fs::write(&dest_file, raw).is_err() { - return 0; - } - if !subagents.is_empty() { - let sub_dir = dest_dir.join(&base_id).join("subagents"); - if fs::create_dir_all(&sub_dir).is_ok() { - for (name, bytes) in subagents { - // file_name() strips any directory component, so the write can't escape sub_dir. - let safe = Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(""); - let lower = safe.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - // A qoder session's subagent transcripts carry the same atomic wrappers — - // the parent's sniff decides, so the whole stored copy is Claude-shaped. - if qoder_text.is_some() && lower.ends_with(".jsonl") { - if let Ok(text) = std::str::from_utf8(bytes) { - let normalized = crate::qoder::normalize_records(&parse_lines(text)); - let _ = fs::write(sub_dir.join(safe), records_to_jsonl(&normalized)); - continue; - } - } - let _ = fs::write(sub_dir.join(safe), bytes); - } - } - } - } - let sidecar = dest_dir.join(format!("{}.import.json", base_id)); - let _ = fs::write( - &sidecar, - serde_json::to_vec_pretty(&json!({ - "originalPath": original_path, - "originalName": original_name, - "sessionId": base_id, - "importedAt": now_ms(), - })) - .unwrap_or_default(), - ); - 1 -} - -/// Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. -/// Foreign-CLI sources (Grok / Copilot / Antigravity) are intentionally not importable — -/// their layouts/formats aren't Claude/Codex, and a Grok chat_history head would otherwise -/// trip looks_codex (its `reasoning` lines look like old envelope-less Codex items). -fn import_one(src: &str) -> i32 { - let src_path = Path::new(src); - if foreign_kind(src_path).is_some() { - return 0; - } - let raw = match read_session_text(src_path) { - Ok(s) => s, - Err(_) => return 0, - }; - // Path-less copies of foreign transcripts: refuse anything whose head sniffs as Grok - // chat_history (type:system + later reasoning/tool_result) or Copilot events - // (type:session.start with producer copilot-agent). - let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); - if looks_foreign_jsonl(&head) { - return 0; - } - let subs = read_subagent_files(src_path); - let original_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - write_imported(&raw, src, original_name, &subs) -} - -/// Content sniff for foreign CLI jsonl (used by import when the path no longer carries the -/// original container shape — e.g. a bare chat_history.jsonl dropped into the import dialog). -fn looks_foreign_jsonl(recs: &[Value]) -> bool { - recs.iter().take(8).any(|r| match r.get("type").and_then(|v| v.as_str()) { - // Copilot event stream - Some("session.start") | Some("user.message") | Some("assistant.message") - | Some("tool.execution_complete") | Some("tool.execution_start") => true, - // Grok chat_history: top-level system/reasoning/tool_result (Claude wraps these) - Some("reasoning") | Some("tool_result") if r.get("message").is_none() => true, - Some("system") if r.get("content").is_some() && r.get("message").is_none() => true, - _ => false, - }) -} - -/// Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout so -/// the pipeline nests them exactly as if they'd been captured live. Round-trips export_bundle. -fn import_zip(src: &str) -> i32 { - let bytes = match fs::read(src) { - Ok(b) => b, - Err(_) => return 0, - }; - let (main, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&bytes)); - let main_data = match main { - Some((_, data)) => data, - None => return 0, - }; - let raw = match String::from_utf8(main_data) { - Ok(s) => s, - Err(_) => return 0, - }; - let original_name = Path::new(src).file_name().and_then(|n| n.to_str()).unwrap_or(""); - write_imported(&raw, src, original_name, &subs) -} - -pub fn import_paths(paths: &[String]) -> Value { - let (mut imported, mut skipped, mut failed) = (0, 0, 0); - for src in paths { - let lower = src.to_lowercase(); - let r = if lower.ends_with(".zip") { - import_zip(src) - } else if lower.ends_with(".jsonl") { - import_one(src) - } else { - 0 - }; - match r { - 1 => imported += 1, - 2 => skipped += 1, - _ => failed += 1, - } - } - json!({ "imported": imported, "skipped": skipped, "failed": failed }) -} - -pub fn remove_import(file: &str) -> Value { - let root = imports_root(); - let f = Path::new(file); - // Hard safety: only ever delete inside our own import store. - if !f.starts_with(&root) { - return json!({ "ok": false, "error": "outside import store" }); - } - let dir = f.parent().unwrap_or(Path::new(".")); - let base = f.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let _ = fs::remove_file(f); - let _ = fs::remove_file(dir.join(format!("{}.import.json", base))); - let _ = fs::remove_dir_all(dir.join(base)); // subagents/ - json!({ "ok": true }) -} - -/// Self-contained test of import → list-as-imported → re-import-skip → remove. -pub fn import_selftest(base_dir: &Path) -> Value { - std::env::set_var("CCBUD_HOME", base_dir); // imports_root() honors CCBUD_HOME - let src_dir = base_dir.join("import-src"); - let _ = fs::create_dir_all(&src_dir); - let src = src_dir.join("foreign.jsonl"); - let _ = fs::write(&src, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"imported hello\"},\"cwd\":\"/imp/cwd\",\"sessionId\":\"impsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); - let srcs = vec![src.to_string_lossy().to_string()]; - let r = import_paths(&srcs); - let r2 = import_paths(&srcs); - let config = json!({ "historyDirs": ["~/.claude"] }); - let sessions = list_sessions(&config, "__imported__", 50); - let found = sessions.iter().any(|s| { - s.get("imported").and_then(|v| v.as_bool()).unwrap_or(false) - && s.get("title").and_then(|v| v.as_str()) == Some("imported hello") - }); - let dest = imports_root().join("projects").join("-imp-cwd").join("impsess.jsonl"); - let rm = remove_import(&dest.to_string_lossy()); - - // ---- bundle round-trip: a session WITH subagents exports as a .zip and re-imports with its - // subagent transcripts restored (the export → import path the 对话 view drives). ---- - let bproj = base_dir.join("bundle-src").join("projects").join("-bnd-cwd"); - let _ = fs::create_dir_all(&bproj); - let bmain = bproj.join("bundsess.jsonl"); - let _ = fs::write(&bmain, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); - let bsub = bproj.join("bundsess").join("subagents"); - let _ = fs::create_dir_all(&bsub); - let _ = fs::write(bsub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n"); - let _ = fs::write(bsub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}"); - let zip = export_bundle(&bmain.to_string_lossy()).unwrap_or_default(); - let zip_is_zip = zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]); - let zip_path = base_dir.join("bundle-src").join("bundsess.zip"); - let _ = fs::write(&zip_path, &zip); - let rb = import_paths(&[zip_path.to_string_lossy().to_string()]); - let imp_dir = imports_root().join("projects").join("-bnd-cwd"); - let sub_restored = imp_dir.join("bundsess").join("subagents").join("agent-b1.jsonl").exists() - && imp_dir.join("bundsess").join("subagents").join("agent-b1.meta.json").exists(); - let bundle_sess = get_session(&imp_dir.join("bundsess.jsonl").to_string_lossy()); - let bundle_sub_count = bundle_sess.get("meta").and_then(|m| m.get("subagentCount")).and_then(|v| v.as_i64()).unwrap_or(0); - - json!({ - "imported": r.get("imported"), - "reskipped": r2.get("skipped"), - "appearsImported": found, - "removed": rm.get("ok"), - "gone": !dest.exists(), - "bundleZip": zip_is_zip, - "bundleImported": rb.get("imported"), - "bundleSubRestored": sub_restored, - "bundleSubagentCount": bundle_sub_count, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - // One work dir carrying ALL foreign layouts (grok sessions/%2F…, copilot session-state/, - // antigravity conversations/*.db): each session must list under its own source with cwd, - // title and detail routed through its shaper, hard-delete must refuse, and content search - // must reach every format. - #[test] - fn foreign_sources_route_end_to_end() { - let base = std::env::temp_dir().join("ccbud-foreign-route-test"); - let _ = fs::remove_dir_all(&base); - - // grok: sessions///chat_history.jsonl + summary.json - let gdir = base.join("sessions").join("%2Ftmp%2Fgproj").join("0199-grok-uuid"); - fs::create_dir_all(&gdir).unwrap(); - fs::write( - gdir.join("chat_history.jsonl"), - "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"grok needle walrus\"}]}\n\ - {\"type\":\"assistant\",\"content\":\"done\",\"tool_calls\":[{\"id\":\"c1\",\"name\":\"run_terminal_command\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}]}\n", - ) - .unwrap(); - fs::write( - gdir.join("summary.json"), - "{\"info\":{\"id\":\"0199-grok-uuid\",\"cwd\":\"/tmp/gproj\"},\"generated_title\":\"Grok 会话\",\"created_at\":\"2026-06-18T06:27:07.777Z\",\"current_model_id\":\"grok-build\"}", - ) - .unwrap(); - // …and a stray sidecar jsonl the codex walker must NOT sweep into a session row - fs::write(gdir.join("events.jsonl"), "{\"ts\":\"x\",\"type\":\"mcp_config_resolved\"}\n").unwrap(); - - // copilot: session-state//events.jsonl + workspace.yaml - let cdir = base.join("session-state").join("cp-uuid-1"); - fs::create_dir_all(&cdir).unwrap(); - fs::write( - cdir.join("events.jsonl"), - "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"cp-uuid-1\",\"context\":{\"cwd\":\"/tmp/cproj\"}},\"timestamp\":\"2026-07-12T07:26:54.363Z\"}\n\ - {\"type\":\"user.message\",\"data\":{\"content\":\"copilot needle pelican\"},\"timestamp\":\"2026-07-12T07:27:14.463Z\"}\n", - ) - .unwrap(); - fs::write( - cdir.join("workspace.yaml"), - "id: cp-uuid-1\ncwd: /tmp/cproj\nname: Copilot 会话\ncreated_at: 2026-07-12T07:26:54.368Z\n", - ) - .unwrap(); - - // antigravity: conversations/.db with one user step (hand-encoded wire format) - let adir = base.join("conversations"); - fs::create_dir_all(&adir).unwrap(); - let adb = adir.join("agy-uuid-1.db"); - { - fn enc_varint(mut v: u64, out: &mut Vec) { - loop { - let b = (v & 0x7f) as u8; - v >>= 7; - if v == 0 { - out.push(b); - break; - } - out.push(b | 0x80); - } - } - fn put_varint(field: u32, v: u64, out: &mut Vec) { - enc_varint(((field as u64) << 3) | 0, out); - enc_varint(v, out); - } - fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { - enc_varint(((field as u64) << 3) | 2, out); - enc_varint(data.len() as u64, out); - out.extend_from_slice(data); - } - let mut ts = vec![]; - put_varint(1, 1_783_811_237, &mut ts); - let mut meta5 = vec![]; - put_bytes(1, &ts, &mut meta5); - let mut u19 = vec![]; - put_bytes(2, "agy needle capybara".as_bytes(), &mut u19); - let mut step = vec![]; - put_varint(1, 14, &mut step); - put_varint(4, 3, &mut step); - put_bytes(5, &meta5, &mut step); - put_bytes(19, &u19, &mut step); - let conn = rusqlite::Connection::open(&adb).unwrap(); - conn.execute_batch( - "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, step_payload BLOB);", - ) - .unwrap(); - conn.execute("INSERT INTO steps (idx, step_type, status, step_payload) VALUES (0, 14, 3, ?1)", [&step]) - .unwrap(); - } - { - let conn = rusqlite::Connection::open(base.join("conversation_summaries.db")).unwrap(); - conn.execute_batch( - "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '', step_count INTEGER NOT NULL DEFAULT 0, last_modified_time DATETIME, workspace_uris TEXT NOT NULL DEFAULT '[]');", - ) - .unwrap(); - conn.execute( - "INSERT INTO conversation_summaries (conversation_id, title, preview, step_count, workspace_uris) VALUES ('agy-uuid-1', 'Agy 会话', 'p', 1, '[\"file:///tmp/aproj\"]')", - [], - ) - .unwrap(); - } - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let rows = list_sessions(&config, "all", 50); - let by = |src: &str| { - rows.iter() - .find(|r| r.get("source").and_then(|v| v.as_str()) == Some(src)) - .unwrap_or_else(|| panic!("no {} row in {:?}", src, rows)) - .clone() - }; - // exactly one row per source — the grok dir's stray events.jsonl must not add a fourth - assert_eq!(rows.len(), 3, "rows: {:?}", rows); - let (g, c, a) = (by("grok"), by("copilot"), by("antigravity")); - assert_eq!(g["cwd"], "/tmp/gproj"); - assert_eq!(g["title"], "Grok 会话"); - assert_eq!(g["model"], "grok-build"); - assert_eq!(c["cwd"], "/tmp/cproj"); - assert_eq!(c["title"], "Copilot 会话"); - assert_eq!(a["cwd"], "/tmp/aproj"); - assert_eq!(a["title"], "Agy 会话"); - - // detail routes through each shaper (assistant name is the renderer's header/stat hook) - for (row, assistant, first_text) in [ - (&g, "Grok", "grok needle walrus"), - (&c, "Copilot", "copilot needle pelican"), - (&a, "Antigravity", "agy needle capybara"), - ] { - let file = row["file"].as_str().unwrap(); - let d = get_session(file); - assert_eq!(d["meta"]["assistant"], assistant); - assert_eq!(d["messages"][0]["content"][0]["text"], first_text); - // another tool's live file: delete-forever must refuse and leave it on disk - let del = delete_session_file(file, &config); - assert_eq!(del["reason"], "foreign"); - assert!(Path::new(file).is_file()); - } - - // content search reaches every format (agy has no raw-text prefilter path) - for needle in ["walrus", "pelican", "capybara"] { - let hits = search_sessions(&config, "all", needle, 10); - assert_eq!(hits.len(), 1, "search {}: {:?}", needle, hits); - } - - let _ = fs::remove_dir_all(&base); - } - - // Qoder writes Claude-like atomic event wrappers plus inline metadata into its own tree. - // Rows and detail must use that metadata, merge one assistant response's content blocks, - // retain queued commands as user turns, nest subagents, and remain searchable/exportable. - #[test] - fn qoder_sessions_route_end_to_end() { - let base = std::env::temp_dir().join("ccbud-qoder-route-test"); - let _ = fs::remove_dir_all(&base); - let root = base.join(".qoder"); - let proj = root.join("projects").join("-tmp-qproj"); - fs::create_dir_all(&proj).unwrap(); - let uuid = "11111111-1111-4111-8111-111111111111"; - let sess = proj.join(format!("{}.jsonl", uuid)); - let records = vec![ - json!({ "type": "agent-setting", "agentSetting": "triage", "entrypoint": "sdk-cli", "sessionId": uuid }), - json!({ "type": "last-prompt", "sessionId": uuid, "lastPrompt": "last prompt fallback" }), - json!({ "type": "ai-title", "sessionId": uuid, "aiTitle": "Qoder 会话" }), - json!({ "type": "workspace-directories", "sessionId": uuid, "directories": ["/tmp/qproj"] }), - json!({ "type": "runtime-config", "sessionId": uuid, "model": "ultimate", "reasoningEffort": "high" }), - json!({ - "type": "user", "uuid": "u1", "timestamp": "2026-06-04T09:47:27.966Z", - "message": { "role": "user", "content": "qoder needle axolotl" }, - "sessionId": uuid, "version": "1.1.13" - }), - json!({ - "type": "assistant", "uuid": "a1", "parentUuid": "u1", "timestamp": "2026-06-04T09:47:32.116Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "model": "wire-model", "content": [ - { "type": "redacted_thinking", "data": "must not render" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a2", "parentUuid": "a1", "timestamp": "2026-06-04T09:47:32.216Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ - { "type": "thinking", "thinking": "considering" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a3", "parentUuid": "a2", "timestamp": "2026-06-04T09:47:32.316Z", - "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ - { "type": "text", "text": "done" } - ]}, "sessionId": uuid - }), - json!({ - "type": "assistant", "uuid": "a4", "parentUuid": "a3", "timestamp": "2026-06-04T09:47:32.416Z", - "message": { - "id": "msg_1", "type": "message", "role": "assistant", "stop_reason": "end_turn", - "usage": { "input_tokens": 100, "cache_creation_input_tokens": 7, "cache_read_input_tokens": 50, "output_tokens": 30 }, - "content": [{ "type": "tool_use", "id": "tu1", "name": "Task", "input": {} }] - }, "sessionId": uuid - }), - json!({ - "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued narwhal follow-up", "commandMode": false }, - "uuid": "u2", "parentUuid": "a4", "timestamp": "2026-06-04T09:47:35.000Z", "sessionId": uuid - }), - ]; - let raw = records - .iter() - .map(|record| serde_json::to_string(record).unwrap()) - .collect::>() - .join("\n") - + "\n"; - fs::write(&sess, raw).unwrap(); - let sub = proj.join(uuid).join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write( - sub.join("agent-q1.jsonl"), - format!("{{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"q1\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"sub quetzal done\"}}]}},\"sessionId\":\"{}\",\"timestamp\":\"2026-06-04T09:47:40.000Z\"}}\n", uuid), - ) - .unwrap(); - fs::write( - sub.join("agent-q1.meta.json"), - "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu1\"}", - ) - .unwrap(); - - let config = json!({ "historyDirs": [ root.to_string_lossy() ] }); - let rows = list_sessions(&config, "all", 50); - assert_eq!(rows.len(), 1, "rows: {:?}", rows); - let r = &rows[0]; - assert_eq!(r["source"], "qoder"); - assert_eq!(r["id"], format!("qoder:{}", uuid)); - assert_eq!(r["title"], "Qoder 会话"); - assert_eq!(r["autoTitle"], "Qoder 会话"); - assert_eq!(r["cwd"], "/tmp/qproj"); - assert_eq!(r["model"], "ultimate"); - assert_eq!(r["deleted"], false); - - let file = r["file"].as_str().unwrap(); - let d = get_session(file); - assert_eq!(d["meta"]["assistant"], "Qoder"); - assert_eq!(d["meta"]["source"], "qoder"); - assert_eq!(d["meta"]["id"], format!("qoder:{}", uuid)); - assert_eq!(d["meta"]["title"], "Qoder 会话"); - assert_eq!(d["meta"]["model"], "ultimate"); - assert_eq!(d["meta"]["subagentCount"], 1); - assert_eq!(d["messages"].as_array().unwrap().len(), 3); - assert_eq!(d["messages"][0]["content"], "qoder needle axolotl"); // string-content user turn - let assistant_blocks = d["messages"][1]["content"].as_array().unwrap(); - assert_eq!( - assistant_blocks - .iter() - .filter_map(|block| block.get("type").and_then(Value::as_str)) - .collect::>(), - vec!["thinking", "text", "tool_use"] - ); - assert_eq!(d["messages"][1]["usage"]["inputTokens"], 100); - assert_eq!(d["messages"][1]["stopReason"], "end_turn"); - assert_eq!(d["messages"][2]["role"], "user"); - assert_eq!(d["messages"][2]["content"], "queued narwhal follow-up"); - assert_eq!(d["subagents"]["tu1"]["messages"][0]["content"][0]["text"], "sub quetzal done"); - - // another tool's live file: delete-forever must refuse and leave it on disk - let del = delete_session_file(file, &config); - assert_eq!(del["reason"], "foreign"); - assert!(Path::new(file).is_file()); - - // content search reaches the main thread and the subagent transcript - let hits = search_sessions(&config, "all", "axolotl", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "main"); - let hits = search_sessions(&config, "all", "narwhal", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "main"); - let hits = search_sessions(&config, "all", "quetzal", 10); - assert_eq!(hits.len(), 1, "{:?}", hits); - assert_eq!(hits[0]["agent"], "tu1"); - - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn session_read_errors_are_structured() { - let base = std::env::temp_dir().join(format!( - "ccbud-session-read-error-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&base); - let project = base.join(".qoder").join("projects").join("-tmp-error"); - fs::create_dir_all(&project).unwrap(); - - let missing = project.join("missing.jsonl"); - let detail = get_session(&missing.to_string_lossy()); - assert_eq!(detail["error"]["kind"], "notFound"); - - let invalid = project.join("invalid.jsonl"); - fs::write(&invalid, [0xff, 0xfe]).unwrap(); - let detail = get_session(&invalid.to_string_lossy()); - assert_eq!(detail["error"]["kind"], "readFailed"); - - let denied = session_read_error( - &invalid, - &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), - ); - assert_eq!(denied["error"]["kind"], "permissionDenied"); - - let _ = fs::remove_dir_all(&base); - } - - // The Rust search extractor and the renderer's stripInjected must agree: a task-notification - // envelope surfaces only its body — transport metadata must never be searchable. - #[test] - fn strip_injected_unwraps_task_notifications() { - let s = strip_injected( - "before \ncompleted\ntransport-noise\n\nDone **ok**\n\n after", - ); - assert!(s.contains("before") && s.contains("after")); - assert!(s.contains("Done **ok**")); - assert!(!s.contains("transport-noise")); - assert!(!s.contains("task-notification")); - // an envelope without a vanishes wholesale, like a system-reminder - let gone = strip_injected("x running y"); - assert!(gone.contains('x') && gone.contains('y') && !gone.contains("running")); - // the pre-existing rules still apply after the unwrap - assert_eq!(strip_injected("himeta"), "hi"); - // a standalone Codex injection vanishes; quoting one alongside prose does not - assert_eq!(strip_injected(" skill-body\n"), ""); - assert!(strip_injected("see quoted here").contains("quoted")); - } - - // The Codex AGENTS bootstrap must index as the SAME compact Markdown the panel renders - // (formatCodexBootstrap parity) — not as the raw XML-ish transport shape. - #[test] - fn strip_injected_formats_codex_bootstrap_like_the_panel() { - let raw = "# AGENTS.md instructions for /work/proj\n\n\nAlways run tests.\n\n\n\n /work/proj\n zsh\n /work/proj\n \n"; - let s = strip_injected(raw); - assert!(s.starts_with("# AGENTS.md instructions for /work/proj"), "{s}"); - assert!(s.contains("**INSTRUCTIONS:** Always run tests."), "{s}"); - assert!(s.contains("**environment_context:** `/work/proj`"), "{s}"); - assert!(s.contains("**shell:** zsh"), "{s}"); - assert!(s.contains("**workspace_roots:** `/work/proj`"), "{s}"); - assert!(s.contains("**permission_profile:** workspace-write"), "{s}"); - assert!(!s.contains("\na\nb\n"); - assert!(multi.contains("**INSTRUCTIONS:**\n\na\nb"), "{multi}"); - // ordinary prose is untouched - assert_eq!(strip_injected("ordinary prose"), "ordinary prose"); - } - - // Imported qoder content is rewritten to Claude shape (wrappers merged, queued commands - // materialized) with qoder's own title carried onto __ccbud__; Claude content passes through. - #[test] - fn qoder_imports_are_normalized_with_title() { - let recs = vec![ - json!({ "type": "ai-title", "aiTitle": "Qoder 导入标题" }), - json!({ "type": "assistant", "uuid": "w1", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "thinking", "thinking": "t" }] } }), - json!({ "type": "assistant", "uuid": "w2", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "text", "text": "done" }] } }), - json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued prompt" } }), - ]; - let (text, normalized) = qoder_import_raw(&recs).expect("sniffs as qoder"); - let assistants: Vec<&Value> = normalized.iter().filter(|r| r["type"] == "assistant").collect(); - assert_eq!(assistants.len(), 1, "wrappers merged: {:?}", normalized); - assert_eq!(assistants[0]["message"]["content"].as_array().unwrap().len(), 2); - assert!(normalized - .iter() - .any(|r| r["type"] == "user" && r["message"]["content"] == "queued prompt")); - let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); - assert_eq!(first["__ccbud__"]["title"], "Qoder 导入标题"); - assert!(qoder_import_raw(&[json!({ "type": "user", "message": { "content": "hi" } })]).is_none()); - } - - // A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted - // by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on - // disk. A Claude session in the same dir's projects/ tree is still deletable. - #[test] - fn delete_forever_refuses_live_codex_rollout() { - let base = std::env::temp_dir().join("ccbud-codex-del-test"); - let _ = fs::remove_dir_all(&base); - // codex rollout under /sessions/… - let sdir = base.join("sessions").join("2026").join("07").join("04"); - fs::create_dir_all(&sdir).unwrap(); - let codex_file = sdir.join("rollout-x.jsonl"); - fs::write( - &codex_file, - "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"x\",\"cwd\":\"/x\"}}\n\ - {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"hi\"}]}}\n", - ) - .unwrap(); - // claude session under /projects/… - let pdir = base.join("projects").join("-x"); - fs::create_dir_all(&pdir).unwrap(); - let claude_file = pdir.join("s1.jsonl"); - fs::write(&claude_file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"cwd\":\"/x\",\"sessionId\":\"s1\"}\n").unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - - // live codex → refused, file survives - let r = delete_session_file(&codex_file.to_string_lossy(), &config); - assert_eq!(r.get("reason").and_then(|v| v.as_str()), Some("foreign"), "live codex must be refused"); - assert!(codex_file.is_file(), "codex rollout must NOT be deleted"); - - // claude session → deleted - let r2 = delete_session_file(&claude_file.to_string_lossy(), &config); - assert_eq!(r2.get("ok").and_then(|v| v.as_bool()), Some(true)); - assert!(!claude_file.is_file(), "claude session should be gone"); - - let _ = fs::remove_dir_all(&base); - } - - // Export a session-with-subagents and prove the .zip splits back into the main session + both - // subagent sidecars (the shape import_zip then writes into the store). Avoids mutating CCBUD_HOME - // so it can't race other threads under `cargo test`; the store round-trip is covered by the - // in-app import_selftest and confirms in review via write_imported (shared with import_one). - #[test] - fn export_bundle_round_trips_through_split() { - let base = std::env::temp_dir().join("ccbud-bundle-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-bnd-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("bundsess.jsonl"); - fs::write(&main, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); - let sub = proj.join("bundsess").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write(sub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n").unwrap(); - fs::write(sub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}").unwrap(); - - assert!(session_has_subagents(&main.to_string_lossy())); - - let zip = export_bundle(&main.to_string_lossy()).unwrap(); - assert!(zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]), "starts with PK local header"); - - let (m, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&zip)); - assert_eq!(m.as_ref().map(|(n, _)| n.as_str()), Some("bundsess.jsonl")); - assert_eq!(subs.len(), 2); - assert!(subs.iter().any(|(n, d)| n == "agent-b1.jsonl" && String::from_utf8_lossy(d).contains("sub done"))); - assert!(subs.iter().any(|(n, _)| n == "agent-b1.meta.json")); - - let _ = fs::remove_dir_all(&base); - } - - // The list is ordered by the session's FIRST RECORD TIMESTAMP, not fs times — a title/tag - // edit rewrites the file via tmp+rename (which resets its fs birth time to "now") and must - // NOT reshuffle the list. - #[test] - fn list_order_survives_title_and_tag_edits() { - let base = std::env::temp_dir().join("ccbud-order-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-ord-cwd"); - fs::create_dir_all(&proj).unwrap(); - let older = proj.join("older.jsonl"); - let newer = proj.join("newer.jsonl"); - fs::write(&older, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"old one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"older\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); - fs::write(&newer, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"new one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"newer\",\"timestamp\":\"2025-06-01T10:00:00.000Z\"}\n").unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let order = |cfg: &Value| -> Vec { - list_sessions(cfg, "all", 50) - .iter() - .filter(|s| s.get("cwd").and_then(|v| v.as_str()) == Some("/ord/cwd")) - .map(|s| s.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string()) - .collect() - }; - assert_eq!(order(&config), vec!["newer", "older"], "newest record time first"); - - // Rename + tag the OLDER session: the file is rewritten through a fresh tmp inode, yet - // the list order must not change. - let r = set_ccbud(&older.to_string_lossy(), &json!({ "title": "Renamed", "tags": ["pinned"] }), &config); - assert_eq!(r.get("ok").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(order(&config), vec!["newer", "older"], "tag/title edit must not reshuffle"); - - // And the row's createdAt still reflects the record timestamp, not the rewrite moment, - // while the edited title shows up immediately (list-meta memo invalidated by the write). - let rows = list_sessions(&config, "all", 50); - let row = rows.iter().find(|s| s.get("sessionId").and_then(|v| v.as_str()) == Some("older")).unwrap(); - let want = chrono::DateTime::parse_from_rfc3339("2025-01-01T10:00:00.000Z").unwrap().timestamp_millis() as f64; - assert_eq!(row.get("createdAt").and_then(|v| v.as_f64()), Some(want)); - assert_eq!(row.get("title").and_then(|v| v.as_str()), Some("Renamed")); - - let _ = fs::remove_dir_all(&base); - } - - // Content search: a main-thread hit reports agent "main"; a subagent-only hit reports the - // spawning tool_use key (+ agent type); injected text never matches; and - // ASCII case folds. Runs twice so the second pass exercises the extraction cache. - #[test] - fn search_sessions_finds_main_and_subagent_content() { - let base = std::env::temp_dir().join("ccbud-search-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-srch-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("srchsess.jsonl"); - fs::write( - &main, - "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"find the zebra crossingreminder-secret\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n\ - {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"Task\",\"input\":{}}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n", - ) - .unwrap(); - let sub = proj.join("srchsess").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write( - sub.join("agent-s1.jsonl"), - "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"s1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"the quokka was found here\"}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:02.000Z\"}\n", - ) - .unwrap(); - fs::write(sub.join("agent-s1.meta.json"), "{\"agentType\":\"explore\",\"description\":\"d\",\"toolUseId\":\"tu1\"}").unwrap(); - // Content stored as \uXXXX escapes (e.g. python json.dumps output) — a byte scan can't - // see the decoded text, so non-ASCII queries must bypass the raw prefilter. - let esc = proj.join("escsess.jsonl"); - fs::write( - &esc, - "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"\\u4e2d\\u6587\\u5185\\u5bb9 escaped\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"escsess\",\"timestamp\":\"2025-01-02T10:00:00.000Z\"}\n", - ) - .unwrap(); - // A codex rollout in the same work dir's sessions/ tree — its own record format, scanned - // through the codex shaper. - let cdir = base.join("sessions").join("2026").join("07").join("04"); - fs::create_dir_all(&cdir).unwrap(); - fs::write( - cdir.join("rollout-c.jsonl"), - "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"c1\",\"cwd\":\"/cx\"}}\n\ - {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"codex kangaroo request\"}]}}\n", - ) - .unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - - for pass in 0..2 { - // main-thread hit - let hits = search_sessions(&config, "all", "zebra crossing", 50); - assert_eq!(hits.len(), 1, "pass {}: one session matches", pass); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); - assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("zebra")); - - // subagent-only hit → keyed by the spawning tool_use id, labeled with the agent type - let hits = search_sessions(&config, "all", "QUOKKA", 50); // also proves case folding - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("tu1")); - assert_eq!(hits[0].get("agentType").and_then(|v| v.as_str()), Some("explore")); - - // codex rollout content is searchable too - let hits = search_sessions(&config, "all", "kangaroo", 50); - assert_eq!(hits.len(), 1, "pass {}: codex rollout matches", pass); - assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); - - // \uXXXX-escaped content still matches a non-ASCII query (no raw prefilter for those) - let hits = search_sessions(&config, "all", "中文", 50); - assert_eq!(hits.len(), 1, "pass {}: escaped unicode content matches", pass); - assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("中文内容")); - - // injected system-reminder content is NOT searchable (matches the renderer) - assert!(search_sessions(&config, "all", "reminder-secret", 50).is_empty()); - // no match at all - assert!(search_sessions(&config, "all", "wombat", 50).is_empty()); - } - - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn subagent_transcript_paths_lists_only_agent_jsonl() { - let base = std::env::temp_dir().join("ccbud-subpaths-test"); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-m-cwd"); - fs::create_dir_all(&proj).unwrap(); - let main = proj.join("m.jsonl"); - fs::write(&main, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"sessionId\":\"m\"}\n").unwrap(); - // no subagents → empty (caller attaches only the main file) - assert!(subagent_transcript_paths(&main.to_string_lossy()).is_empty()); - - let sub = proj.join("m").join("subagents"); - fs::create_dir_all(&sub).unwrap(); - fs::write(sub.join("agent-a.jsonl"), "{}\n").unwrap(); - fs::write(sub.join("agent-b.jsonl"), "{}\n").unwrap(); - fs::write(sub.join("agent-a.meta.json"), "{}").unwrap(); // sidecar must be excluded - - let paths = subagent_transcript_paths(&main.to_string_lossy()); - assert_eq!(paths.len(), 2, "only the two agent-*.jsonl, not the .meta.json"); - assert!(paths.iter().all(|p| p.ends_with(".jsonl"))); - assert!(paths.iter().any(|p| p.ends_with("agent-a.jsonl"))); - assert!(paths.iter().any(|p| p.ends_with("agent-b.jsonl"))); - - let _ = fs::remove_dir_all(&base); - } -} diff --git a/src-tauri/src/history/codexdedupe.rs b/src-tauri/src/history/codexdedupe.rs new file mode 100644 index 0000000..484419b --- /dev/null +++ b/src-tauri/src/history/codexdedupe.rs @@ -0,0 +1,146 @@ +use serde_json::Value; +use std::path::Path; + +fn canonical_codex_key(session: &Value) -> Option { + if session.get("source").and_then(Value::as_str) != Some("codex") { + return None; + } + if session.get("canonicalThreadIdValid").and_then(Value::as_bool) != Some(true) { + return None; + } + let thread_id = session.get("threadId").and_then(Value::as_str)?; + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + Some(format!("{dir_id}\0{thread_id}")) +} + +fn codex_canonical_filename(session: &Value) -> bool { + let Some(thread_id) = session.get("threadId").and_then(Value::as_str) else { + return false; + }; + let Some(file) = session.get("file").and_then(Value::as_str) else { + return false; + }; + let stem = Path::new(file).file_stem().and_then(|value| value.to_str()).unwrap_or(""); + stem == thread_id || stem.strip_suffix(thread_id).is_some_and(|prefix| prefix.ends_with('-')) +} + +fn codex_candidate_preferred(candidate: &Value, current: &Value) -> bool { + let candidate_file = candidate.get("file").and_then(Value::as_str).unwrap_or(""); + let current_file = current.get("file").and_then(Value::as_str).unwrap_or(""); + let thread_id = candidate + .get("threadId") + .and_then(Value::as_str) + .or_else(|| current.get("threadId").and_then(Value::as_str)) + .unwrap_or(""); + + // Codex's completed state DB is authoritative when its rollout_path still exists. Both + // candidates already passed ccbud's first-SessionMeta parse, so a matching path also verifies + // that the DB row belongs to this canonical id. + let preferred_path = [candidate_file, current_file] + .into_iter() + .filter(|file| !file.is_empty()) + .find_map(|file| crate::codex::preferred_rollout_path(Path::new(file), thread_id)); + if let Some(preferred) = preferred_path { + let candidate_matches = Path::new(candidate_file) == preferred.as_path(); + let current_matches = Path::new(current_file) == preferred.as_path(); + if candidate_matches != current_matches { + return candidate_matches; + } + } + + let imported = |value: &Value| value.get("imported").and_then(Value::as_bool).unwrap_or(false); + if imported(candidate) != imported(current) { + return !imported(candidate); + } + let archived = |file: &str| { + Path::new(file) + .components() + .any(|part| part.as_os_str().to_str() == Some("archived_sessions")) + }; + if archived(candidate_file) != archived(current_file) { + return !archived(candidate_file); + } + let number = |value: &Value, field: &str| value.get(field).and_then(Value::as_f64).unwrap_or(0.0); + for field in ["lastActivity", "createdAt"] { + let candidate_value = number(candidate, field); + let current_value = number(current, field); + if candidate_value != current_value { + return candidate_value > current_value; + } + } + if codex_canonical_filename(candidate) != codex_canonical_filename(current) { + return codex_canonical_filename(candidate); + } + let candidate_size = number(candidate, "sizeKB"); + let current_size = number(current, "sizeKB"); + if candidate_size != current_size { + return candidate_size > current_size; + } + candidate_file > current_file +} + +pub(super) fn dedupe_canonical_codex_sessions(sessions: Vec) -> Vec { + let mut out = Vec::with_capacity(sessions.len()); + let mut positions = std::collections::HashMap::::new(); + for session in sessions { + let Some(key) = canonical_codex_key(&session) else { + out.push(session); + continue; + }; + if let Some(index) = positions.get(&key).copied() { + if codex_candidate_preferred(&session, &out[index]) { + out[index] = session; + } + } else { + positions.insert(key, out.len()); + out.push(session); + } + } + out +} + +pub(super) fn limit_with_codex_ancestors(sessions: Vec, limit: usize) -> Vec { + if sessions.len() <= limit { + return sessions; + } + let mut positions = std::collections::HashMap::::new(); + for (index, session) in sessions.iter().enumerate() { + if let Some(key) = canonical_codex_key(session) { + positions.insert(key, index); + } + } + let mut included: std::collections::HashSet = (0..limit).collect(); + let mut queue: Vec = (0..limit).collect(); + let mut cursor = 0usize; + while cursor < queue.len() { + let index = queue[cursor]; + cursor += 1; + let session = &sessions[index]; + if canonical_codex_key(session).is_none() { + continue; + } + let dir_id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + let direct_parent = session.get("parentThreadId").and_then(Value::as_str); + let root_parent = session + .get("isSubagent") + .and_then(Value::as_bool) + .unwrap_or(false) + .then(|| session.get("rootSessionId").and_then(Value::as_str)) + .flatten(); + let parent_index = [direct_parent, root_parent] + .into_iter() + .flatten() + .find_map(|parent_id| positions.get(&format!("{dir_id}\0{parent_id}")).copied()); + let Some(parent_index) = parent_index else { + continue; + }; + if included.insert(parent_index) { + queue.push(parent_index); + } + } + sessions + .into_iter() + .enumerate() + .filter_map(|(index, session)| included.contains(&index).then_some(session)) + .collect() +} diff --git a/src-tauri/src/history/edit.rs b/src-tauri/src/history/edit.rs new file mode 100644 index 0000000..0aa8652 --- /dev/null +++ b/src-tauri/src/history/edit.rs @@ -0,0 +1,189 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::{foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_head}; +use super::meta::meta_cache; +use super::paths::{all_dirs, sibling_dir}; +use super::session::read_import_meta; + +/// Write per-conversation customization (custom title + tags) onto the FIRST parseable line as a +/// `__ccbud__` field. Atomic (tmp + rename). Guarded to the configured dirs + the imports store +/// (renderer can't drive an arbitrary-path write, but imported sessions must be titleable/taggable +/// too — mirrors history.js setCcbud, whose getDirs() includes the imported dir). +pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { + let target = Path::new(file); + if !within_scope(target, config) { + return json!({ "ok": false, "reason": "out-of-scope" }); + } + // Foreign-CLI sessions are other tools' files (one is SQLite): their title/tags/delete + // flag always live in the app-owned sidecar. Same cache-drop contract as the codex branch. + if let Some(fk) = foreign_kind(target) { + let r = match fk { + Foreign::Grok => crate::grok::set_meta(file, patch), + Foreign::Copilot => crate::copilot::set_meta(file, patch), + Foreign::Antigravity => crate::antigravity::set_meta(file, patch), + }; + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + // Qoder sessions are Claude-format but another tool's live files: title/tags/delete go to + // the shared sidecar (keyed qoder:) instead of an in-file rewrite. The sidecar edit + // doesn't touch the file (no mtime bump), so the list-meta memo is dropped by hand. + if crate::qoder::looks_qoder_path(target) { + let r = crate::qoder::set_meta(file, patch); + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + let raw = match fs::read_to_string(file) { + Ok(s) => s, + Err(_) => return json!({ "ok": false, "reason": "read" }), + }; + // Live Codex rollouts are another tool's files — their title/tags/delete flag live in the + // app-owned sidecar instead of being written into the rollout. Imported codex COPIES sit + // inside our store (marked by .import.json) and take the normal in-file path below. + let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); + if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { + let r = crate::codex::set_meta(file, patch); + // A sidecar edit changes the row without touching the rollout file (no mtime bump), so + // the list-meta memo must be dropped by hand. In-file writes below invalidate via mtime. + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } + let mut lines: Vec = raw.split('\n').map(|s| s.to_string()).collect(); + let mut found: Option<(usize, Value)> = None; + for (i, l) in lines.iter().enumerate() { + let s = l.trim(); + if s.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(s) { + if v.is_object() { + found = Some((i, v)); + break; + } + } + } + let (idx, mut obj) = match found { + Some(x) => x, + None => return json!({ "ok": false, "reason": "empty" }), + }; + let mut next = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); + if let Some(t) = patch.get("title") { + let t = t.as_str().unwrap_or("").trim().to_string(); + if !t.is_empty() { + next.insert("title".into(), json!(t)); + } else { + next.remove("title"); + } + } + if let Some(tags) = patch.get("tags") { + let mut arr: Vec = vec![]; + if let Some(ta) = tags.as_array() { + for x in ta { + if let Some(s) = x.as_str() { + let s = s.trim(); + if !s.is_empty() && !arr.iter().any(|y| y == s) { + arr.push(s.to_string()); + } + } + } + } + if !arr.is_empty() { + next.insert("tagList".into(), json!(arr)); + } else { + next.remove("tagList"); + } + } + // Soft delete / restore: `delete: true` marks the session deleted; `delete: false` (restore) + // drops the flag. Restore that empties __ccbud__ removes the field wholesale below. + if let Some(d) = patch.get("delete") { + if d.as_bool().unwrap_or(false) { + next.insert("delete".into(), json!(true)); + } else { + next.remove("delete"); + } + } + let o = obj.as_object_mut().unwrap(); + if !next.is_empty() { + o.insert("__ccbud__".into(), Value::Object(next)); + } else { + o.remove("__ccbud__"); + } + lines[idx] = serde_json::to_string(&obj).unwrap_or_default(); + let out = lines.join("\n"); + let tmp = format!("{}.ccbud.tmp", file); + if fs::write(&tmp, &out).is_err() { + return json!({ "ok": false, "reason": "write" }); + } + if fs::rename(&tmp, file).is_err() { + let _ = fs::remove_file(&tmp); + return json!({ "ok": false, "reason": "write" }); + } + // The rewrite bumps mtime/size, which already invalidates the list-meta memo — dropping the + // entry outright also covers a same-millisecond, same-length rewrite. + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + json!({ "ok": true }) +} + +/// Permanently remove a session's .jsonl from disk (recycle-bin "delete forever"). Guarded to the +/// configured dirs + the imports store exactly like set_ccbud, and also drops the session's +/// `/` subagents tree and any import sidecar (mirrors remove_import's cleanup). +/// Renderer-driven writes/deletes are confined to the configured work dirs' data trees +/// (projects/ AND sessions/) plus the imports store. +fn within_scope(target: &Path, config: &Value) -> bool { + all_dirs(config).iter().any(|(_, _, pd)| { + target.starts_with(pd) + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(pd, n).map(|sd| target.starts_with(sd)).unwrap_or(false)) + }) +} + +pub fn delete_session_file(file: &str, config: &Value) -> Value { + let target = Path::new(file); + if !within_scope(target, config) { + return json!({ "ok": false, "reason": "out-of-scope" }); + } + if !target.is_file() { + return json!({ "ok": false, "reason": "missing" }); + } + // A LIVE Codex rollout, Qoder session, or foreign-CLI session is another tool's file — the + // app only ever soft-deletes those via the sidecar and never rewrites them (see set_ccbud), + // so "delete forever" must not rm the source either. Imported codex COPIES (marked by an + // .import.json) are our own snapshots and stay hard-deletable, like Claude sessions the app + // manages in the configured dirs. + if foreign_kind(target).is_some() || crate::qoder::looks_qoder_path(target) { + return json!({ "ok": false, "reason": "foreign" }); + } + let head = parse_lines(&read_head(target, 131072)); + if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { + return json!({ "ok": false, "reason": "foreign" }); + } + if fs::remove_file(target).is_err() { + return json!({ "ok": false, "reason": "remove" }); + } + crate::codex::remove_meta(file); // drop any codex sidecar entry (no-op for Claude sessions) + let dir = target.parent().unwrap_or(Path::new(".")); + let stem = target.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if !stem.is_empty() { + let _ = fs::remove_dir_all(dir.join(stem)); // /subagents/... + let _ = fs::remove_file(dir.join(format!("{}.import.json", stem))); + } + json!({ "ok": true }) +} diff --git a/src-tauri/src/history/foreign.rs b/src-tauri/src/history/foreign.rs new file mode 100644 index 0000000..fe24d35 --- /dev/null +++ b/src-tauri/src/history/foreign.rs @@ -0,0 +1,103 @@ +use std::path::{Path, PathBuf}; + +use super::jsonl::{mtime_ms, parse_lines, read_head}; +use super::session::read_import_meta; +use super::text::read_ccbud; + +/// The foreign-CLI session sources routed by CONTAINER SHAPE (their path layouts are +/// distinctive per tool, and one of them isn't even jsonl) — content sniffing stays reserved +/// for the historical Claude-vs-Codex jsonl split. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Foreign { + Grok, + Copilot, + Antigravity, +} + +pub(crate) fn foreign_kind(file: &Path) -> Option { + if crate::grok::looks_grok_path(file) { + return Some(Foreign::Grok); + } + if crate::copilot::looks_copilot_path(file) { + return Some(Foreign::Copilot); + } + if crate::antigravity::looks_agy_path(file) { + return Some(Foreign::Antigravity); + } + None +} + +/// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's +/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / Qoder session / +/// foreign source, whose flag lives in a sidecar and can flip WITHOUT touching the file — so only +/// the format verdict is cached, never the flag). +#[derive(Clone, Copy)] +enum DelKind { + Claude(bool), + Codex, + Qoder, + Foreign(Foreign), +} + +/// Process-lifetime memo of soft-delete status, keyed `path -> (mtime, kind)`. mtime is the +/// invalidation signal: set_ccbud rewrites a Claude file (bumping mtime) whenever the flag flips, +/// so a matching mtime means the cached answer is still valid. This lets dir_stats *stat* +/// unchanged sessions on each refresh instead of re-reading them. +fn deleted_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// Cheap soft-delete probe for counting, memoized by mtime. A Claude session's `__ccbud__.delete` +/// rides on the first parseable line, so a small head read suffices; a Codex rollout's flag is +/// re-read from the sidecar every time (itself mtime-cached and cheap). +pub(super) fn is_session_deleted(file: &Path) -> bool { + let mt = mtime_ms(file); + let cached: Option = deleted_cache() + .lock() + .ok() + .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); + let kind = cached.unwrap_or_else(|| { + // Foreign sources are recognized by path shape alone — no read needed. Qoder is + // Claude-FORMAT but another tool's file, so its flag lives in the sidecar too. + let kind = if let Some(fk) = foreign_kind(file) { + DelKind::Foreign(fk) + } else if crate::qoder::looks_qoder_path(file) { + DelKind::Qoder + } else { + // Read the same window session_meta uses: a Codex rollout's first (session_meta) line + // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields + // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. + let recs = parse_lines(&read_head(file, 131072)); + // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — + // only live rollouts (no .import.json) use the sidecar. + if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { + DelKind::Codex + } else { + DelKind::Claude(read_ccbud(&recs).2) + } + }; + if let Ok(mut cache) = deleted_cache().lock() { + cache.insert(file.to_path_buf(), (mt, kind)); + } + kind + }); + match kind { + DelKind::Claude(del) => del, + DelKind::Codex => crate::codex::is_deleted(file), + DelKind::Qoder => crate::qoder::is_deleted(file), + DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), + DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), + DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), + } +} + +/// Freshness stamp for the list-meta / search caches: plain mtime, except Antigravity DBs +/// where a live agy writes into the WAL without touching the main file. +pub(super) fn cache_stamp_ms(file: &Path) -> f64 { + match foreign_kind(file) { + Some(Foreign::Antigravity) => crate::antigravity::wal_mtime_ms(file), + _ => mtime_ms(file), + } +} diff --git a/src-tauri/src/history/foreign_probe.rs b/src-tauri/src/history/foreign_probe.rs new file mode 100644 index 0000000..1efdeb5 --- /dev/null +++ b/src-tauri/src/history/foreign_probe.rs @@ -0,0 +1,68 @@ +use serde_json::{json, Value}; + +use super::list::list_sessions; +use super::session::get_session; + +// Diagnostic harness (not an assertion): list + open REAL foreign-CLI sessions so the +// shapers can be eyeballed against live ~/.grok, ~/.copilot, ~/.gemini/antigravity-cli. +// Run: CCBUD_PROBE_FOREIGN="~/.grok,~/.copilot,~/.gemini/antigravity-cli" \ +// cargo test --lib probe_foreign_dirs -- --ignored --nocapture +#[test] +#[ignore] +fn probe_foreign_dirs() { + let Ok(dirs) = std::env::var("CCBUD_PROBE_FOREIGN") else { + eprintln!("set CCBUD_PROBE_FOREIGN=dir1,dir2,…"); + return; + }; + let list: Vec<&str> = dirs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let config = json!({ "historyDirs": list }); + let sessions = list_sessions(&config, "all", 500); + eprintln!("== {} sessions across {:?}", sessions.len(), list); + let mut by_source: std::collections::HashMap = std::collections::HashMap::new(); + for s in &sessions { + *by_source + .entry(s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string()) + .or_insert(0) += 1; + } + eprintln!("== by source: {:?}", by_source); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for s in &sessions { + let src = s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string(); + if !seen.insert(src.clone()) { + continue; + } + let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); + eprintln!( + "-- [{}] {} | cwd={} | title={:?}", + src, + file, + s.get("cwd").and_then(|v| v.as_str()).unwrap_or("-"), + s.get("title").and_then(|v| v.as_str()).unwrap_or("-") + ); + let detail = get_session(file); + let meta = detail.get("meta").cloned().unwrap_or(Value::Null); + let msgs = detail.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + eprintln!( + " detail: assistant={:?} messages={} totals={} firstTs={:?}", + meta.get("assistant").and_then(|v| v.as_str()), + msgs, + meta.get("totals").map(|t| t.to_string()).unwrap_or_default(), + meta.get("firstTs").and_then(|v| v.as_str()) + ); + if let Some(arr) = detail.get("messages").and_then(|v| v.as_array()) { + for m in arr.iter().take(4) { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?"); + let kinds: Vec = m + .get("content") + .and_then(|c| c.as_array()) + .map(|a| { + a.iter() + .map(|b| b.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()) + .collect() + }) + .unwrap_or_default(); + eprintln!(" msg {} {:?}", role, kinds); + } + } + } +} diff --git a/src-tauri/src/history/import.rs b/src-tauri/src/history/import.rs new file mode 100644 index 0000000..8956a63 --- /dev/null +++ b/src-tauri/src/history/import.rs @@ -0,0 +1,151 @@ +// ---- import (copy someone else's .jsonl into the app-managed store) ---- + +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::jsonl::parse_lines; +use super::paths::imports_root; + +fn encode_cwd(cwd: Option<&str>) -> String { + match cwd { + Some(c) if !c.is_empty() => c.replace(['/', '\\'], "-"), + _ => "-imported".to_string(), + } +} + +fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for e in fs::read_dir(src)? { + let e = e?; + let s = e.path(); + let d = dst.join(e.file_name()); + if s.is_dir() { + copy_dir(&s, &d)?; + } else { + fs::copy(&s, &d)?; + } + } + Ok(()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn records_to_jsonl(records: &[Value]) -> String { + let mut text = records + .iter() + .map(|r| serde_json::to_string(r).unwrap_or_default()) + .collect::>() + .join("\n"); + text.push('\n'); + text +} + +/// A qoder transcript imported into the app store loses its container path and would otherwise +/// re-parse as raw Claude records: queued commands vanish, redacted duplicates render, atomic +/// wrappers stay fragmented, and qoder's own title is lost. Sniffed by CONTENT (import copies and +/// bundle zips carry no .qoder path), the copy is rewritten up front — normalized records, with +/// the qoder title carried onto the first line's __ccbud__ so the import keeps its name. +pub(super) fn qoder_import_raw(recs: &[Value]) -> Option<(String, Vec)> { + if !crate::qoder::looks_qoder_records(recs) { + return None; + } + let mut normalized = crate::qoder::normalize_records(recs); + if let Some(title) = crate::qoder::session_title_from(recs) { + if let Some(first) = normalized.iter_mut().find(|r| r.is_object()) { + let obj = first.as_object_mut().unwrap(); + let mut cc = obj.get("__ccbud__").and_then(|v| v.as_object()).cloned().unwrap_or_default(); + cc.entry("title".to_string()).or_insert_with(|| json!(title)); + obj.insert("__ccbud__".into(), Value::Object(cc)); + } + } + Some((records_to_jsonl(&normalized), normalized)) +} + +/// Snapshot a transcript (already read into `raw`) plus its subagent sidecars into the import store, +/// laid out like a native projects/ tree + a provenance sidecar. `subagents`: (filename, bytes) to +/// drop under `/subagents/` — names are basename-reduced and pattern-checked so a crafted +/// entry can't escape the directory. Returns 1 = imported, 2 = skipped (already present), +/// 0 = failed/not-a-transcript. Shared by the plain-.jsonl and .zip-bundle import paths. +pub(super) fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents: &[(String, Vec)]) -> i32 { + let recs = parse_lines(raw); + let is_codex = crate::codex::looks_codex(&recs); + // Qoder content is rewritten to Claude shape before storing — the has_msg gate below then + // sees the materialized queued-command user turns too. + let (qoder_text, recs) = match qoder_import_raw(&recs) { + Some((text, normalized)) => (Some(text), normalized), + None => (None, recs), + }; + let raw = qoder_text.as_deref().unwrap_or(raw); + let has_msg = recs.iter().any(|r| { + let t = r.get("type").and_then(|v| v.as_str()); + (t == Some("user") || t == Some("assistant")) && r.get("message").is_some() + }); + if !has_msg && !is_codex { + return 0; + } + let name_stem = || Path::new(original_name).file_stem().and_then(|s| s.to_str()).unwrap_or("import").to_string(); + // Codex rollouts keep cwd/session id inside the session_meta payload, not on the records. + let (cwd_owned, base_id) = if is_codex { + let (c, s) = crate::codex::head_ids(&recs); + (c, s.unwrap_or_else(name_stem)) + } else { + let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + ( + meta_rec.and_then(|r| r.get("cwd")).and_then(|v| v.as_str()).map(|s| s.to_string()), + meta_rec + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(name_stem), + ) + }; + let cwd = cwd_owned.as_deref(); + let dest_dir = imports_root().join("projects").join(encode_cwd(cwd)); + let dest_file = dest_dir.join(format!("{}.jsonl", base_id)); + if dest_file.exists() { + return 2; + } + if fs::create_dir_all(&dest_dir).is_err() || fs::write(&dest_file, raw).is_err() { + return 0; + } + if !subagents.is_empty() { + let sub_dir = dest_dir.join(&base_id).join("subagents"); + if fs::create_dir_all(&sub_dir).is_ok() { + for (name, bytes) in subagents { + // file_name() strips any directory component, so the write can't escape sub_dir. + let safe = Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(""); + let lower = safe.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + // A qoder session's subagent transcripts carry the same atomic wrappers — + // the parent's sniff decides, so the whole stored copy is Claude-shaped. + if qoder_text.is_some() && lower.ends_with(".jsonl") { + if let Ok(text) = std::str::from_utf8(bytes) { + let normalized = crate::qoder::normalize_records(&parse_lines(text)); + let _ = fs::write(sub_dir.join(safe), records_to_jsonl(&normalized)); + continue; + } + } + let _ = fs::write(sub_dir.join(safe), bytes); + } + } + } + } + let sidecar = dest_dir.join(format!("{}.import.json", base_id)); + let _ = fs::write( + &sidecar, + serde_json::to_vec_pretty(&json!({ + "originalPath": original_path, + "originalName": original_name, + "sessionId": base_id, + "importedAt": now_ms(), + })) + .unwrap_or_default(), + ); + 1 +} diff --git a/src-tauri/src/history/importpaths.rs b/src-tauri/src/history/importpaths.rs new file mode 100644 index 0000000..c096c63 --- /dev/null +++ b/src-tauri/src/history/importpaths.rs @@ -0,0 +1,103 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::foreign_kind; +use super::import::write_imported; +use super::jsonl::read_session_text; +use super::paths::imports_root; +use super::subagents::read_subagent_files; + +/// Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. +/// Foreign-CLI sources (Grok / Copilot / Antigravity) are intentionally not importable — +/// their layouts/formats aren't Claude/Codex, and a Grok chat_history head would otherwise +/// trip looks_codex (its `reasoning` lines look like old envelope-less Codex items). +fn import_one(src: &str) -> i32 { + let src_path = Path::new(src); + if foreign_kind(src_path).is_some() { + return 0; + } + let raw = match read_session_text(src_path) { + Ok(s) => s, + Err(_) => return 0, + }; + // Path-less copies of foreign transcripts: refuse anything whose head sniffs as Grok + // chat_history (type:system + later reasoning/tool_result) or Copilot events + // (type:session.start with producer copilot-agent). + let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); + if looks_foreign_jsonl(&head) { + return 0; + } + let subs = read_subagent_files(src_path); + let original_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + write_imported(&raw, src, original_name, &subs) +} + +/// Content sniff for foreign CLI jsonl (used by import when the path no longer carries the +/// original container shape — e.g. a bare chat_history.jsonl dropped into the import dialog). +fn looks_foreign_jsonl(recs: &[Value]) -> bool { + recs.iter().take(8).any(|r| match r.get("type").and_then(|v| v.as_str()) { + // Copilot event stream + Some("session.start") | Some("user.message") | Some("assistant.message") + | Some("tool.execution_complete") | Some("tool.execution_start") => true, + // Grok chat_history: top-level system/reasoning/tool_result (Claude wraps these) + Some("reasoning") | Some("tool_result") if r.get("message").is_none() => true, + Some("system") if r.get("content").is_some() && r.get("message").is_none() => true, + _ => false, + }) +} + +/// Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout so +/// the pipeline nests them exactly as if they'd been captured live. Round-trips export_bundle. +fn import_zip(src: &str) -> i32 { + let bytes = match fs::read(src) { + Ok(b) => b, + Err(_) => return 0, + }; + let (main, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&bytes)); + let main_data = match main { + Some((_, data)) => data, + None => return 0, + }; + let raw = match String::from_utf8(main_data) { + Ok(s) => s, + Err(_) => return 0, + }; + let original_name = Path::new(src).file_name().and_then(|n| n.to_str()).unwrap_or(""); + write_imported(&raw, src, original_name, &subs) +} + +pub fn import_paths(paths: &[String]) -> Value { + let (mut imported, mut skipped, mut failed) = (0, 0, 0); + for src in paths { + let lower = src.to_lowercase(); + let r = if lower.ends_with(".zip") { + import_zip(src) + } else if lower.ends_with(".jsonl") { + import_one(src) + } else { + 0 + }; + match r { + 1 => imported += 1, + 2 => skipped += 1, + _ => failed += 1, + } + } + json!({ "imported": imported, "skipped": skipped, "failed": failed }) +} + +pub fn remove_import(file: &str) -> Value { + let root = imports_root(); + let f = Path::new(file); + // Hard safety: only ever delete inside our own import store. + if !f.starts_with(&root) { + return json!({ "ok": false, "error": "outside import store" }); + } + let dir = f.parent().unwrap_or(Path::new(".")); + let base = f.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let _ = fs::remove_file(f); + let _ = fs::remove_file(dir.join(format!("{}.import.json", base))); + let _ = fs::remove_dir_all(dir.join(base)); // subagents/ + json!({ "ok": true }) +} diff --git a/src-tauri/src/history/jsonl.rs b/src-tauri/src/history/jsonl.rs new file mode 100644 index 0000000..7693def --- /dev/null +++ b/src-tauri/src/history/jsonl.rs @@ -0,0 +1,128 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +pub(crate) fn parse_lines(text: &str) -> Vec { + let mut out = vec![]; + for line in text.split('\n') { + let s = line.trim(); + if s.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(s) { + out.push(v); + } + } + out +} + +pub(super) fn read_head_result(file: &Path, max: usize) -> std::io::Result { + use std::io::{BufRead, BufReader, Read}; + // Qoder data can be protected as "Other Application Data" on macOS. Its reader first + // attempts the normal filesystem path and uses the installed Qoder CLI only for EPERM; + // keep the same bounded-head contract used by list metadata after that read succeeds. + if crate::qoder::looks_qoder_path(file) { + let mut bytes = crate::qoder::read_bytes(file)?; + bytes.truncate(max); + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + let mut file = fs::File::open(file)?; + let mut buf = vec![0u8; max]; + let read = file.read(&mut buf)?; + buf.truncate(read); + // SessionMeta may exceed the ordinary list window because it can embed base instructions and + // dynamic tools. Extend ONLY when the first record itself has no newline yet; a later partial + // record can be ignored, avoiding an accidental multi-megabyte image/tool-result read. + let prefix_len = buf.len().min(4096); + let compact_prefix: String = String::from_utf8_lossy(&buf[..prefix_len]) + .chars() + .filter(|value| !value.is_ascii_whitespace()) + .collect(); + let codex_session_meta = compact_prefix + .find("\"type\":\"session_meta\"") + .is_some_and(|position| position < 512); + if codex_session_meta && read == max && !buf.contains(&b'\n') { + let mut reader = BufReader::new(file); + let _ = reader.read_until(b'\n', &mut buf)?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +pub(crate) fn read_head(file: &Path, max: usize) -> String { + read_head_result(file, max).unwrap_or_default() +} + +pub(super) fn read_session_text(file: &Path) -> std::io::Result { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(file) + } else { + fs::read_to_string(file) + } +} + +pub(super) fn read_session_bytes(file: &Path) -> std::io::Result> { + if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_bytes(file) + } else { + fs::read(file) + } +} + +/// Verbatim bytes for raw export. Qoder sessions may require the guarded Qoder CLI fallback on +/// macOS; all other sources retain the ordinary filesystem read used before Qoder support. +pub(crate) fn raw_session_bytes(file: &str) -> std::io::Result> { + read_session_bytes(Path::new(file)) +} + +pub(crate) fn session_read_error(file: &Path, error: &std::io::Error) -> Value { + let kind = match error.kind() { + std::io::ErrorKind::NotFound => "notFound", + std::io::ErrorKind::PermissionDenied => "permissionDenied", + _ => "readFailed", + }; + json!({ + "error": { + "kind": kind, + "file": file.to_string_lossy(), + "message": error.to_string(), + } + }) +} + +pub(super) fn mtime_ms(file: &Path) -> f64 { + fs::metadata(file) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +/// File creation (birth) time in ms; mtime on filesystems that don't record one. NOT stable +/// across a title/tag edit — set_ccbud rewrites via tmp+rename, which gives the path the tmp +/// file's (fresh) birth time — so this is only the FALLBACK sort key when a session's records +/// carry no timestamp; record_created_ms is the real one. +pub(crate) fn created_ms(file: &Path) -> f64 { + fs::metadata(file) + .and_then(|m| m.created()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .filter(|v| *v > 0.0) + .unwrap_or_else(|| mtime_ms(file)) +} + +/// Session creation time for ORDERING: the first record's timestamp, i.e. content-derived and +/// therefore immune to file rewrites — renaming/tagging a conversation (tmp+rename resets the +/// fs birth time) must never reshuffle the list. Falls back to fs times when no record carries +/// a timestamp. Claude records and Codex rollout lines both put `timestamp` at the top level. +pub(crate) fn record_created_ms(recs: &[Value], file: &Path) -> f64 { + for r in recs { + if let Some(ts) = r.get("timestamp").and_then(|v| v.as_str()) { + if let Ok(d) = chrono::DateTime::parse_from_rfc3339(ts) { + return d.timestamp_millis() as f64; + } + } + } + created_ms(file) +} diff --git a/src-tauri/src/history/list.rs b/src-tauri/src/history/list.rs new file mode 100644 index 0000000..7a1a292 --- /dev/null +++ b/src-tauri/src/history/list.rs @@ -0,0 +1,180 @@ +use serde_json::{json, Value}; +use std::path::PathBuf; + +use super::codexdedupe::{dedupe_canonical_codex_sessions, limit_with_codex_ancestors}; +use super::meta::{meta_cache, session_meta}; +use super::paths::{all_dirs, each_session_file, sibling_dir}; +use super::TRASH_ID; + +pub fn list_sessions(config: &Value, active: &str, limit: usize) -> Vec { + // The recycle bin spans every dir and shows only soft-deleted sessions; every other view + // is scoped to its dir and hides them. + let trash = active == TRASH_ID; + // Read (memoized) metas for EVERY candidate, then dedupe/order before the limit cut. Most + // formats use content-derived CreatedAt so title/tag rewrites cannot reshuffle rows; Codex + // uses rollout UpdatedAt because its custom metadata is sidecar-only and Codex defines latest + // that way. The meta cache turns the full walk into stats for unchanged files. + let mut live: std::collections::HashSet = std::collections::HashSet::new(); + let mut candidates: Vec<(PathBuf, String, String, String)> = Vec::new(); + each_session_file(config, |file, dir_name, id, label| { + live.insert(file.clone()); + if !trash && active != "all" && id != active { + return; + } + candidates.push((file, dir_name, id.to_string(), label.to_string())); + }); + // Warm the qoder helper cache in ONE batch before the per-row reads — on a macOS install with + // protected app data, every stale row would otherwise spawn its own helper process. + let qoder_files: Vec = candidates + .iter() + .map(|(file, _, _, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); + let mut out: Vec = Vec::new(); + for (file, dir_name, id, label) in &candidates { + if let Some(m) = session_meta(file, dir_name, id, label) { + out.push(m); + } + } + // Drop memo entries for files that no longer exist, so removed dirs don't pin stale rows. + if let Ok(mut cache) = meta_cache().lock() { + cache.retain(|k, _| live.contains(k)); + } + // Collapse only true physical duplicates (same dir + canonical thread id) BEFORE limit. + // Threads that merely share rootSessionId are distinct root/subagent nodes and remain intact. + let mut out = dedupe_canonical_codex_sessions(out); + // Apply recycle-bin visibility to the selected logical representative, not to each physical + // candidate. Otherwise deleting the authoritative copy could make a stale duplicate reappear + // in the normal list while the same logical thread also sits in the recycle bin. + out.retain(|session| { + session.get("deleted").and_then(Value::as_bool).unwrap_or(false) == trash + }); + let key = |v: &Value| { + // Codex defines "latest" as UpdatedAt (rollout mtime); its title/tags live in a sidecar, + // so this timestamp is not dirtied by ccbud edits. Keep the stable CreatedAt policy for + // formats whose transcript itself is rewritten when metadata changes. + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; + out.sort_by(|a, b| { + key(b) + .partial_cmp(&key(a)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let updated = |value: &Value| { + value.get("lastActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + updated(b) + .partial_cmp(&updated(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| { + let a_id = a + .get("threadId") + .or_else(|| a.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + let b_id = b + .get("threadId") + .or_else(|| b.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + b_id.cmp(a_id) + }) + }); + // Soft-cap Codex trees: include the parent/root chain of every selected child so a busy tree + // cannot show orphan subagents merely because its older root fell just below the limit. + limit_with_codex_ancestors(out, limit) +} + +pub fn list_projects(config: &Value, active: &str) -> Vec { + let sessions = list_sessions(config, active, 600); + let mut order: Vec = vec![]; + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for s in sessions { + let cwd = s.get("cwd").and_then(|v| v.as_str()).unwrap_or("(unknown)").to_string(); + let la = s.get("lastActivity").and_then(|v| v.as_f64()).unwrap_or(0.0); + let ct = s.get("createdAt").and_then(|v| v.as_f64()).unwrap_or(la); + let sk = if s.get("source").and_then(Value::as_str) == Some("codex") { la } else { ct }; + let g = groups.entry(cwd.clone()).or_insert_with(|| { + order.push(cwd.clone()); + json!({ "cwd": s.get("cwd").cloned().unwrap_or(Value::Null), "name": s.get("project").cloned().unwrap_or(Value::Null), "sessions": [], "lastActivity": 0.0, "createdAt": 0.0, "sortActivity": 0.0 }) + }); + g["sessions"].as_array_mut().unwrap().push(s.clone()); + if la > g["lastActivity"].as_f64().unwrap_or(0.0) { + g["lastActivity"] = json!(la); + } + if ct > g["createdAt"].as_f64().unwrap_or(0.0) { + g["createdAt"] = json!(ct); + } + if sk > g["sortActivity"].as_f64().unwrap_or(0.0) { + g["sortActivity"] = json!(sk); + } + } + // Codex's latest semantic is rollout UpdatedAt; other formats retain CreatedAt so in-file + // title/tag edits cannot reorder them. Apply the same source-aware rule to rows and projects. + let sort_key = |v: &Value| { + let field = if v.get("source").and_then(Value::as_str) == Some("codex") { + "lastActivity" + } else { + "createdAt" + }; + v.get(field).and_then(Value::as_f64).unwrap_or(0.0) + }; + let mut arr: Vec = order.into_iter().filter_map(|k| groups.remove(&k)).collect(); + for g in &mut arr { + g["sessions"].as_array_mut().unwrap().sort_by(|a, b| { + sort_key(b).partial_cmp(&sort_key(a)).unwrap_or(std::cmp::Ordering::Equal) + }); + } + arr.sort_by(|a, b| { + let key = |value: &Value| { + value.get("sortActivity").and_then(Value::as_f64).unwrap_or(0.0) + }; + key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) + }); + for group in &mut arr { + if let Some(object) = group.as_object_mut() { + object.remove("sortActivity"); + } + } + arr +} + +pub fn dir_stats(config: &Value) -> Vec { + // Per-dir counts exclude soft-deleted sessions (they're hidden from those views); the deleted + // ones are tallied separately into the synthetic recycle-bin bucket. Reuse list_sessions so + // counts reflect canonical logical rows rather than duplicate physical rollout files. + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + for session in list_sessions(config, "all", usize::MAX) { + let id = session.get("dirId").and_then(Value::as_str).unwrap_or(""); + *counts.entry(id.to_string()).or_insert(0) += 1; + } + let trash = list_sessions(config, TRASH_ID, usize::MAX).len() as i64; + let mut out: Vec = all_dirs(config) + .into_iter() + .map(|(id, label, pd)| { + // A dir "exists" when ANY data tree is on disk — ~/.codex has only sessions/, + // ~/.copilot only session-state/, ~/.gemini/antigravity-cli only conversations/. + let exists = pd.is_dir() + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(&pd, n).map(|s| s.is_dir()).unwrap_or(false)); + let imported = id == "__imported__"; + json!({ + "id": id.clone(), "label": label, "projectsDir": pd.to_string_lossy(), + "sessions": counts.get(&id).copied().unwrap_or(0), "exists": exists, "imported": imported, + }) + }) + .collect(); + out.push(json!({ + "id": TRASH_ID, "label": "回收站", "projectsDir": "", + "sessions": trash, "exists": true, "imported": false, "trash": true, + })); + out +} diff --git a/src-tauri/src/history/meta.rs b/src-tauri/src/history/meta.rs new file mode 100644 index 0000000..7874eaf --- /dev/null +++ b/src-tauri/src/history/meta.rs @@ -0,0 +1,168 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::foreign::{cache_stamp_ms, foreign_kind, Foreign}; +use super::jsonl::{ + mtime_ms, parse_lines, read_head, read_head_result, record_created_ms, session_read_error, +}; +use super::norm::line_to_message; +use super::paths::{base_name, decode_dir_name}; +use super::text::{first_user_text, read_ccbud}; + +/// Mtime+size-keyed memo of session_meta list rows (mirrors the JS metaCache). List refreshes +/// fire on every watched write during a live session and previously re-read every candidate's +/// file head each time — with the memo, unchanged sessions cost a stat. Pruned in list_sessions +/// against the live file set; a live Codex rollout's sidecar edit (which does NOT touch the +/// file) is invalidated explicitly by set_ccbud. +pub(super) fn meta_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +pub(super) fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { + let (mt, size) = (cache_stamp_ms(file), fs::metadata(file).ok()?.len()); + if let Ok(cache) = meta_cache().lock() { + if let Some((cmt, csz, v)) = cache.get(file) { + if *cmt == mt && *csz == size { + return Some(v.clone()); + } + } + } + let built = build_session_meta(file, dir_name, dir_id, dir_label)?; + // A permission failure is recoverable without changing the transcript's mtime/size (for + // example after the user grants macOS App Data access) — never memoize that placeholder row, + // otherwise it would stay "(conversation)" until the process restarts. Every OTHER read + // error is memoized like a normal row: the mtime/size key already invalidates it when the + // file changes, and skipping the memo would re-read a broken transcript on every refresh. + let awaiting_grant = built + .get("readError") + .and_then(|e| e.get("kind")) + .and_then(|k| k.as_str()) + == Some("permissionDenied"); + if !awaiting_grant { + if let Ok(mut cache) = meta_cache().lock() { + cache.insert(file.to_path_buf(), (mt, size, built.clone())); + } + } + Some(built) +} + +fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { + // Foreign sources first — routed by container shape BEFORE any content read (one of them + // isn't even text), each through its own shaper. + match foreign_kind(file) { + Some(Foreign::Grok) => return crate::grok::session_meta_from(file, dir_id, dir_label), + Some(Foreign::Copilot) => { + let recs = parse_lines(&read_head(file, 131072)); + return crate::copilot::session_meta_from(file, &recs, dir_id, dir_label); + } + Some(Foreign::Antigravity) => return crate::antigravity::session_meta_from(file, dir_id, dir_label), + None => {} + } + let meta = fs::metadata(file).ok()?; + let size = meta.len(); + let qoder = crate::qoder::looks_qoder_path(file); + // Qoder stores title/workspace/runtime records throughout the transcript, and its first JSON + // line can itself exceed the ordinary 128 KiB list window. Read the full file once (the row + // cache makes this a one-time cost per mtime/size); other formats retain the bounded head. + let raw = if qoder { + crate::qoder::read_text(file) + } else { + read_head_result(file, 131072) + }; + let (parsed_recs, read_error) = match raw { + Ok(raw) => (parse_lines(&raw), Value::Null), + Err(error) => ( + vec![], + session_read_error(file, &error) + .get("error") + .cloned() + .unwrap_or(Value::Null), + ), + }; + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; + // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list + // through the codex shaper — the record format shares nothing with Claude's. + if crate::codex::looks_codex(&recs) { + return crate::codex::session_meta_from(file, &recs, dir_id, dir_label); + } + // Qoder sessions use Claude-like user/assistant envelopes plus inline title/workspace/runtime + // records and atomic assistant content wrappers. normalize_records makes the message stream + // Claude-shaped; Qoder-specific metadata remains app-sidecar + inline JSONL data. + let meta_rec = recs + .iter() + .find(|r| r.get("cwd").is_some()) + .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); + let msgs: Vec = recs.iter().filter_map(line_to_message).collect(); + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(file) } else { read_ccbud(&recs) }; + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&msgs)); + let mut model: Option = None; + for r in &recs { + if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { + if let Some(md) = r.get("message").and_then(|m| m.get("model")).and_then(|v| v.as_str()) { + model = Some(md.to_string()); + } + } + } + if qoder_model.is_some() { + model = qoder_model; + } + let subagent = agent_rec.is_some(); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let cwd = (if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }) + .or_else(|| decode_dir_name(dir_name)); + let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let mt = mtime_ms(file); + Some(json!({ + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, + "file": file.to_string_lossy(), + "source": if qoder { "qoder" } else { "disk" }, + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": meta_rec.and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).unwrap_or(&stem), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(base_name).unwrap_or_default(), + "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": model, + "isSubagent": subagent, + "imported": dir_id == "__imported__", + "deleted": cc_deleted, + "readError": read_error, + "createdAt": record_created_ms(&recs, file), + "lastActivity": mt, + "sizeKB": (size as f64 / 1024.0).round() as i64, + })) +} diff --git a/src-tauri/src/history/mod.rs b/src-tauri/src/history/mod.rs new file mode 100644 index 0000000..7b18c8d --- /dev/null +++ b/src-tauri/src/history/mod.rs @@ -0,0 +1,62 @@ +// Conversation history. +// +// Reads Claude Code and Codex on-disk sessions across configured dirs, imported snapshots, and +// the app-managed recycle bin. Shapes list/detail payloads for the renderer, including subagents, +// custom title/tags/delete metadata, bundle import/export helpers, and live-watch roots. + +#![allow(dead_code)] + +mod codexdedupe; +mod edit; +mod foreign; +mod import; +mod importpaths; +mod jsonl; +mod list; +mod meta; +mod norm; +mod paths; +mod search; +mod searchfmt; +mod searchscan; +mod searchtext; +mod selftest; +mod session; +mod skills; +mod subagents; +mod text; + +#[cfg(test)] +mod foreign_probe; +#[cfg(test)] +mod tests_foreign; +#[cfg(test)] +mod tests_misc; +#[cfg(test)] +mod tests_qoder; +#[cfg(test)] +mod tests_search; + +pub use edit::{delete_session_file, set_ccbud}; +pub use importpaths::{import_paths, remove_import}; +pub use list::{dir_stats, list_projects, list_sessions}; +pub use norm::Norm; +pub use paths::watch_roots; +pub use search::search_sessions; +pub use selftest::{history_selftest, import_selftest}; +pub use session::get_session; +pub use subagents::{export_bundle, session_has_subagents, subagent_transcript_paths}; + +pub(crate) use foreign::{foreign_kind, Foreign}; +pub(crate) use jsonl::{ + created_ms, parse_lines, raw_session_bytes, read_head, record_created_ms, session_read_error, +}; +pub(crate) use norm::image_block; +pub(crate) use paths::base_name; +pub(crate) use session::read_import_meta; +pub(crate) use skills::{apply_skill_names, skill_from_recs}; +pub(crate) use text::{first_user_text, read_ccbud}; + +/// Synthetic "recycle bin" bucket id. Not a real projects tree (never in all_dirs / +/// each_session_file) — a cross-cutting view of soft-deleted sessions across every dir. +pub const TRASH_ID: &str = "__trash__"; diff --git a/src-tauri/src/history/norm.rs b/src-tauri/src/history/norm.rs new file mode 100644 index 0000000..501f948 --- /dev/null +++ b/src-tauri/src/history/norm.rs @@ -0,0 +1,182 @@ +use serde_json::{json, Value}; + +fn usage_of(u: &Value) -> Value { + let mut usage = json!({ + "inputTokens": u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "outputTokens": u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheRead": u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "cacheCreation": u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }); + let object = usage.as_object_mut().unwrap(); + // Qoder supplies billing/context facts alongside its zeroed token counters. Keep them on the + // per-turn usage object without adding empty fields to ordinary Claude Code messages. + for (source, target) in [ + ("credits", "credits"), + ("original_credits", "originalCredits"), + ("context_usage_ratio", "contextUsageRatio"), + ] { + if let Some(value) = u.get(source).filter(|value| value.is_number()) { + object.insert(target.to_string(), value.clone()); + } + } + usage +} + +pub(super) fn line_to_message(rec: &Value) -> Option { + let t = rec.get("type").and_then(|v| v.as_str())?; + if t != "user" && t != "assistant" { + return None; + } + let m = rec.get("message")?; + let role = m.get("role").and_then(|v| v.as_str())?; + let mut out = json!({ + "role": role, + "content": m.get("content").cloned().unwrap_or(Value::Null), + "_ts": rec.get("timestamp").cloned().unwrap_or(Value::Null), + "_sidechain": rec.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), + "_meta": rec.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false), + }); + if t == "assistant" { + let o = out.as_object_mut().unwrap(); + o.insert("_model".into(), m.get("model").cloned().unwrap_or(Value::Null)); + o.insert("_usage".into(), m.get("usage").map(usage_of).unwrap_or(Value::Null)); + o.insert("_stopReason".into(), m.get("stop_reason").cloned().unwrap_or(Value::Null)); + } + Some(out) +} + +pub(super) struct Shaped { + pub(super) messages: Vec, + pub(super) totals: Value, + pub(super) model: Option, + pub(super) first_ts: Option, + pub(super) last_ts: Option, +} + +/// The renderer's normalized session shape shared by every non-Claude source (Codex, Grok, +/// Copilot, Antigravity): Anthropic-style messages (`role` + content blocks of +/// text/thinking/tool_use/tool_result) plus the session-level facts each format can recover. +pub struct Norm { + pub messages: Vec, + pub totals: Value, + pub model: Option, + pub first_ts: Option, + pub last_ts: Option, + pub cwd: Option, + pub session_id: Option, + pub thread_id: Option, + pub parent_thread_id: Option, + pub forked_from_id: Option, + pub is_subagent: bool, + pub agent_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, + pub agent_depth: Option, + pub git_branch: Option, + pub version: Option, +} + +impl Default for Norm { + fn default() -> Self { + Norm { + messages: vec![], + totals: json!({ "in": 0, "out": 0, "cacheRead": 0, "cacheCreation": 0, "turns": 0 }), + model: None, + first_ts: None, + last_ts: None, + cwd: None, + session_id: None, + thread_id: None, + parent_thread_id: None, + forked_from_id: None, + is_subagent: false, + agent_path: None, + agent_nickname: None, + agent_role: None, + agent_depth: None, + git_branch: None, + version: None, + } + } +} + +/// data-URL image → Claude-style image source block, else None. +pub(crate) fn image_block(url: &str) -> Option { + let rest = url.strip_prefix("data:")?; + let (mime, b64) = rest.split_once(";base64,")?; + Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) +} + +pub(super) fn shape_messages(recs: &[Value]) -> Shaped { + let mut messages = vec![]; + let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut credits = 0.0f64; + let mut has_credits = false; + let mut model: Option = None; + let mut first_ts: Option = None; + let mut last_ts: Option = None; + for r in recs { + let lm = match line_to_message(r) { + Some(m) => m, + None => continue, + }; + if lm.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + let ts = lm.get("_ts").and_then(|v| v.as_str()).map(|s| s.to_string()); + if let Some(t) = &ts { + if first_ts.is_none() { + first_ts = Some(t.clone()); + } + last_ts = Some(t.clone()); + } + let mut msg = json!({ "role": lm.get("role").cloned().unwrap_or(Value::Null), "content": lm.get("content").cloned().unwrap_or(Value::Null) }); + let mo = msg.as_object_mut().unwrap(); + if lm.get("_sidechain").and_then(|v| v.as_bool()).unwrap_or(false) { + mo.insert("isSidechain".into(), json!(true)); + } + if let Some(t) = &ts { + mo.insert("ts".into(), json!(t)); + } + if r.get("type").and_then(|v| v.as_str()) == Some("assistant") { + if let Some(md) = lm.get("_model").and_then(|v| v.as_str()) { + mo.insert("modelActual".into(), json!(md)); + model = Some(md.to_string()); + } + let u = lm.get("_usage").cloned().unwrap_or(Value::Null); + if u.is_object() { + mo.insert("usage".into(), u.clone()); + tin += u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + tout += u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + tcr += u.get("cacheRead").and_then(|v| v.as_i64()).unwrap_or(0); + tcc += u.get("cacheCreation").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(value) = u.get("credits").and_then(|v| v.as_f64()) { + credits += value; + has_credits = true; + } + turns += 1; + } + if let Some(sr) = lm.get("_stopReason").and_then(|v| v.as_str()) { + mo.insert("stopReason".into(), json!(sr)); + } + } + messages.push(msg); + } + let mut totals = json!({ "in": tin, "out": tout, "cacheRead": tcr, "cacheCreation": tcc, "turns": turns }); + if has_credits { + let totals = totals.as_object_mut().unwrap(); + totals.insert("credits".into(), json!(credits)); + // Qoder's source log may omit usable token accounting while still providing real credits. + // Flag that state so the UI does not misrepresent unavailable token counts as zero usage. + if tin == 0 && tout == 0 && tcr == 0 && tcc == 0 { + totals.insert("tokenUsageAvailable".into(), json!(false)); + } + } + Shaped { + messages, + totals, + model, + first_ts, + last_ts, + } +} diff --git a/src-tauri/src/history/paths.rs b/src-tauri/src/history/paths.rs new file mode 100644 index 0000000..a7b2925 --- /dev/null +++ b/src-tauri/src/history/paths.rs @@ -0,0 +1,132 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +fn expand_tilde(p: &str) -> PathBuf { + if let Some(rest) = p.strip_prefix("~/") { + home().join(rest) + } else if p == "~" { + home() + } else { + PathBuf::from(p) + } +} + +/// Configured dirs → (id, label, projects_dir). id == the dir string (historyActive matches it). +fn config_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { + let mut out = vec![]; + if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { + for d in arr { + if let Some(s) = d.as_str() { + out.push((s.to_string(), s.to_string(), expand_tilde(s).join("projects"))); + } + } + } + out +} + +pub(crate) fn base_name(p: &str) -> String { + p.split('/').filter(|s| !s.is_empty()).last().unwrap_or(p).to_string() +} + +/// Best-effort decode of an encoded project dir name → cwd (record cwd wins when present). +pub(super) fn decode_dir_name(name: &str) -> Option { + if name.is_empty() { + return None; + } + let trimmed = name.trim_start_matches('-'); + Some(format!("/{}", trimmed.replace('-', "/"))) +} + +pub(super) fn imports_root() -> PathBuf { + crate::store::ccbud_home().join("imports") +} +/// Configured dirs + the synthetic imported-transcripts store (id `__imported__`). +pub(super) fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { + let mut dirs = config_dirs(config); + dirs.push(("__imported__".to_string(), "导入".to_string(), imports_root().join("projects"))); + dirs +} +/// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for +/// EVERY layout (Claude Code AND Qoder write `/projects/…`, Codex and Grok +/// `/sessions/…`, Copilot `/session-state/…`, Antigravity `/conversations/*.db`), +/// so `~/.codex`, `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli`, `~/.qoder` are just +/// configured dirs rather than special cases. +pub(super) fn sibling_dir(projects_dir: &Path, name: &str) -> Option { + projects_dir.parent().map(|b| b.join(name)) +} + +fn sessions_dir(projects_dir: &Path) -> Option { + sibling_dir(projects_dir, "sessions") +} + +/// Dirs to watch for live history changes — each work dir's data trees (all four layouts). +pub fn watch_roots(config: &Value) -> Vec { + let mut roots: Vec = vec![]; + for (_, _, pd) in all_dirs(config) { + for name in ["sessions", "session-state", "conversations"] { + if let Some(sd) = sibling_dir(&pd, name) { + roots.push(sd); + } + } + roots.push(pd); + } + roots +} + +/// Walk every session .jsonl across the configured dirs (+ imports), invoking +/// `cb(file, dir_name, dir_id, dir_label)` — both the Claude projects/ tree and the +/// Codex sessions/ tree of each dir. +pub(super) fn each_session_file(config: &Value, mut cb: F) { + for (id, label, root) in all_dirs(config) { + if let Ok(entries) = fs::read_dir(&root) { + for ent in entries.flatten() { + if !ent.path().is_dir() { + continue; + } + let dir_name = ent.file_name().to_string_lossy().into_owned(); + let pfiles = match fs::read_dir(ent.path()) { + Ok(f) => f, + Err(_) => continue, + }; + for f in pfiles.flatten() { + let p = f.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p, dir_name.clone(), &id, &label); + } + } + } + } + // Codex rollouts live in a date-sharded sessions/ tree; Grok shares the same sessions/ + // root but keys children by percent-encoded cwd (and stuffs sidecar jsonl — events/ + // updates/rewind — beside each chat_history.jsonl), so children are routed one by one + // rather than letting the codex walker sweep grok trees into garbage rows. + if let Some(sd) = sessions_dir(&root) { + if let Ok(children) = fs::read_dir(&sd) { + for ent in children.flatten() { + let p = ent.path(); + let name = ent.file_name().to_string_lossy().into_owned(); + if p.is_dir() && crate::grok::is_cwd_dir_name(&name) { + crate::grok::walk_cwd_dir(&p, &mut |f| cb(f, String::new(), &id, &label)); + } else if p.is_dir() { + crate::codex::walk_sessions(&p, |f| cb(f, String::new(), &id, &label)); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p, String::new(), &id, &label); + } + } + } + } + // Copilot session logs (flat .jsonl + /events.jsonl). + if let Some(ss) = sibling_dir(&root, "session-state") { + crate::copilot::walk(&ss, &mut |f| cb(f, String::new(), &id, &label)); + } + // Antigravity conversations (one SQLite per session). + if let Some(cd) = sibling_dir(&root, "conversations") { + crate::antigravity::walk(&cd, &mut |f| cb(f, String::new(), &id, &label)); + } + } +} diff --git a/src-tauri/src/history/search.rs b/src-tauri/src/history/search.rs new file mode 100644 index 0000000..fd3fa91 --- /dev/null +++ b/src-tauri/src/history/search.rs @@ -0,0 +1,72 @@ +use serde_json::Value; +use std::path::PathBuf; + +use super::foreign::is_session_deleted; +use super::jsonl::created_ms; +use super::list::list_sessions; +use super::searchscan::scan_session; +use super::TRASH_ID; + +/// Content search over the same candidate set (and dir/trash scoping) as the list view, newest +/// first. Returns [{ file, agent, agentType?, snippet, count }] for up to `limit` sessions. +pub fn search_sessions(config: &Value, active: &str, query: &str, limit: usize) -> Vec { + let q = query.trim(); + if q.is_empty() { + return vec![]; + } + let trash = active == TRASH_ID; + // The raw-bytes prefilter only applies to queries whose every byte is guaranteed to appear + // verbatim in the file's JSON encoding: printable ASCII minus the chars JSON escapes + // (quote/backslash/control). Non-ASCII stays OFF the prefilter — some producers (e.g. + // Python's json.dumps default) escape it as \uXXXX, which a byte scan would miss; those + // queries always take the parse+extract path (cached, so paid once per file version). + let raw_safe = q.bytes().all(|b| b.is_ascii() && b != b'"' && b != b'\\' && b >= 0x20); + // Reuse the list's pre-limit canonical-thread dedupe, directory/trash scope, and ordering. + // Otherwise duplicate physical rollouts could consume the 600-file search window even though + // the sidebar shows only their selected representative. + let files: Vec<(PathBuf, f64)> = list_sessions(config, active, 600) + .into_iter() + .filter_map(|session| { + let file = PathBuf::from(session.get("file")?.as_str()?); + let created = session + .get("createdAt") + .and_then(Value::as_f64) + .unwrap_or_else(|| created_ms(&file)); + Some((file, created)) + }) + .collect(); + // One batch helper call instead of a spawn per protected qoder file inside the worker loop + // (repeat scans of unchanged files are then served by the extraction + helper caches). + let qoder_files: Vec = files + .iter() + .map(|(file, _)| file.clone()) + .filter(|file| crate::qoder::looks_qoder_path(file)) + .collect(); + crate::qoder::prefetch(&qoder_files); + let hits = std::sync::Mutex::new(Vec::<(f64, Value)>::new()); + let next = std::sync::atomic::AtomicUsize::new(0); + let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8); + std::thread::scope(|s| { + for _ in 0..workers { + s.spawn(|| loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if i >= files.len() { + break; + } + let (file, ct) = &files[i]; + if is_session_deleted(file) != trash { + continue; + } + if let Some(hit) = scan_session(file, q, raw_safe) { + if let Ok(mut h) = hits.lock() { + h.push((*ct, hit)); + } + } + }); + } + }); + let mut hits = hits.into_inner().unwrap_or_else(|e| e.into_inner()); + hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + hits.truncate(limit); + hits.into_iter().map(|(_, v)| v).collect() +} diff --git a/src-tauri/src/history/searchfmt.rs b/src-tauri/src/history/searchfmt.rs new file mode 100644 index 0000000..8a1a567 --- /dev/null +++ b/src-tauri/src/history/searchfmt.rs @@ -0,0 +1,136 @@ +/// Mirror of the renderer's formatCodexBootstrap (conversations.js / runtime.js): Codex records +/// its initial AGENTS.md instructions + environment snapshot as one XML-ish user text block, and +/// the panel renders it as compact Markdown — search must index that same Markdown, not the raw +/// transport shape. None = not a bootstrap message (ordinary prose passes through untouched). +fn format_codex_bootstrap(source: &str) -> Option { + static AGENTS_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ENV_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static ROOT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let agents_re = AGENTS_RE.get_or_init(|| { + regex::Regex::new( + r"(?is)^\s*#\s+AGENTS\.md instructions for ([^\r\n]+).*?]*>(.*?)", + ) + .unwrap() + }); + let env_re = ENV_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>(.*?)").unwrap() + }); + let root_re = + ROOT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); + let agents = agents_re.captures(source)?; + + // Dynamic per-name regexes are fine here: at most one bootstrap message exists per session. + let tag = |block: &str, name: &str| -> String { + regex::Regex::new(&format!(r"(?is)<{name}\b[^>]*>(.*?)")) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let attr = |block: &str, name: &str, attribute: &str| -> String { + regex::Regex::new(&format!(r#"(?i)<{name}\b[^>]*\b{attribute}=["']([^"']+)["']"#)) + .ok() + .and_then(|re| re.captures(block).and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))) + .unwrap_or_default() + }; + let code = |value: &str| -> String { + if value.is_empty() { String::new() } else { format!("`{}`", value) } + }; + + let mut parts: Vec = + vec![format!("# AGENTS.md instructions for {}", agents.get(1).map(|m| m.as_str().trim()).unwrap_or(""))]; + let instructions = agents.get(2).map(|m| m.as_str().trim()).unwrap_or(""); + if !instructions.is_empty() { + let lines: Vec<&str> = instructions.lines().filter(|line| !line.trim().is_empty()).collect(); + parts.push(if lines.len() == 1 { + format!("**INSTRUCTIONS:** {}", lines[0].trim()) + } else { + format!("**INSTRUCTIONS:**\n\n{}", instructions) + }); + } + + let env = env_re.captures(source); + if let Some(env) = &env { + let block = env.get(1).map(|m| m.as_str()).unwrap_or(""); + let roots: Vec = root_re + .captures_iter(block) + .filter_map(|c| c.get(1).map(|m| m.as_str().trim().to_string())) + .filter(|r| !r.is_empty()) + .map(|r| code(&r)) + .collect(); + let fields: Vec<(&str, String)> = vec![ + ("environment_context", code(&tag(block, "cwd"))), + ("shell", tag(block, "shell")), + ("current_date", tag(block, "current_date")), + ("timezone", tag(block, "timezone")), + ("workspace_roots", roots.join(", ")), + ("permission_profile", attr(block, "permission_profile", "type")), + ("file_system", attr(block, "file_system", "type")), + ] + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .collect(); + if !fields.is_empty() { + parts.push( + fields + .iter() + .map(|(name, value)| format!("**{}:** {}", name, value)) + .collect::>() + .join(" \n"), + ); + } + } + + let mut rest = source.replacen(agents.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + if let Some(env) = &env { + rest = rest.replacen(env.get(0).map(|m| m.as_str()).unwrap_or(""), "", 1); + } + let rest = rest.trim(); + if !rest.is_empty() { + parts.push(rest.to_string()); + } + Some(parts.join("\n\n").trim().to_string()) +} + +/// Strip harness-injected blocks from user prose — MUST stay rule-for-rule in sync with the +/// renderer's stripInjected (conversations.js) and the export viewer's copy (runtime.js), so what +/// the big search matches is exactly what the in-conversation search (and the panel) will show. +/// The Codex AGENTS bootstrap reformats to the same Markdown the panel renders; task-notification +/// envelopes keep their human-facing body; the transport metadata (ids, status, summary) +/// is dropped and must therefore never be searchable. +pub(super) fn strip_injected(s: &str) -> String { + static SKILL_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static TASK_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static RESULT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + // Same rule order as the JS: the Codex AGENTS bootstrap is reformatted FIRST, then the + // envelope rules run over the (possibly rewritten) text. + let bootstrap = format_codex_bootstrap(s); + let s: &str = bootstrap.as_deref().unwrap_or(s); + // A turn that is nothing but a envelope is Codex's recorded skill-instruction + // injection — runtime context the panel suppresses wholesale, so search must too. Prose that + // merely quotes markup alongside other text stays searchable. + let skill_re = SKILL_RE + .get_or_init(|| regex::Regex::new(r"(?is)^\s*]*>.*\s*$").unwrap()); + if skill_re.is_match(s) { + return String::new(); + } + let task_re = TASK_RE.get_or_init(|| { + regex::Regex::new(r"(?is)]*>.*?").unwrap() + }); + let result_re = + RESULT_RE.get_or_init(|| regex::Regex::new(r"(?is)]*>(.*?)").unwrap()); + let re = RE.get_or_init(|| { + regex::Regex::new( + r"(?s).*?|.*?|.*?", + ) + .unwrap() + }); + let unwrapped = task_re.replace_all(s, |caps: ®ex::Captures| { + result_re + .captures(caps.get(0).map(|m| m.as_str()).unwrap_or("")) + .and_then(|c| c.get(1)) + .map(|m| format!("\n{}\n", m.as_str().trim())) + .unwrap_or_default() + }); + re.replace_all(&unwrapped, "").trim().to_string() +} diff --git a/src-tauri/src/history/searchscan.rs b/src-tauri/src/history/searchscan.rs new file mode 100644 index 0000000..9aa4bc1 --- /dev/null +++ b/src-tauri/src/history/searchscan.rs @@ -0,0 +1,157 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::foreign::{cache_stamp_ms, foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_session_text}; +use super::norm::shape_messages; +use super::searchtext::{extract_search_text, icount, ifind}; +use super::subagents::subagent_dir; + +struct SearchCache { + map: std::collections::HashMap)>, + bytes: usize, +} +/// Extracted-text memo, keyed path -> (mtime, size, text). Cleared wholesale past the byte budget +/// (crude but safe — the next search simply re-extracts what it touches). +fn search_cache() -> &'static std::sync::Mutex { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(SearchCache { map: std::collections::HashMap::new(), bytes: 0 })) +} +const SEARCH_CACHE_BUDGET: usize = 128 * 1024 * 1024; + +/// Search one transcript file for `q`: (extracted text, first-match byte offset), or None. +/// Serves from the extraction cache when fresh; otherwise prefilters the raw bytes and only +/// parses candidates — files that can't match are neither parsed nor cached. +fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc, usize)> { + let meta = fs::metadata(path).ok()?; + let (mt, sz) = (cache_stamp_ms(path), meta.len()); + if let Ok(cache) = search_cache().lock() { + if let Some((cmt, csz, text)) = cache.map.get(path) { + if *cmt == mt && *csz == sz { + let t = text.clone(); + drop(cache); + return ifind(&t, q, 0).map(|p| (t, p)); + } + } + } + let fk = foreign_kind(path); + let messages: Vec = if fk == Some(Foreign::Antigravity) { + // SQLite source: no raw-bytes prefilter (the payloads are binary) — extraction is + // cached, so the decode is paid once per file version. + crate::antigravity::normalize_db(path).messages + } else { + let raw = read_session_text(path).ok()?; + if raw_safe && ifind(&raw, q, 0).is_none() { + return None; + } + let parsed = parse_lines(&raw); + let recs = if crate::qoder::looks_qoder_path(path) { + crate::qoder::normalize_records(&parsed) + } else { + parsed + }; + match fk { + Some(Foreign::Grok) => crate::grok::normalize(&recs, None).messages, + Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, + _ => { + if crate::codex::looks_codex(&recs) { + crate::codex::session_from_recs(&path.to_string_lossy(), &recs) + .get("messages") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + } else { + shape_messages(&recs).messages + } + } + } + }; + let text = std::sync::Arc::new(extract_search_text(&messages)); + if let Ok(mut cache) = search_cache().lock() { + if cache.bytes + text.len() > SEARCH_CACHE_BUDGET { + cache.map.clear(); + cache.bytes = 0; + } + if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, sz, text.clone())) { + cache.bytes = cache.bytes.saturating_sub(old.len()); // replaced a stale entry + } + cache.bytes += text.len(); + } + ifind(&text, q, 0).map(|p| (text, p)) +} + +/// Display snippet around the first match: ~56 chars of context either side, whitespace collapsed, +/// ellipsized at cut edges. Slice bounds snap outward/inward to char boundaries. +fn snippet_around(text: &str, pos: usize, match_len: usize) -> String { + const CTX: usize = 56; + let mut start = pos.saturating_sub(CTX); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + let mut end = (pos + match_len + CTX).min(text.len()); + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + let body = text[start..end].split_whitespace().collect::>().join(" "); + format!("{}{}{}", if start > 0 { "…" } else { "" }, body, if end < text.len() { "…" } else { "" }) +} + +/// Scan one session — main thread first, then each subagent transcript — and shape the hit the +/// renderer needs to auto-locate: which agent matched, a snippet, and the occurrence count. +pub(super) fn scan_session(file: &Path, q: &str, raw_safe: bool) -> Option { + if let Some((text, pos)) = thread_scan(file, q, raw_safe) { + return Some(json!({ + "file": file.to_string_lossy(), + "agent": "main", + "snippet": snippet_around(&text, pos, q.len()), + "count": icount(&text, q), + })); + } + let dir = subagent_dir(file)?; + let mut names: Vec = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let name = ent.file_name().to_string_lossy().into_owned(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + names.push(name); + } + } + } + names.sort(); + for name in names { + if let Some((text, pos)) = thread_scan(&dir.join(&name), q, raw_safe) { + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl").to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if crate::qoder::looks_qoder_path(file) { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + // Key by the spawning tool_use id — the same key read_subagents uses, so the renderer + // can switch its panel straight to this agent. + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + let agent_type = meta + .get("agentType") + .and_then(|v| v.as_str()) + .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) + .unwrap_or("agent"); + return Some(json!({ + "file": file.to_string_lossy(), + "agent": key, + "agentType": agent_type, + "snippet": snippet_around(&text, pos, q.len()), + "count": icount(&text, q), + })); + } + } + None +} diff --git a/src-tauri/src/history/searchtext.rs b/src-tauri/src/history/searchtext.rs new file mode 100644 index 0000000..397ab44 --- /dev/null +++ b/src-tauri/src/history/searchtext.rs @@ -0,0 +1,130 @@ +// ---- content search (the session list's "big search") ---- +// +// Scans session CONTENT (message text / thinking / tool calls + results) across every listed +// session — main threads, their subagent transcripts, and Codex rollouts — and reports, per +// matching session, WHERE the first match lives ("main" or a subagent's tool_use key) plus a +// display snippet. The renderer opens the session, switches the panel to that agent, and +// re-finds the query locally, so list hits and in-conversation positioning stay aligned. +// +// Performance model (this runs per keystroke, debounced): +// - extraction cache: path -> (mtime, size, extracted text), so repeated queries pay the JSON +// parse + shaping once per file version; +// - raw prefilter: on a cache miss the raw JSONL bytes are substring-scanned first, and only +// files that could match are parsed at all (JSON escapes quotes/backslashes/control chars, +// so the prefilter is skipped for queries containing those); +// - parallel scan: per-file work fans out over a small thread pool. + +use serde_json::Value; + +use super::searchfmt::strip_injected; + +/// ASCII-case-insensitive substring search (byte-wise; non-ASCII must match exactly — CJK has no +/// case). A valid-UTF-8 needle can only match at char boundaries of valid-UTF-8 text (ASCII bytes +/// never equal continuation bytes), so the returned byte offset is safe to slice on. +pub(super) fn ifind(hay: &str, needle: &str, from: usize) -> Option { + let h = hay.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || h.len() < n.len() { + return None; + } + let last = h.len() - n.len(); + let n0 = n[0].to_ascii_lowercase(); + let mut i = from; + while i <= last { + if h[i].to_ascii_lowercase() == n0 { + let mut k = 1; + while k < n.len() && h[i + k].to_ascii_lowercase() == n[k].to_ascii_lowercase() { + k += 1; + } + if k == n.len() { + return Some(i); + } + } + i += 1; + } + None +} + +/// Non-overlapping case-insensitive occurrence count (same fold as ifind). +pub(super) fn icount(hay: &str, needle: &str) -> usize { + let (mut i, mut c) = (0usize, 0usize); + while let Some(p) = ifind(hay, needle, i) { + c += 1; + i = p + needle.len().max(1); + } + c +} + +fn tool_result_search_text(c: &Value) -> String { + if let Some(s) = c.as_str() { + return s.to_string(); + } + if let Some(arr) = c.as_array() { + return arr + .iter() + .filter_map(|x| x.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"); + } + String::new() +} + +/// One searchable text blob for a shaped message list — the renderer's messagePlainText, flattened: +/// user prose (injected blocks stripped), assistant text, thinking, tool name + input JSON, and +/// tool results. Images and raw structure are skipped so a hit here is findable in the panel. +pub(super) fn extract_search_text(messages: &[Value]) -> String { + let mut out = String::new(); + let mut push = |t: &str| { + if !t.is_empty() { + out.push_str(t); + out.push('\n'); + } + }; + for m in messages { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or(""); + let content = match m.get("content") { + Some(c) => c, + None => continue, + }; + if let Some(s) = content.as_str() { + if role == "user" { + push(&strip_injected(s)); + } else { + push(s); + } + continue; + } + let arr = match content.as_array() { + Some(a) => a, + None => continue, + }; + for b in arr { + match b.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "text" => { + let t = b.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if role == "user" { + push(&strip_injected(t)); + } else { + push(t); + } + } + "thinking" => push(b.get("thinking").and_then(|v| v.as_str()).unwrap_or("")), + "skill_load" => { + push(b.get("name").and_then(Value::as_str).unwrap_or("")); + push(b.get("path").and_then(Value::as_str).unwrap_or("")); + push(b.get("snapshot").and_then(Value::as_str).unwrap_or("")); + } + "tool_use" => { + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = b.get("input").map(|i| i.to_string()).unwrap_or_default(); + push(&format!("{} {}", name, input)); + } + "tool_result" => { + push(&tool_result_search_text(b.get("content").unwrap_or(&Value::Null))) + } + _ => {} + } + } + } + out +} diff --git a/src-tauri/src/history/selftest.rs b/src-tauri/src/history/selftest.rs new file mode 100644 index 0000000..9f0925d --- /dev/null +++ b/src-tauri/src/history/selftest.rs @@ -0,0 +1,97 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::edit::set_ccbud; +use super::importpaths::{import_paths, remove_import}; +use super::list::list_sessions; +use super::paths::imports_root; +use super::session::get_session; +use super::subagents::export_bundle; +use super::TRASH_ID; + +/// Self-contained round-trip test of set_ccbud + get_session in a throwaway projects tree. +pub fn history_selftest(base_dir: &Path) -> Value { + let proj = base_dir.join("test-claude").join("projects").join("-test-cwd"); + let _ = fs::create_dir_all(&proj); + let file = proj.join("sess1.jsonl"); + let content = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hello world from selfcheck\"},\"cwd\":\"/test/cwd\",\"sessionId\":\"sess1\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"; + let _ = fs::write(&file, content); + let config = json!({ "historyDirs": [ base_dir.join("test-claude").to_string_lossy() ] }); + let fpath = file.to_string_lossy().to_string(); + let set = set_ccbud(&fpath, &json!({ "title": "My Title", "tags": ["a", "b", "b"] }), &config); + let sess = get_session(&fpath); + let title = sess.get("meta").and_then(|m| m.get("title")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let tags = sess.get("meta").and_then(|m| m.get("tags")).and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + let auto = sess.get("meta").and_then(|m| m.get("autoTitle")).and_then(|v| v.as_str()).unwrap_or("").to_string(); + // Soft-delete round-trip: marked → hidden from "all" but present in trash → restored → back in "all". + let _ = set_ccbud(&fpath, &json!({ "delete": true }), &config); + let after_del = get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); + let hidden_in_all = !list_sessions(&config, "all", 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); + let shown_in_trash = list_sessions(&config, TRASH_ID, 50).iter().any(|s| s.get("file").and_then(|v| v.as_str()) == Some(fpath.as_str())); + let _ = set_ccbud(&fpath, &json!({ "delete": false }), &config); + let restored = !get_session(&fpath).get("meta").and_then(|m| m.get("deleted")).and_then(|v| v.as_bool()).unwrap_or(false); + json!({ + "setOk": set.get("ok").and_then(|v| v.as_bool()).unwrap_or(false), + "title": title, + "tagCount": tags, + "autoTitle": auto, + "deletedAfterMark": after_del, + "hiddenInAll": hidden_in_all, + "shownInTrash": shown_in_trash, + "restored": restored, + }) +} + +/// Self-contained test of import → list-as-imported → re-import-skip → remove. +pub fn import_selftest(base_dir: &Path) -> Value { + std::env::set_var("CCBUD_HOME", base_dir); // imports_root() honors CCBUD_HOME + let src_dir = base_dir.join("import-src"); + let _ = fs::create_dir_all(&src_dir); + let src = src_dir.join("foreign.jsonl"); + let _ = fs::write(&src, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"imported hello\"},\"cwd\":\"/imp/cwd\",\"sessionId\":\"impsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); + let srcs = vec![src.to_string_lossy().to_string()]; + let r = import_paths(&srcs); + let r2 = import_paths(&srcs); + let config = json!({ "historyDirs": ["~/.claude"] }); + let sessions = list_sessions(&config, "__imported__", 50); + let found = sessions.iter().any(|s| { + s.get("imported").and_then(|v| v.as_bool()).unwrap_or(false) + && s.get("title").and_then(|v| v.as_str()) == Some("imported hello") + }); + let dest = imports_root().join("projects").join("-imp-cwd").join("impsess.jsonl"); + let rm = remove_import(&dest.to_string_lossy()); + + // ---- bundle round-trip: a session WITH subagents exports as a .zip and re-imports with its + // subagent transcripts restored (the export → import path the 对话 view drives). ---- + let bproj = base_dir.join("bundle-src").join("projects").join("-bnd-cwd"); + let _ = fs::create_dir_all(&bproj); + let bmain = bproj.join("bundsess.jsonl"); + let _ = fs::write(&bmain, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n"); + let bsub = bproj.join("bundsess").join("subagents"); + let _ = fs::create_dir_all(&bsub); + let _ = fs::write(bsub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n"); + let _ = fs::write(bsub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}"); + let zip = export_bundle(&bmain.to_string_lossy()).unwrap_or_default(); + let zip_is_zip = zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]); + let zip_path = base_dir.join("bundle-src").join("bundsess.zip"); + let _ = fs::write(&zip_path, &zip); + let rb = import_paths(&[zip_path.to_string_lossy().to_string()]); + let imp_dir = imports_root().join("projects").join("-bnd-cwd"); + let sub_restored = imp_dir.join("bundsess").join("subagents").join("agent-b1.jsonl").exists() + && imp_dir.join("bundsess").join("subagents").join("agent-b1.meta.json").exists(); + let bundle_sess = get_session(&imp_dir.join("bundsess.jsonl").to_string_lossy()); + let bundle_sub_count = bundle_sess.get("meta").and_then(|m| m.get("subagentCount")).and_then(|v| v.as_i64()).unwrap_or(0); + + json!({ + "imported": r.get("imported"), + "reskipped": r2.get("skipped"), + "appearsImported": found, + "removed": rm.get("ok"), + "gone": !dest.exists(), + "bundleZip": zip_is_zip, + "bundleImported": rb.get("imported"), + "bundleSubRestored": sub_restored, + "bundleSubagentCount": bundle_sub_count, + }) +} diff --git a/src-tauri/src/history/session.rs b/src-tauri/src/history/session.rs new file mode 100644 index 0000000..7d3965d --- /dev/null +++ b/src-tauri/src/history/session.rs @@ -0,0 +1,148 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::foreign::{foreign_kind, Foreign}; +use super::jsonl::{parse_lines, read_session_text, session_read_error}; +use super::norm::shape_messages; +use super::paths::base_name; +use super::skills::{apply_skill_names, skill_from_recs}; +use super::subagents::read_subagents; +use super::text::{first_user_text, read_ccbud}; + +/// Read the import provenance sidecar (`.import.json`) for an imported transcript. +pub(crate) fn read_import_meta(file: &str) -> Option { + let p = Path::new(file); + let stem = p.file_stem().and_then(|s| s.to_str())?; + let dir = p.parent()?; + let raw = fs::read_to_string(dir.join(format!("{}.import.json", stem))).ok()?; + serde_json::from_str(&raw).ok() +} + +pub fn get_session(file: &str) -> Value { + let path = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(path); + // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are + // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. + match foreign_kind(path) { + Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), + Some(fk) => { + let raw = match read_session_text(path) { + Ok(s) => s, + Err(error) => return session_read_error(path, &error), + }; + let recs = parse_lines(&raw); + return match fk { + Foreign::Grok => crate::grok::session_from_recs(file, &recs), + _ => crate::copilot::session_from_recs(file, &recs), + }; + } + None => {} + } + let raw = match read_session_text(path) { + Ok(s) => s, + Err(error) => return session_read_error(path, &error), + }; + let parsed_recs = parse_lines(&raw); + if crate::codex::looks_codex(&parsed_recs) { + return crate::codex::session_from_recs(file, &parsed_recs); + } + let qoder_title = if qoder { + crate::qoder::session_title_from(&parsed_recs) + } else { + None + }; + let qoder_cwd = if qoder { + crate::qoder::working_dir_from(&parsed_recs) + } else { + None + }; + let qoder_model = if qoder { + crate::qoder::model_from(&parsed_recs) + } else { + None + }; + let recs = if qoder { + crate::qoder::normalize_records(&parsed_recs) + } else { + parsed_recs + }; + let meta_rec = recs + .iter() + .find(|r| r.get("cwd").is_some()) + .or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); + let agent_rec = recs.iter().find(|r| r.get("agentId").is_some()); + let agent_id = agent_rec + .and_then(|r| r.get("agentId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let summary = recs + .iter() + .find(|r| r.get("type").and_then(|v| v.as_str()) == Some("summary") && r.get("summary").is_some()) + .and_then(|r| r.get("summary").cloned()); + // Qoder detail mirrors build_session_meta: normalized atomic wrappers, inline metadata, and + // app-owned title/tags/delete overrides without rewriting another CLI's transcript. + let (cc_title, cc_tags, cc_deleted) = + if qoder { crate::qoder::sidecar_meta(path) } else { read_ccbud(&recs) }; + let shaped = shape_messages(&recs); + let auto_title = qoder_title.unwrap_or_else(|| first_user_text(&shaped.messages)); + let subagent = agent_rec.is_some(); + let top_level_cwd = meta_rec + .and_then(|r| r.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let cwd = if qoder { + qoder_cwd.or(top_level_cwd) + } else { + top_level_cwd + }; + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let base_id = meta_rec + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + .unwrap_or(&stem) + .to_string(); + // A subagent session's id carries the agent suffix; only a top-level session embeds subagents. + let sess_id = match (subagent, &agent_id) { + (true, Some(aid)) => format!("{}-{}", base_id, aid), + _ => base_id.clone(), + }; + let mut subs = if subagent { serde_json::Map::new() } else { read_subagents(file) }; + apply_skill_names(&shaped.messages, &mut subs); + // Live Qoder files are never imported snapshots; avoid probing a protected sibling sidecar. + let import_meta = if qoder { None } else { read_import_meta(file) }; + + json!({ + "meta": { + "id": if qoder { format!("qoder:{}", stem) } else { format!("disk:{}{}", stem, if subagent { ":sub" } else { "" }) }, + "file": file, + "source": if qoder { "qoder" } else { "disk" }, + // Renderer falls back to Claude when null (the app's home turf carries no label). + "assistant": if qoder { json!("Qoder") } else { Value::Null }, + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": summary, + "sessionId": sess_id, + "cwd": cwd.clone(), + "project": cwd.as_deref().map(base_name).unwrap_or_default(), + "gitBranch": meta_rec.and_then(|r| r.get("gitBranch")).cloned().unwrap_or(Value::Null), + "version": meta_rec.and_then(|r| r.get("version")).cloned().unwrap_or(Value::Null), + "isSubagent": subagent, + // A standalone subagent transcript self-reports its invoking skill via the sentinel. + "skill": if subagent { skill_from_recs(&recs) } else { None:: }, + "deleted": cc_deleted, + "imported": import_meta.is_some(), + "importedFrom": import_meta.as_ref().and_then(|m| m.get("originalPath")).cloned().unwrap_or(Value::Null), + "importedAt": import_meta.as_ref().and_then(|m| m.get("importedAt")).cloned().unwrap_or(Value::Null), + "model": qoder_model.or(shaped.model), + "totals": shaped.totals, + "messages": shaped.messages.len(), + "subagentCount": subs.len(), + "firstTs": shaped.first_ts, + "lastTs": shaped.last_ts, + }, + "messages": shaped.messages, + "subagents": subs, + }) +} diff --git a/src-tauri/src/history/skills.rs b/src-tauri/src/history/skills.rs new file mode 100644 index 0000000..4e0ca1e --- /dev/null +++ b/src-tauri/src/history/skills.rs @@ -0,0 +1,68 @@ +use serde_json::{json, Value}; + +use super::text::content_text; + +/// A skill-forked subagent transcript opens with a sentinel user line +/// "Base directory for this skill: /" — the last path segment names the skill. +/// Fallback attribution only: the spawning `Skill` tool_use in the parent thread +/// (apply_skill_names) is authoritative and overrides this when present. (history.js skillFromRecs) +const SKILL_BASE_DIR_PREFIX: &str = "Base directory for this skill: "; +pub(crate) fn skill_from_recs(recs: &[Value]) -> Option { + let first = recs.iter().find(|r| { + r.get("type").and_then(|v| v.as_str()) == Some("user") + && r.get("message").is_some() + && !r.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) + })?; + let text = content_text(first.get("message")?.get("content").unwrap_or(&Value::Null)); + // Only the opening prompt carries the sentinel — don't scan further user turns. + let rest = text.trim().strip_prefix(SKILL_BASE_DIR_PREFIX)?; + let line = rest.lines().next().unwrap_or("").trim(); + line.split(['/', '\\']).filter(|s| !s.is_empty()).last().map(|s| s.to_string()) +} + +/// Primary skill attribution (history.js applySkillNames): a subagent spawned by the `Skill` tool +/// is named by the spawning tool_use's input.skill (matched by tool_use id — the subagents map +/// key), in whichever thread the call lives (main or a nested subagent). Overrides the sentinel +/// fallback from skill_from_recs. +pub(crate) fn apply_skill_names(main_messages: &[Value], subs: &mut serde_json::Map) { + if subs.is_empty() { + return; + } + fn scan(msgs: &[Value], subs: &serde_json::Map, out: &mut Vec<(String, String)>) { + for m in msgs { + let blocks = match m.get("content").and_then(|c| c.as_array()) { + Some(b) => b, + None => continue, + }; + for b in blocks { + if b.get("type").and_then(|v| v.as_str()) != Some("tool_use") + || b.get("name").and_then(|v| v.as_str()) != Some("Skill") + { + continue; + } + let id = match b.get("id").and_then(|v| v.as_str()) { + Some(i) if subs.contains_key(i) => i, + _ => continue, + }; + if let Some(s) = b.get("input").and_then(|i| i.get("skill")).and_then(|v| v.as_str()) { + let s = s.trim(); + if !s.is_empty() { + out.push((id.to_string(), s.to_string())); + } + } + } + } + } + let mut named: Vec<(String, String)> = vec![]; + scan(main_messages, subs, &mut named); + for (_, v) in subs.iter() { + if let Some(msgs) = v.get("messages").and_then(|m| m.as_array()) { + scan(msgs, subs, &mut named); + } + } + for (id, name) in named { + if let Some(o) = subs.get_mut(&id).and_then(|s| s.as_object_mut()) { + o.insert("skill".into(), json!(name)); + } + } +} diff --git a/src-tauri/src/history/subagents.rs b/src-tauri/src/history/subagents.rs new file mode 100644 index 0000000..3ec5223 --- /dev/null +++ b/src-tauri/src/history/subagents.rs @@ -0,0 +1,181 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::jsonl::{parse_lines, read_session_bytes, read_session_text}; +use super::norm::shape_messages; +use super::skills::skill_from_recs; + +/// Read a session's child subagent dialogues from `/subagents/agent-*.jsonl` (+ .meta.json), +/// keyed by the spawning tool_use id so the renderer can nest them. {} when none. (history.js readSubagents) +pub(super) fn read_subagents(file: &str) -> serde_json::Map { + let p = Path::new(file); + let qoder = crate::qoder::looks_qoder_path(p); + let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let dir = match p.parent() { + Some(d) => d.join(stem).join("subagents"), + None => return serde_json::Map::new(), + }; + let mut by_tool = serde_json::Map::new(); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return by_tool, + }; + let mut agent_files: Vec<(String, PathBuf)> = vec![]; + for ent in entries.flatten() { + let name = ent.file_name().to_string_lossy().to_string(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + agent_files.push((name, ent.path())); + } + } + // A protected qoder session's subagent transcripts + meta sidecars warm in one helper batch + // instead of two spawns per agent. + if qoder { + let mut warm: Vec = vec![]; + for (name, path) in &agent_files { + warm.push(path.clone()); + let agent_id = name.trim_start_matches("agent-").trim_end_matches(".jsonl"); + warm.push(dir.join(format!("agent-{}.meta.json", agent_id))); + } + crate::qoder::prefetch(&warm); + } + for (name, transcript_path) in agent_files { + let agent_id = name + .trim_start_matches("agent-") + .trim_end_matches(".jsonl") + .to_string(); + let meta_path = dir.join(format!("agent-{}.meta.json", agent_id)); + let meta_raw = if qoder { + crate::qoder::read_text(&meta_path) + } else { + fs::read_to_string(&meta_path) + }; + let meta: Value = meta_raw + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + let raw = match read_session_text(&transcript_path) { + Ok(s) => s, + Err(_) => continue, + }; + let parsed = parse_lines(&raw); + let recs = if qoder { + crate::qoder::normalize_records(&parsed) + } else { + parsed + }; + let shaped = shape_messages(&recs); + let key = meta + .get("toolUseId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("agent:{}", agent_id)); + let agent_type = meta + .get("agentType") + .and_then(|v| v.as_str()) + .or_else(|| meta.get("subagent_type").and_then(|v| v.as_str())) + .unwrap_or("agent"); + by_tool.insert( + key, + json!({ + "agentId": agent_id, + "file": transcript_path.to_string_lossy(), + "type": agent_type, + "description": meta.get("description").and_then(|v| v.as_str()).unwrap_or(""), + "skill": skill_from_recs(&recs), + "count": shaped.messages.len(), + "totals": shaped.totals, + "messages": shaped.messages, + }), + ); + } + by_tool +} + +/// A session's subagents directory: `//subagents`. None when the path has no stem. +pub(super) fn subagent_dir(file: &Path) -> Option { + let stem = file.file_stem().and_then(|s| s.to_str())?; + file.parent().map(|d| d.join(stem).join("subagents")) +} + +/// The raw subagent sidecar files for a session — `(agent-*.jsonl | agent-*.meta.json, bytes)`. +/// Empty when the session spawned no subagents. Shared by bundle export, import, and replay-merge. +pub(super) fn read_subagent_files(file: &Path) -> Vec<(String, Vec)> { + let dir = match subagent_dir(file) { + Some(d) => d, + None => return vec![], + }; + let qoder = crate::qoder::looks_qoder_path(file); + let mut out = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_file() { + continue; + } + let name = ent.file_name().to_string_lossy().into_owned(); + let lower = name.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + let bytes = if qoder { + crate::qoder::read_bytes(&p) + } else { + fs::read(&p) + }; + if let Ok(bytes) = bytes { + out.push((name, bytes)); + } + } + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic bundle order + out +} + +/// Whether a session has any subagent transcripts (drives export → .zip vs plain .jsonl). +pub fn session_has_subagents(file: &str) -> bool { + !read_subagent_files(Path::new(file)).is_empty() +} + +/// Build a conversation-bundle ZIP: the main session `.jsonl` at the top level and each +/// subagent file under `subagents/`. Caller uses this only when the session actually has subagents +/// (a plain .jsonl export otherwise). Round-trips through import_zip / splitBundle. +pub fn export_bundle(file: &str) -> std::io::Result> { + let path = Path::new(file); + let main = read_session_bytes(path)?; + let main_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("conversation.jsonl") + .to_string(); + let mut entries = vec![crate::ziputil::Entry { name: main_name, data: main }]; + for (name, bytes) in read_subagent_files(path) { + entries.push(crate::ziputil::Entry { name: format!("subagents/{}", name), data: bytes }); + } + Ok(crate::ziputil::build(&entries)) +} + +/// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. +/// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is +/// attached alongside the main session in the Cowork deep link (which takes a repeated `file=` param), +/// so the analysis covers subagent runs — not just the main thread. +pub fn subagent_transcript_paths(file: &str) -> Vec { + let dir = match subagent_dir(Path::new(file)) { + Some(d) => d, + None => return vec![], + }; + let mut out = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_file() { + continue; + } + let name = ent.file_name().to_string_lossy().to_lowercase(); + if name.starts_with("agent-") && name.ends_with(".jsonl") { + out.push(p.to_string_lossy().into_owned()); + } + } + } + out.sort(); + out +} diff --git a/src-tauri/src/history/tests_foreign.rs b/src-tauri/src/history/tests_foreign.rs new file mode 100644 index 0000000..fa53494 --- /dev/null +++ b/src-tauri/src/history/tests_foreign.rs @@ -0,0 +1,150 @@ +use serde_json::json; +use std::fs; +use std::path::Path; + +use super::edit::delete_session_file; +use super::list::list_sessions; +use super::search::search_sessions; +use super::session::get_session; + +// One work dir carrying ALL foreign layouts (grok sessions/%2F…, copilot session-state/, +// antigravity conversations/*.db): each session must list under its own source with cwd, +// title and detail routed through its shaper, hard-delete must refuse, and content search +// must reach every format. +#[test] +fn foreign_sources_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-foreign-route-test"); + let _ = fs::remove_dir_all(&base); + + // grok: sessions///chat_history.jsonl + summary.json + let gdir = base.join("sessions").join("%2Ftmp%2Fgproj").join("0199-grok-uuid"); + fs::create_dir_all(&gdir).unwrap(); + fs::write( + gdir.join("chat_history.jsonl"), + "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"grok needle walrus\"}]}\n\ + {\"type\":\"assistant\",\"content\":\"done\",\"tool_calls\":[{\"id\":\"c1\",\"name\":\"run_terminal_command\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}]}\n", + ) + .unwrap(); + fs::write( + gdir.join("summary.json"), + "{\"info\":{\"id\":\"0199-grok-uuid\",\"cwd\":\"/tmp/gproj\"},\"generated_title\":\"Grok 会话\",\"created_at\":\"2026-06-18T06:27:07.777Z\",\"current_model_id\":\"grok-build\"}", + ) + .unwrap(); + // …and a stray sidecar jsonl the codex walker must NOT sweep into a session row + fs::write(gdir.join("events.jsonl"), "{\"ts\":\"x\",\"type\":\"mcp_config_resolved\"}\n").unwrap(); + + // copilot: session-state//events.jsonl + workspace.yaml + let cdir = base.join("session-state").join("cp-uuid-1"); + fs::create_dir_all(&cdir).unwrap(); + fs::write( + cdir.join("events.jsonl"), + "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"cp-uuid-1\",\"context\":{\"cwd\":\"/tmp/cproj\"}},\"timestamp\":\"2026-07-12T07:26:54.363Z\"}\n\ + {\"type\":\"user.message\",\"data\":{\"content\":\"copilot needle pelican\"},\"timestamp\":\"2026-07-12T07:27:14.463Z\"}\n", + ) + .unwrap(); + fs::write( + cdir.join("workspace.yaml"), + "id: cp-uuid-1\ncwd: /tmp/cproj\nname: Copilot 会话\ncreated_at: 2026-07-12T07:26:54.368Z\n", + ) + .unwrap(); + + // antigravity: conversations/.db with one user step (hand-encoded wire format) + let adir = base.join("conversations"); + fs::create_dir_all(&adir).unwrap(); + let adb = adir.join("agy-uuid-1.db"); + { + fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + } + fn put_varint(field: u32, v: u64, out: &mut Vec) { + enc_varint(((field as u64) << 3) | 0, out); + enc_varint(v, out); + } + fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + enc_varint(((field as u64) << 3) | 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); + } + let mut ts = vec![]; + put_varint(1, 1_783_811_237, &mut ts); + let mut meta5 = vec![]; + put_bytes(1, &ts, &mut meta5); + let mut u19 = vec![]; + put_bytes(2, "agy needle capybara".as_bytes(), &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + let conn = rusqlite::Connection::open(&adb).unwrap(); + conn.execute_batch( + "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, step_payload BLOB);", + ) + .unwrap(); + conn.execute("INSERT INTO steps (idx, step_type, status, step_payload) VALUES (0, 14, 3, ?1)", [&step]) + .unwrap(); + } + { + let conn = rusqlite::Connection::open(base.join("conversation_summaries.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '', step_count INTEGER NOT NULL DEFAULT 0, last_modified_time DATETIME, workspace_uris TEXT NOT NULL DEFAULT '[]');", + ) + .unwrap(); + conn.execute( + "INSERT INTO conversation_summaries (conversation_id, title, preview, step_count, workspace_uris) VALUES ('agy-uuid-1', 'Agy 会话', 'p', 1, '[\"file:///tmp/aproj\"]')", + [], + ) + .unwrap(); + } + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + let by = |src: &str| { + rows.iter() + .find(|r| r.get("source").and_then(|v| v.as_str()) == Some(src)) + .unwrap_or_else(|| panic!("no {} row in {:?}", src, rows)) + .clone() + }; + // exactly one row per source — the grok dir's stray events.jsonl must not add a fourth + assert_eq!(rows.len(), 3, "rows: {:?}", rows); + let (g, c, a) = (by("grok"), by("copilot"), by("antigravity")); + assert_eq!(g["cwd"], "/tmp/gproj"); + assert_eq!(g["title"], "Grok 会话"); + assert_eq!(g["model"], "grok-build"); + assert_eq!(c["cwd"], "/tmp/cproj"); + assert_eq!(c["title"], "Copilot 会话"); + assert_eq!(a["cwd"], "/tmp/aproj"); + assert_eq!(a["title"], "Agy 会话"); + + // detail routes through each shaper (assistant name is the renderer's header/stat hook) + for (row, assistant, first_text) in [ + (&g, "Grok", "grok needle walrus"), + (&c, "Copilot", "copilot needle pelican"), + (&a, "Antigravity", "agy needle capybara"), + ] { + let file = row["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], assistant); + assert_eq!(d["messages"][0]["content"][0]["text"], first_text); + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + } + + // content search reaches every format (agy has no raw-text prefilter path) + for needle in ["walrus", "pelican", "capybara"] { + let hits = search_sessions(&config, "all", needle, 10); + assert_eq!(hits.len(), 1, "search {}: {:?}", needle, hits); + } + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/tests_misc.rs b/src-tauri/src/history/tests_misc.rs new file mode 100644 index 0000000..7e7a0f6 --- /dev/null +++ b/src-tauri/src/history/tests_misc.rs @@ -0,0 +1,171 @@ +use serde_json::{json, Value}; +use std::fs; + +use super::edit::{delete_session_file, set_ccbud}; +use super::jsonl::session_read_error; +use super::list::list_sessions; +use super::session::get_session; +use super::subagents::{export_bundle, session_has_subagents, subagent_transcript_paths}; + +#[test] +fn session_read_errors_are_structured() { + let base = std::env::temp_dir().join(format!( + "ccbud-session-read-error-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&base); + let project = base.join(".qoder").join("projects").join("-tmp-error"); + fs::create_dir_all(&project).unwrap(); + + let missing = project.join("missing.jsonl"); + let detail = get_session(&missing.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "notFound"); + + let invalid = project.join("invalid.jsonl"); + fs::write(&invalid, [0xff, 0xfe]).unwrap(); + let detail = get_session(&invalid.to_string_lossy()); + assert_eq!(detail["error"]["kind"], "readFailed"); + + let denied = session_read_error( + &invalid, + &std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + ); + assert_eq!(denied["error"]["kind"], "permissionDenied"); + + let _ = fs::remove_dir_all(&base); +} + +// A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted +// by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on +// disk. A Claude session in the same dir's projects/ tree is still deletable. +#[test] +fn delete_forever_refuses_live_codex_rollout() { + let base = std::env::temp_dir().join("ccbud-codex-del-test"); + let _ = fs::remove_dir_all(&base); + // codex rollout under /sessions/… + let sdir = base.join("sessions").join("2026").join("07").join("04"); + fs::create_dir_all(&sdir).unwrap(); + let codex_file = sdir.join("rollout-x.jsonl"); + fs::write( + &codex_file, + "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"x\",\"cwd\":\"/x\"}}\n\ + {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"hi\"}]}}\n", + ) + .unwrap(); + // claude session under /projects/… + let pdir = base.join("projects").join("-x"); + fs::create_dir_all(&pdir).unwrap(); + let claude_file = pdir.join("s1.jsonl"); + fs::write(&claude_file, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"cwd\":\"/x\",\"sessionId\":\"s1\"}\n").unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + + // live codex → refused, file survives + let r = delete_session_file(&codex_file.to_string_lossy(), &config); + assert_eq!(r.get("reason").and_then(|v| v.as_str()), Some("foreign"), "live codex must be refused"); + assert!(codex_file.is_file(), "codex rollout must NOT be deleted"); + + // claude session → deleted + let r2 = delete_session_file(&claude_file.to_string_lossy(), &config); + assert_eq!(r2.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert!(!claude_file.is_file(), "claude session should be gone"); + + let _ = fs::remove_dir_all(&base); +} + +// Export a session-with-subagents and prove the .zip splits back into the main session + both +// subagent sidecars (the shape import_zip then writes into the store). Avoids mutating CCBUD_HOME +// so it can't race other threads under `cargo test`; the store round-trip is covered by the +// in-app import_selftest and confirms in review via write_imported (shared with import_one). +#[test] +fn export_bundle_round_trips_through_split() { + let base = std::env::temp_dir().join("ccbud-bundle-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-bnd-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("bundsess.jsonl"); + fs::write(&main, "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu9\",\"name\":\"Task\",\"input\":{}}]},\"cwd\":\"/bnd/cwd\",\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); + let sub = proj.join("bundsess").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("agent-b1.jsonl"), "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"b1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub done\"}]},\"sessionId\":\"bundsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n").unwrap(); + fs::write(sub.join("agent-b1.meta.json"), "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu9\"}").unwrap(); + + assert!(session_has_subagents(&main.to_string_lossy())); + + let zip = export_bundle(&main.to_string_lossy()).unwrap(); + assert!(zip.starts_with(&[0x50, 0x4b, 0x03, 0x04]), "starts with PK local header"); + + let (m, subs) = crate::ziputil::split_bundle(crate::ziputil::read(&zip)); + assert_eq!(m.as_ref().map(|(n, _)| n.as_str()), Some("bundsess.jsonl")); + assert_eq!(subs.len(), 2); + assert!(subs.iter().any(|(n, d)| n == "agent-b1.jsonl" && String::from_utf8_lossy(d).contains("sub done"))); + assert!(subs.iter().any(|(n, _)| n == "agent-b1.meta.json")); + + let _ = fs::remove_dir_all(&base); +} + +// The list is ordered by the session's FIRST RECORD TIMESTAMP, not fs times — a title/tag +// edit rewrites the file via tmp+rename (which resets its fs birth time to "now") and must +// NOT reshuffle the list. +#[test] +fn list_order_survives_title_and_tag_edits() { + let base = std::env::temp_dir().join("ccbud-order-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-ord-cwd"); + fs::create_dir_all(&proj).unwrap(); + let older = proj.join("older.jsonl"); + let newer = proj.join("newer.jsonl"); + fs::write(&older, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"old one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"older\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n").unwrap(); + fs::write(&newer, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"new one\"},\"cwd\":\"/ord/cwd\",\"sessionId\":\"newer\",\"timestamp\":\"2025-06-01T10:00:00.000Z\"}\n").unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let order = |cfg: &Value| -> Vec { + list_sessions(cfg, "all", 50) + .iter() + .filter(|s| s.get("cwd").and_then(|v| v.as_str()) == Some("/ord/cwd")) + .map(|s| s.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string()) + .collect() + }; + assert_eq!(order(&config), vec!["newer", "older"], "newest record time first"); + + // Rename + tag the OLDER session: the file is rewritten through a fresh tmp inode, yet + // the list order must not change. + let r = set_ccbud(&older.to_string_lossy(), &json!({ "title": "Renamed", "tags": ["pinned"] }), &config); + assert_eq!(r.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(order(&config), vec!["newer", "older"], "tag/title edit must not reshuffle"); + + // And the row's createdAt still reflects the record timestamp, not the rewrite moment, + // while the edited title shows up immediately (list-meta memo invalidated by the write). + let rows = list_sessions(&config, "all", 50); + let row = rows.iter().find(|s| s.get("sessionId").and_then(|v| v.as_str()) == Some("older")).unwrap(); + let want = chrono::DateTime::parse_from_rfc3339("2025-01-01T10:00:00.000Z").unwrap().timestamp_millis() as f64; + assert_eq!(row.get("createdAt").and_then(|v| v.as_f64()), Some(want)); + assert_eq!(row.get("title").and_then(|v| v.as_str()), Some("Renamed")); + + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn subagent_transcript_paths_lists_only_agent_jsonl() { + let base = std::env::temp_dir().join("ccbud-subpaths-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-m-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("m.jsonl"); + fs::write(&main, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"sessionId\":\"m\"}\n").unwrap(); + // no subagents → empty (caller attaches only the main file) + assert!(subagent_transcript_paths(&main.to_string_lossy()).is_empty()); + + let sub = proj.join("m").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("agent-a.jsonl"), "{}\n").unwrap(); + fs::write(sub.join("agent-b.jsonl"), "{}\n").unwrap(); + fs::write(sub.join("agent-a.meta.json"), "{}").unwrap(); // sidecar must be excluded + + let paths = subagent_transcript_paths(&main.to_string_lossy()); + assert_eq!(paths.len(), 2, "only the two agent-*.jsonl, not the .meta.json"); + assert!(paths.iter().all(|p| p.ends_with(".jsonl"))); + assert!(paths.iter().any(|p| p.ends_with("agent-a.jsonl"))); + assert!(paths.iter().any(|p| p.ends_with("agent-b.jsonl"))); + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/tests_qoder.rs b/src-tauri/src/history/tests_qoder.rs new file mode 100644 index 0000000..e52808e --- /dev/null +++ b/src-tauri/src/history/tests_qoder.rs @@ -0,0 +1,160 @@ +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use super::edit::delete_session_file; +use super::import::qoder_import_raw; +use super::list::list_sessions; +use super::search::search_sessions; +use super::session::get_session; + +// Qoder writes Claude-like atomic event wrappers plus inline metadata into its own tree. +// Rows and detail must use that metadata, merge one assistant response's content blocks, +// retain queued commands as user turns, nest subagents, and remain searchable/exportable. +#[test] +fn qoder_sessions_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-qoder-route-test"); + let _ = fs::remove_dir_all(&base); + let root = base.join(".qoder"); + let proj = root.join("projects").join("-tmp-qproj"); + fs::create_dir_all(&proj).unwrap(); + let uuid = "11111111-1111-4111-8111-111111111111"; + let sess = proj.join(format!("{}.jsonl", uuid)); + let records = vec![ + json!({ "type": "agent-setting", "agentSetting": "triage", "entrypoint": "sdk-cli", "sessionId": uuid }), + json!({ "type": "last-prompt", "sessionId": uuid, "lastPrompt": "last prompt fallback" }), + json!({ "type": "ai-title", "sessionId": uuid, "aiTitle": "Qoder 会话" }), + json!({ "type": "workspace-directories", "sessionId": uuid, "directories": ["/tmp/qproj"] }), + json!({ "type": "runtime-config", "sessionId": uuid, "model": "ultimate", "reasoningEffort": "high" }), + json!({ + "type": "user", "uuid": "u1", "timestamp": "2026-06-04T09:47:27.966Z", + "message": { "role": "user", "content": "qoder needle axolotl" }, + "sessionId": uuid, "version": "1.1.13" + }), + json!({ + "type": "assistant", "uuid": "a1", "parentUuid": "u1", "timestamp": "2026-06-04T09:47:32.116Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "model": "wire-model", "content": [ + { "type": "redacted_thinking", "data": "must not render" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a2", "parentUuid": "a1", "timestamp": "2026-06-04T09:47:32.216Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "thinking", "thinking": "considering" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a3", "parentUuid": "a2", "timestamp": "2026-06-04T09:47:32.316Z", + "message": { "id": "msg_1", "type": "message", "role": "assistant", "content": [ + { "type": "text", "text": "done" } + ]}, "sessionId": uuid + }), + json!({ + "type": "assistant", "uuid": "a4", "parentUuid": "a3", "timestamp": "2026-06-04T09:47:32.416Z", + "message": { + "id": "msg_1", "type": "message", "role": "assistant", "stop_reason": "end_turn", + "usage": { "input_tokens": 100, "cache_creation_input_tokens": 7, "cache_read_input_tokens": 50, "output_tokens": 30 }, + "content": [{ "type": "tool_use", "id": "tu1", "name": "Task", "input": {} }] + }, "sessionId": uuid + }), + json!({ + "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued narwhal follow-up", "commandMode": false }, + "uuid": "u2", "parentUuid": "a4", "timestamp": "2026-06-04T09:47:35.000Z", "sessionId": uuid + }), + ]; + let raw = records + .iter() + .map(|record| serde_json::to_string(record).unwrap()) + .collect::>() + .join("\n") + + "\n"; + fs::write(&sess, raw).unwrap(); + let sub = proj.join(uuid).join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("agent-q1.jsonl"), + format!("{{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"q1\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"sub quetzal done\"}}]}},\"sessionId\":\"{}\",\"timestamp\":\"2026-06-04T09:47:40.000Z\"}}\n", uuid), + ) + .unwrap(); + fs::write( + sub.join("agent-q1.meta.json"), + "{\"agentType\":\"general-purpose\",\"description\":\"d\",\"toolUseId\":\"tu1\"}", + ) + .unwrap(); + + let config = json!({ "historyDirs": [ root.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + assert_eq!(rows.len(), 1, "rows: {:?}", rows); + let r = &rows[0]; + assert_eq!(r["source"], "qoder"); + assert_eq!(r["id"], format!("qoder:{}", uuid)); + assert_eq!(r["title"], "Qoder 会话"); + assert_eq!(r["autoTitle"], "Qoder 会话"); + assert_eq!(r["cwd"], "/tmp/qproj"); + assert_eq!(r["model"], "ultimate"); + assert_eq!(r["deleted"], false); + + let file = r["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], "Qoder"); + assert_eq!(d["meta"]["source"], "qoder"); + assert_eq!(d["meta"]["id"], format!("qoder:{}", uuid)); + assert_eq!(d["meta"]["title"], "Qoder 会话"); + assert_eq!(d["meta"]["model"], "ultimate"); + assert_eq!(d["meta"]["subagentCount"], 1); + assert_eq!(d["messages"].as_array().unwrap().len(), 3); + assert_eq!(d["messages"][0]["content"], "qoder needle axolotl"); // string-content user turn + let assistant_blocks = d["messages"][1]["content"].as_array().unwrap(); + assert_eq!( + assistant_blocks + .iter() + .filter_map(|block| block.get("type").and_then(Value::as_str)) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + assert_eq!(d["messages"][1]["usage"]["inputTokens"], 100); + assert_eq!(d["messages"][1]["stopReason"], "end_turn"); + assert_eq!(d["messages"][2]["role"], "user"); + assert_eq!(d["messages"][2]["content"], "queued narwhal follow-up"); + assert_eq!(d["subagents"]["tu1"]["messages"][0]["content"][0]["text"], "sub quetzal done"); + + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + + // content search reaches the main thread and the subagent transcript + let hits = search_sessions(&config, "all", "axolotl", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "narwhal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "main"); + let hits = search_sessions(&config, "all", "quetzal", 10); + assert_eq!(hits.len(), 1, "{:?}", hits); + assert_eq!(hits[0]["agent"], "tu1"); + + let _ = fs::remove_dir_all(&base); +} + +// Imported qoder content is rewritten to Claude shape (wrappers merged, queued commands +// materialized) with qoder's own title carried onto __ccbud__; Claude content passes through. +#[test] +fn qoder_imports_are_normalized_with_title() { + let recs = vec![ + json!({ "type": "ai-title", "aiTitle": "Qoder 导入标题" }), + json!({ "type": "assistant", "uuid": "w1", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "thinking", "thinking": "t" }] } }), + json!({ "type": "assistant", "uuid": "w2", "message": { "id": "m1", "role": "assistant", "content": [{ "type": "text", "text": "done" }] } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "queued prompt" } }), + ]; + let (text, normalized) = qoder_import_raw(&recs).expect("sniffs as qoder"); + let assistants: Vec<&Value> = normalized.iter().filter(|r| r["type"] == "assistant").collect(); + assert_eq!(assistants.len(), 1, "wrappers merged: {:?}", normalized); + assert_eq!(assistants[0]["message"]["content"].as_array().unwrap().len(), 2); + assert!(normalized + .iter() + .any(|r| r["type"] == "user" && r["message"]["content"] == "queued prompt")); + let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(first["__ccbud__"]["title"], "Qoder 导入标题"); + assert!(qoder_import_raw(&[json!({ "type": "user", "message": { "content": "hi" } })]).is_none()); +} diff --git a/src-tauri/src/history/tests_search.rs b/src-tauri/src/history/tests_search.rs new file mode 100644 index 0000000..26b95c6 --- /dev/null +++ b/src-tauri/src/history/tests_search.rs @@ -0,0 +1,122 @@ +use serde_json::json; +use std::fs; + +use super::search::search_sessions; +use super::searchfmt::strip_injected; + +// The Rust search extractor and the renderer's stripInjected must agree: a task-notification +// envelope surfaces only its body — transport metadata must never be searchable. +#[test] +fn strip_injected_unwraps_task_notifications() { + let s = strip_injected( + "before \ncompleted\ntransport-noise\n\nDone **ok**\n\n after", + ); + assert!(s.contains("before") && s.contains("after")); + assert!(s.contains("Done **ok**")); + assert!(!s.contains("transport-noise")); + assert!(!s.contains("task-notification")); + // an envelope without a vanishes wholesale, like a system-reminder + let gone = strip_injected("x running y"); + assert!(gone.contains('x') && gone.contains('y') && !gone.contains("running")); + // the pre-existing rules still apply after the unwrap + assert_eq!(strip_injected("himeta"), "hi"); + // a standalone Codex injection vanishes; quoting one alongside prose does not + assert_eq!(strip_injected(" skill-body\n"), ""); + assert!(strip_injected("see quoted here").contains("quoted")); +} + +// The Codex AGENTS bootstrap must index as the SAME compact Markdown the panel renders +// (formatCodexBootstrap parity) — not as the raw XML-ish transport shape. +#[test] +fn strip_injected_formats_codex_bootstrap_like_the_panel() { + let raw = "# AGENTS.md instructions for /work/proj\n\n\nAlways run tests.\n\n\n\n /work/proj\n zsh\n /work/proj\n \n"; + let s = strip_injected(raw); + assert!(s.starts_with("# AGENTS.md instructions for /work/proj"), "{s}"); + assert!(s.contains("**INSTRUCTIONS:** Always run tests."), "{s}"); + assert!(s.contains("**environment_context:** `/work/proj`"), "{s}"); + assert!(s.contains("**shell:** zsh"), "{s}"); + assert!(s.contains("**workspace_roots:** `/work/proj`"), "{s}"); + assert!(s.contains("**permission_profile:** workspace-write"), "{s}"); + assert!(!s.contains("\na\nb\n"); + assert!(multi.contains("**INSTRUCTIONS:**\n\na\nb"), "{multi}"); + // ordinary prose is untouched + assert_eq!(strip_injected("ordinary prose"), "ordinary prose"); +} + +// Content search: a main-thread hit reports agent "main"; a subagent-only hit reports the +// spawning tool_use key (+ agent type); injected text never matches; and +// ASCII case folds. Runs twice so the second pass exercises the extraction cache. +#[test] +fn search_sessions_finds_main_and_subagent_content() { + let base = std::env::temp_dir().join("ccbud-search-test"); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-srch-cwd"); + fs::create_dir_all(&proj).unwrap(); + let main = proj.join("srchsess.jsonl"); + fs::write( + &main, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"find the zebra crossingreminder-secret\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:00.000Z\"}\n\ + {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"tu1\",\"name\":\"Task\",\"input\":{}}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:01.000Z\"}\n", + ) + .unwrap(); + let sub = proj.join("srchsess").join("subagents"); + fs::create_dir_all(&sub).unwrap(); + fs::write( + sub.join("agent-s1.jsonl"), + "{\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"s1\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"the quokka was found here\"}]},\"sessionId\":\"srchsess\",\"timestamp\":\"2025-01-01T10:00:02.000Z\"}\n", + ) + .unwrap(); + fs::write(sub.join("agent-s1.meta.json"), "{\"agentType\":\"explore\",\"description\":\"d\",\"toolUseId\":\"tu1\"}").unwrap(); + // Content stored as \uXXXX escapes (e.g. python json.dumps output) — a byte scan can't + // see the decoded text, so non-ASCII queries must bypass the raw prefilter. + let esc = proj.join("escsess.jsonl"); + fs::write( + &esc, + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"\\u4e2d\\u6587\\u5185\\u5bb9 escaped\"},\"cwd\":\"/srch/cwd\",\"sessionId\":\"escsess\",\"timestamp\":\"2025-01-02T10:00:00.000Z\"}\n", + ) + .unwrap(); + // A codex rollout in the same work dir's sessions/ tree — its own record format, scanned + // through the codex shaper. + let cdir = base.join("sessions").join("2026").join("07").join("04"); + fs::create_dir_all(&cdir).unwrap(); + fs::write( + cdir.join("rollout-c.jsonl"), + "{\"timestamp\":\"2026-07-04T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"session_id\":\"c1\",\"cwd\":\"/cx\"}}\n\ + {\"timestamp\":\"2026-07-04T00:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"codex kangaroo request\"}]}}\n", + ) + .unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + + for pass in 0..2 { + // main-thread hit + let hits = search_sessions(&config, "all", "zebra crossing", 50); + assert_eq!(hits.len(), 1, "pass {}: one session matches", pass); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); + assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("zebra")); + + // subagent-only hit → keyed by the spawning tool_use id, labeled with the agent type + let hits = search_sessions(&config, "all", "QUOKKA", 50); // also proves case folding + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("tu1")); + assert_eq!(hits[0].get("agentType").and_then(|v| v.as_str()), Some("explore")); + + // codex rollout content is searchable too + let hits = search_sessions(&config, "all", "kangaroo", 50); + assert_eq!(hits.len(), 1, "pass {}: codex rollout matches", pass); + assert_eq!(hits[0].get("agent").and_then(|v| v.as_str()), Some("main")); + + // \uXXXX-escaped content still matches a non-ASCII query (no raw prefilter for those) + let hits = search_sessions(&config, "all", "中文", 50); + assert_eq!(hits.len(), 1, "pass {}: escaped unicode content matches", pass); + assert!(hits[0].get("snippet").and_then(|v| v.as_str()).unwrap_or("").contains("中文内容")); + + // injected system-reminder content is NOT searchable (matches the renderer) + assert!(search_sessions(&config, "all", "reminder-secret", 50).is_empty()); + // no match at all + assert!(search_sessions(&config, "all", "wombat", 50).is_empty()); + } + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/history/text.rs b/src-tauri/src/history/text.rs new file mode 100644 index 0000000..2499089 --- /dev/null +++ b/src-tauri/src/history/text.rs @@ -0,0 +1,87 @@ +use serde_json::Value; + +pub(super) fn content_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(arr) = content.as_array() { + return arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" "); + } + String::new() +} + +fn command_label(raw: &str) -> String { + let name = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(n, _)| n.trim().to_string()) + .unwrap_or_default(); + if name.is_empty() { + return String::new(); + } + let args = raw + .split_once("") + .and_then(|(_, r)| r.split_once("")) + .map(|(a, _)| a.trim().to_string()) + .unwrap_or_default(); + format!("{} {}", name, args).trim().to_string() +} + +/// First human prose turn (skips slash-command XML / meta / interrupt notices), capped at 90 chars. +pub(crate) fn first_user_text(messages: &[Value]) -> String { + let mut fallback_cmd = String::new(); + for m in messages { + if m.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + if m.get("_meta").and_then(|v| v.as_bool()).unwrap_or(false) { + continue; + } + let content = m.get("content").cloned().unwrap_or(Value::Null); + let raw = content_text(&content); + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + if raw.starts_with('<') { + if fallback_cmd.is_empty() { + fallback_cmd = command_label(raw); + } + continue; + } + let t: String = raw.split_whitespace().collect::>().join(" "); + if t.starts_with("[Request interrupted") || t.starts_with("Caveat:") { + continue; + } + return t.chars().take(90).collect(); + } + fallback_cmd.chars().take(90).collect() +} + +/// __ccbud__ customization (custom title + tags + soft-delete flag) from any record carrying it. +pub(crate) fn read_ccbud(recs: &[Value]) -> (Option, Vec, bool) { + let c = recs.iter().find_map(|r| r.get("__ccbud__")); + let title = c + .and_then(|c| c.get("title")) + .and_then(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let tags = c + .and_then(|c| c.get("tagList")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + let deleted = c.and_then(|c| c.get("delete")).and_then(|v| v.as_bool()).unwrap_or(false); + (title, tags, deleted) +} diff --git a/src-tauri/src/legacybundle.rs b/src-tauri/src/legacybundle.rs new file mode 100644 index 0000000..ccc1af9 --- /dev/null +++ b/src-tauri/src/legacybundle.rs @@ -0,0 +1,51 @@ +// Legacy macOS bundle rename, moved verbatim from lib.rs. (run() calls the copy that lives in +// startup.rs; this one is kept as-is by the split.) + +/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The +/// in-app updater swaps the bundle's contents but never the folder itself, and +/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName +/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back +/// to the folder name. Rename the bundle once, relaunch from the new path so +/// Launch Services re-registers it, and exit. Bails out on any obstacle +/// (translocation, read-only volume, name already taken) and keeps running under +/// the old name. +#[cfg(target_os = "macos")] +fn migrate_legacy_bundle_name() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + // exe = /.app/Contents/MacOS/ + let bundle = match exe.ancestors().nth(3) { + Some(p) + if matches!( + p.file_name().and_then(|n| n.to_str()), + Some("ccbud.app") | Some("CCBuddy.app") + ) => + { + p.to_path_buf() + } + _ => return, + }; + let target = match bundle.parent() { + Some(dir) => dir.join("CC Buddy.app"), + None => return, + }; + if target.exists() || std::fs::rename(&bundle, &target).is_err() { + return; + } + // `open -n` asks Launch Services to start a fresh instance from the new path + // (which also re-registers the name). Wait for its verdict rather than exiting + // on spawn: a refusal must restore the old name so the running process keeps a + // valid bundle path behind it instead of leaving the user with nothing open. + let launched = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(&target) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if launched { + std::process::exit(0); + } + let _ = std::fs::rename(&target, &bundle); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3567870..646beed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,1783 +9,40 @@ mod antigravity; mod claude; mod codex; mod codexconnect; +mod commands; mod copilot; mod counttokens; mod exporthtml; mod gateway; mod grok; mod history; +mod legacybundle; mod plugin; +mod popover; mod protocol; mod qoder; mod sidecar; +mod startup; mod store; +mod tray; +mod trayicon; mod usage; mod ziputil; -use serde_json::{json, Value}; -use tauri::{Emitter, Manager}; +use tauri::Manager; -// Timestamp (ms since epoch) of the last popover hide — used to debounce the tray click, -// which would otherwise re-show the popover on the very click that blurred it shut. -static LAST_POPOVER_HIDE_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -// Timestamp of the last popover show — a fullscreen app steals focus the instant the popover -// appears, so we ignore blur within a grace window after show (else it hides before being seen). -static LAST_POPOVER_SHOW_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -// ---- config / providers (real, store.rs) ---- -#[tauri::command] -fn config_get() -> Value { - store::read_config() -} -/// Last gateway start error (e.g. a bad port the user typed). Surfaced via server:status so the -/// renderer can show the failure banner. Mirrors main.js lastStartError. -static LAST_START_ERROR: std::sync::Mutex> = std::sync::Mutex::new(None); -#[tauri::command] -async fn config_save( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - cfg: Value, -) -> Result { - let prev = store::read_config(); - let prev_port = prev.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let next_port = cfg - .get("port") - .and_then(|v| v.as_u64()) - .map(|p| p as u16) - .unwrap_or(prev_port); - let was_connected = claude::is_connected(prev_port); - let codex_was_connected = codexconnect::is_connected(prev_port); - let prev_dirs = prev.get("historyDirs").cloned(); - - // If the gateway is running and the port changed, bind the NEW port BEFORE committing so a bad - // port can never lock the user out — roll back to the old port and report on failure. - if next_port != prev_port && gw.current_port().await.is_some() { - gw.stop().await; - if let Err(e) = gw.start(next_port).await { - let _ = gw.start(prev_port).await; - let msg = format!("端口 {} 启动失败:{}", next_port, e); - *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); - gw.emit("gateway:status", full_status(&gw).await); - return Err(msg); - } - *LAST_START_ERROR.lock().unwrap() = None; - } - - let saved = store::write_config(cfg); - use tauri_plugin_autostart::ManagerExt; - let want = saved.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false); - let mgr = app.autolaunch(); - let _ = if want { mgr.enable() } else { mgr.disable() }; - - // Keep each connected CLI's config in sync if connected (port/token may have changed). - if was_connected || codex_was_connected { - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(&saved); - if was_connected { - claude::connect(port, &token); - } - if codex_was_connected { - codexconnect::connect(port, &token, &codex_model(&saved)); - } - } - - // History dirs changed → invalidate + re-warm the usage cache and notify the renderer. - if saved.get("historyDirs").cloned() != prev_dirs { - usage::invalidate_cache(); - let cfg2 = saved.clone(); - std::thread::spawn(move || usage::warm_cache(&cfg2, "all")); - let _ = app.emit("history:changed", json!({ "files": [] })); - } - - update_tray_title(&app); - gw.emit("gateway:status", full_status(&gw).await); - Ok(saved) -} -#[tauri::command] -fn provider_upsert(p: Value) -> Value { - let mut cfg = store::read_config(); - let mut provider = p; - let pid = provider.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); - { - let provs = cfg["providers"].as_array_mut().unwrap(); - match pid { - Some(id) if !id.is_empty() => { - if let Some(i) = provs - .iter() - .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(id.as_str())) - { - provs[i] = provider; - } else { - provs.push(provider); - } - } - _ => { - let id = store::gen_id(); - provider - .as_object_mut() - .unwrap() - .insert("id".into(), json!(id.clone())); - provs.push(provider); - if cfg["activeProviderId"].is_null() { - cfg["activeProviderId"] = json!(id); - } - } - } - } - store::write_config(cfg) -} -#[tauri::command] -fn provider_delete(id: String) -> Value { - let mut cfg = store::read_config(); - let kept: Vec = cfg["providers"] - .as_array() - .map(|a| { - a.iter() - .filter(|p| p.get("id").and_then(|v| v.as_str()) != Some(id.as_str())) - .cloned() - .collect() - }) - .unwrap_or_default(); - cfg["providers"] = json!(kept); - if cfg["activeProviderId"].as_str() == Some(id.as_str()) { - cfg["activeProviderId"] = cfg["providers"] - .as_array() - .and_then(|a| a.first()) - .and_then(|p| p.get("id").cloned()) - .unwrap_or(Value::Null); - } - store::write_config(cfg) -} -#[tauri::command] -fn provider_set_active(pm: PluginState<'_>, id: String) -> Result { - let cfg = store::read_config(); - // A plugin-backed service can only be activated while its plugin is running — - // otherwise the gateway would forward to a dead port. The UI localizes this code. - if let Some(p) = cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id.as_str()))) - { - if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { - let plugin_id = p.get("pluginId").and_then(|v| v.as_str()).unwrap_or(""); - if !pm.is_running(plugin_id) { - return Err("pluginNotRunning".into()); - } - } - } - let mut cfg = store::read_config(); - cfg["activeProviderId"] = json!(id); - Ok(store::write_config(cfg)) -} - -// ---- plugins (sidecar coding-agent backends, see plugin.rs) ---- -type PluginState<'a> = tauri::State<'a, std::sync::Arc>; - -/// List discovered plugins with running + auth status. -#[tauri::command] -async fn plugin_list(pm: PluginState<'_>) -> Result { - Ok(pm.list().await) -} -/// Single plugin status snapshot. -#[tauri::command] -async fn plugin_status(pm: PluginState<'_>, id: String) -> Result { - Ok(pm.status(&id).await) -} -/// Enable (spawn + health-gate + register provider) or disable (stop the process; the service stays until uninstalled) a plugin. -#[tauri::command] -async fn plugin_set_enabled(pm: PluginState<'_>, id: String, enabled: bool) -> Result { - if enabled { - pm.start(&id).await?; - } else { - pm.stop(&id)?; - } - Ok(pm.status(&id).await) -} -/// Run a plugin-declared UI action: forward form `values` to its control plane. -#[tauri::command] -async fn plugin_action(pm: PluginState<'_>, id: String, action: String, values: Value) -> Result { - pm.action(&id, &action, values).await -} -/// Prefill a plugin action form with the plugin's current values. -#[tauri::command] -async fn plugin_action_load(pm: PluginState<'_>, id: String, action: String) -> Result { - pm.action_load(&id, &action).await -} -/// Add a plugin: pick a local folder containing plugin.json and install it. -/// `title` is the localized folder-picker title (supplied by the renderer). -#[tauri::command] -async fn plugin_install(pm: PluginState<'_>, title: Option) -> Result { - let title = title.filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "Select the plugin folder".into()); - let picked = rfd::AsyncFileDialog::new() - .set_title(&title) - .pick_folder() - .await; - let dir = match picked { - Some(f) => f.path().to_path_buf(), - None => return Ok(json!({ "canceled": true })), - }; - let id = pm.install(&dir)?; - Ok(json!({ "ok": true, "id": id })) -} -/// Remove a plugin (the renderer confirms first): stop it, drop its service, delete its files. -#[tauri::command] -async fn plugin_uninstall(pm: PluginState<'_>, id: String) -> Result { - // The confirmation is shown by the renderer (localized confirmDialog) before - // this is called, so we just do the work here. - pm.uninstall(&id)?; - Ok(json!({ "ok": true })) -} -/// Open the plugins folder in the OS file browser. -#[tauri::command] -fn plugin_open_dir() -> bool { - let dir = plugin::plugins_root(); - let _ = std::fs::create_dir_all(&dir); - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(&dir).spawn().is_ok() - } - #[cfg(target_os = "windows")] - { - std::process::Command::new("explorer").arg(&dir).spawn().is_ok() - } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - std::process::Command::new("xdg-open").arg(&dir).spawn().is_ok() - } -} -/// Install a plugin from a git repository (clone + build + install). Runs the -/// blocking git/build work off the async runtime. -#[tauri::command] -async fn plugin_install_git(pm: PluginState<'_>, url: String) -> Result { - let mgr = pm.inner().clone(); - let id = tokio::task::spawn_blocking(move || mgr.install_from_git(&url)) - .await - .map_err(|e| e.to_string())??; - Ok(json!({ "ok": true, "id": id })) -} -/// Check whether a plugin's git source has a newer version. -#[tauri::command] -async fn plugin_check_update(pm: PluginState<'_>, id: String) -> Result { - Ok(pm.check_update(&id).await) -} -/// Update a plugin from its recorded git source (re-clone + build + replace). -#[tauri::command] -async fn plugin_update(pm: PluginState<'_>, id: String) -> Result { - let mgr = pm.inner().clone(); - let id = tokio::task::spawn_blocking(move || mgr.update(&id)) - .await - .map_err(|e| e.to_string())??; - Ok(json!({ "ok": true, "id": id })) -} -async fn send_provider_probe( - client: &reqwest::Client, - url: &str, - wire: crate::protocol::Wire, - token: &str, - body: &Value, -) -> Result { - let mut request = client - .post(url) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", token)); - if wire == crate::protocol::Wire::Anthropic { - request = request.header("anthropic-version", "2023-06-01"); - } - request.json(body).send().await -} - -/// Live connection test: POST a tiny ping to the provider, shaped for its declared wire protocol -/// (Anthropic /messages, OpenAI /chat/completions, or /responses), and report ok/error/timeout. -/// The renderer localizes the result message. -#[tauri::command] -async fn provider_test(app: tauri::AppHandle, p: Value) -> Value { - let base = p.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim(); - if base.is_empty() { - return json!({ "ok": false, "reason": "baseUrlEmpty" }); - } - if !(base.starts_with("http://") || base.starts_with("https://")) { - return json!({ "ok": false, "reason": "baseUrlInvalid" }); - } - // Test against the provider's DECLARED protocol endpoint — an openai-chat provider must be - // pinged at /chat/completions with a Chat body, not the Anthropic /v1/messages default. - let wire = crate::protocol::Wire::from_provider(p.get("protocol").and_then(|v| v.as_str())); - let url = wire.upstream_url(base); - let model = p - .get("defaultModel") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .or_else(|| { - p.get("models") - .and_then(|m| m.as_array()) - .and_then(|a| a.first()) - .and_then(|m| m.get("upstream")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }) - .unwrap_or("claude-3-5-haiku-20241022") - .to_string(); - let token = p.get("authToken").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let insecure = store::read_config() - .get("insecureSkipVerify") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - // Protocol-shaped ping body. - let body = match wire { - crate::protocol::Wire::OpenAiResponses => json!({ "model": model, "max_output_tokens": 16, "input": "ping" }), - crate::protocol::Wire::OpenAiChat => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), - crate::protocol::Wire::Anthropic => json!({ "model": model, "max_tokens": 16, "messages": [{ "role": "user", "content": "ping" }] }), - }; - let client = match reqwest::Client::builder() - .danger_accept_invalid_certs(insecure) - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(e) => return json!({ "ok": false, "message": e.to_string() }), - }; - // Auth via Authorization: Bearer only. Sending both authorization and x-api-key trips - // providers that reject having the two auth headers present at once. - let first = send_provider_probe(&client, &url, wire, &token, &body).await; - match first { - Ok(mut r) => { - let mut migrated_base_url: Option = None; - if crate::protocol::should_try_v1_fallback(r.status().as_u16()) { - if let Some(fallback_url) = wire.v1_fallback_url(base) { - if let Ok(candidate) = send_provider_probe(&client, &fallback_url, wire, &token, &body).await { - if candidate.status().is_success() { - r = candidate; - migrated_base_url = Some(format!("{}/v1", base.trim_end_matches('/'))); - } - } - } - } - let status = r.status().as_u16(); - let text = r.text().await.unwrap_or_default(); - let parsed: Option = serde_json::from_str(&text).ok(); - let http_ok = (200..300).contains(&status); - // A well-shaped reply for the tested protocol: Anthropic `type:message`, Chat `choices`, - // Responses `output`/`id`. - let shape_ok = parsed.as_ref().map(|j| match wire { - crate::protocol::Wire::Anthropic => j.get("type").and_then(|v| v.as_str()) == Some("message"), - crate::protocol::Wire::OpenAiChat => j.get("choices").map(|c| c.is_array()).unwrap_or(false), - crate::protocol::Wire::OpenAiResponses => j.get("output").is_some() || j.get("id").is_some(), - }).unwrap_or(false); - if http_ok && shape_ok { - let m = parsed - .as_ref() - .and_then(|j| j.get("model")) - .and_then(|v| v.as_str()) - .unwrap_or(&model); - if let Some(next_base) = migrated_base_url.as_deref() { - if let Some(id) = p.get("id").and_then(Value::as_str) { - if let Some(saved) = store::migrate_provider_base_url_to_v1(id, base) { - let _ = app.emit("config:changed", saved); - return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); - } - } else { - return json!({ "ok": true, "status": status, "model": m, "baseUrl": next_base }); - } - } - return json!({ "ok": true, "status": status, "model": m }); - } - let msg = parsed - .as_ref() - .and_then(|j| j.get("error")) - .and_then(|e| e.get("message")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - if !text.is_empty() { - text.chars().take(200).collect() - } else { - format!("HTTP {}", status) - } - }); - json!({ "ok": false, "status": status, "message": msg }) - } - Err(e) => { - if e.is_timeout() { - json!({ "ok": false, "reason": "timeout" }) - } else { - json!({ "ok": false, "message": e.to_string() }) - } - } - } -} - -// ---- coding CLI connect / replay ---- -/// The literal selected CLIs from config `connectTargets` (subset of {claude, codex}, deduped). -/// Empty is a valid state ("nothing connected") — the hero Connect button substitutes a default. -fn connect_targets(cfg: &Value) -> Vec { - let mut out: Vec = vec![]; - if let Some(a) = cfg.get("connectTargets").and_then(|v| v.as_array()) { - for v in a { - if let Some(s) = v.as_str() { - if (s == "claude" || s == "codex") && !out.iter().any(|x| x == s) { - out.push(s.to_string()); - } - } - } - } - out -} - -/// Plan the safe subset of connections to repair on startup. Older releases could persist the -/// then-default `["claude"]` without the user ever connecting, so selection alone is insufficient: -/// a target's compatibility backup is the proof that CC Buddy previously took ownership of it. -fn startup_reconcile_targets(cfg: &Value) -> Vec { - connect_targets(cfg) - .into_iter() - .filter(|target| { - let backup_key = if target == "claude" { - "claudeBackup" - } else { - "codexBackup" - }; - cfg.get(backup_key).map(Value::is_object).unwrap_or(false) - }) - .collect() -} - -/// Repair only previously managed, still-selected targets. This intentionally has no disconnect -/// branch: startup must not restore/consume an unselected target's compatibility backup. Since each -/// connect call is gated on an existing object backup, it also cannot create a first-time backup. -fn reconcile_connections_on_startup(cfg: &Value) { - let selected = startup_reconcile_targets(cfg); - if selected.is_empty() { - return; - } - let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(cfg); - if selected.iter().any(|target| target == "claude") { - claude::connect(port, &token); - } - if selected.iter().any(|target| target == "codex") { - codexconnect::connect(port, &token, &codex_model(cfg)); - } -} - -/// The legacy one-click Connect command still has a useful default even though startup does not: -/// when no target is selected, choose Claude and persist that now-explicit selection. -fn ensure_hero_connect_target(cfg: &mut Value) -> bool { - if connect_targets(cfg).is_empty() { - cfg["connectTargets"] = json!(["claude"]); - true - } else { - false - } -} - -/// The model written into Codex's config. `gpt-5.4` is a stable model identity understood by the -/// current CLI and enables its normal function/custom tool registry for custom providers. The -/// synthetic `gpt-5.6-sol-pro` identity previously used here selected code-mode metadata and made -/// Codex send an empty Responses `tools` array, so the gateway could never drive an agent turn. -fn codex_model(_cfg: &Value) -> String { - "gpt-5.4".to_string() -} - -/// Make each CLI's config file match the selected `connectTargets`: write the selected ones to -/// point at the gateway, restore the rest. PURELY a config-file operation — the gateway service -/// itself is an independent switch (`gatewayEnabled`), never started or stopped from here. -fn apply_connections(cfg: &Value) { - let selected = connect_targets(cfg); - let claude_on = selected.iter().any(|t| t == "claude"); - let codex_on = selected.iter().any(|t| t == "codex"); - let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let token = claude::current_token(cfg); - if claude_on { - claude::connect(port, &token); - } else { - claude::disconnect(); - } - if codex_on { - codexconnect::connect(port, &token, &codex_model(cfg)); - } else { - codexconnect::disconnect(); - } -} - -#[tauri::command] -async fn claude_connect( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - let mut cfg = store::read_config(); - let n = cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - if n == 0 { - return Ok(json!({ "ok": false, "reason": "noProvider" })); - } - // Hero "一键接入" with nothing selected connects Claude Code by default (and persists it, so the - // toggle reflects it). - if ensure_hero_connect_target(&mut cfg) { - cfg = store::write_config(cfg); - } - apply_connections(&cfg); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} -#[tauri::command] -async fn claude_disconnect( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - // Master off: restore BOTH CLIs' config files (idempotent). The gateway service keeps its own - // switch — removing the CLI wiring doesn't stop it. - claude::disconnect(); - codexconnect::disconnect(); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} - -/// Independent gateway-service switch: persist `gatewayEnabled` and start/stop the localhost -/// server. CLI config files are untouched — connect/disconnect is a separate, config-only action. -#[tauri::command] -async fn gateway_set_enabled( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - on: bool, -) -> Result { - let mut cfg = store::read_config(); - cfg["gatewayEnabled"] = json!(on); - let saved = store::write_config(cfg); - if on { - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - if let Err(e) = gw.start(port).await { - let msg = format!("port {} failed: {}", port, e); - *LAST_START_ERROR.lock().unwrap() = Some(msg.clone()); - gw.emit("gateway:status", full_status(&gw).await); - refresh_tray_menu(&app); - return Ok(json!({ "ok": false, "reason": "portFailed", "message": msg })); - } - *LAST_START_ERROR.lock().unwrap() = None; - } else { - gw.stop().await; - } - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} - -/// Live per-CLI switch: flip one target on/off, persist the selection, and immediately write or -/// restore that CLI's config file. Config-only — the gateway service has its own switch. -#[tauri::command] -async fn set_connect_target( - app: tauri::AppHandle, - gw: tauri::State<'_, std::sync::Arc>, - target: String, - on: bool, -) -> Result { - let mut cfg = store::read_config(); - if on && cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.is_empty()).unwrap_or(true) { - return Ok(json!({ "ok": false, "reason": "noProvider" })); - } - let mut targets = connect_targets(&cfg); - targets.retain(|t| t != &target); - if on && (target == "claude" || target == "codex") { - targets.push(target.clone()); - } - cfg["connectTargets"] = json!(targets); - let saved = store::write_config(cfg); - apply_connections(&saved); - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - refresh_tray_menu(&app); - Ok(json!({ "ok": true })) -} -fn pct(s: &str) -> String { - s.bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), - _ => format!("%{:02X}", b), - }) - .collect() -} -#[tauri::command] -fn desktop_replay(file: String, prompt: Option) -> Value { - if file.is_empty() { - return json!({ "ok": false, "reason": "noFile" }); - } - if !cfg!(target_os = "macos") { - return json!({ "ok": false, "reason": "unsupported" }); - } - // The full review prompt comes from the renderer's i18n (desktop.replayPrompt) so it stays - // localized; fall back to a minimal default only if the renderer didn't supply one. - let prompt = prompt - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| "请基于这些对话记录在 Claude 桌面版里继续。".to_string()); - // Attach the main session AND every subagent transcript (they live in a separate subagents/ dir), - // each as its own `file=` — the Cowork deep link honors repeated `file=` — so the analysis covers - // subagent runs, not just the main thread. - let mut url = format!("claude://cowork/new?q={}&file={}", pct(&prompt), pct(&file)); - for sub in history::subagent_transcript_paths(&file) { - url.push_str("&file="); - url.push_str(&pct(&sub)); - } - #[cfg(target_os = "macos")] - { - let ok = std::process::Command::new("/usr/bin/open").arg(&url).spawn().is_ok(); - json!({ "ok": ok }) - } - #[cfg(not(target_os = "macos"))] - { - let _ = url; - json!({ "ok": false, "reason": "unsupported" }) - } -} -#[tauri::command] -fn chatgpt_replay(file: String, prompt: Option) -> Value { - if file.is_empty() { - return json!({ "ok": false, "reason": "noFile" }); - } - if !cfg!(target_os = "macos") { - return json!({ "ok": false, "reason": "unsupported" }); - } - // The ChatGPT desktop app (Codex era) keeps the codex:// scheme: codex://new takes - // `prompt` (initial composer text) and `path` (workspace dir). It has no file-attach - // param, so the workspace is pointed at the transcripts' directory and the prompt - // lists the absolute JSONL paths — main session plus every subagent (they live under - // `//subagents/`, inside the same workspace) — for the task to read. - let prompt = prompt - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| "请读取下列 Coding CLI 会话的 JSONL 记录并帮我复盘。".to_string()); - let mut text = prompt; - text.push_str("\n\nTranscripts:\n"); - text.push_str(&file); - for sub in history::subagent_transcript_paths(&file) { - text.push('\n'); - text.push_str(&sub); - } - let mut url = format!("codex://new?prompt={}", pct(&text)); - if let Some(dir) = std::path::Path::new(&file).parent() { - url.push_str("&path="); - url.push_str(&pct(&dir.to_string_lossy())); - } - #[cfg(target_os = "macos")] - { - // `open` exits non-zero when nothing handles the scheme → app not installed. - match std::process::Command::new("/usr/bin/open").arg(&url).status() { - Ok(s) if s.success() => json!({ "ok": true }), - Ok(_) => json!({ "ok": false, "reason": "notInstalled" }), - Err(_) => json!({ "ok": false, "reason": "failed" }), - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = url; - json!({ "ok": false, "reason": "unsupported" }) - } -} - -// ---- server / usage / monitor / logs ---- -async fn full_status(gw: &std::sync::Arc) -> Value { - let mut s = gw.status().await; - let port = gw - .current_port() - .await - .unwrap_or_else(|| store::read_config().get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16); - if let Some(o) = s.as_object_mut() { - let claude_on = claude::is_connected(port); - let codex_on = codexconnect::is_connected(port); - // `connected` = any CLI wired to the gateway (drives the tray "已接入" indicator). - o.insert("connected".into(), json!(claude_on || codex_on)); - o.insert("connectedClaude".into(), json!(claude_on)); - o.insert("connectedCodex".into(), json!(codex_on)); - o.insert("codexAvailable".into(), json!(codexconnect::is_available())); - o.insert( - "gatewayEnabled".into(), - json!(store::read_config().get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true)), - ); - o.insert( - "lastStartError".into(), - LAST_START_ERROR - .lock() - .ok() - .and_then(|g| g.clone()) - .map(Value::String) - .unwrap_or(Value::Null), - ); - o.insert("claudePath".into(), json!(claude::settings_path().to_string_lossy())); - } - s -} -#[tauri::command] -async fn server_status( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - Ok(full_status(&gw).await) -} -#[tauri::command] -fn usage_get(range: Option) -> Value { - let t = std::time::Instant::now(); - let cfg = store::read_config(); - // Usage surfaces (popover heatmap/stats, hero) always aggregate EVERY configured dir — the - // conversations-page directory switcher must not silently filter the calendar down to one CLI. - let r = usage::usage_get(&cfg, "all", range.as_deref().unwrap_or("7d")); - eprintln!( - "[TIMING] usage_get(range={}) {}ms", - range.as_deref().unwrap_or("7d"), - t.elapsed().as_millis() - ); - r -} - -/// Compact token count (mirror of usage.js `formatTokens`): 1234→"1.2K", 4.9e9→"4.9B". -fn format_tokens(n: i64) -> String { - let n = n.max(0); - if n < 1000 { - return n.to_string(); - } - let strip = |s: String| s.strip_suffix(".0").map(|p| p.to_string()).unwrap_or(s); - if n < 1_000_000 { - let v = n as f64 / 1e3; - let s = if n < 10_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; - return format!("{}K", strip(s)); - } - if n < 1_000_000_000 { - let v = n as f64 / 1e6; - let s = if n < 10_000_000 { format!("{:.1}", v) } else { format!("{:.0}", v) }; - return format!("{}M", strip(s)); - } - let v = n as f64 / 1e9; - format!("{}B", strip(format!("{:.1}", v))) -} - -#[cfg(test)] -mod fmt_tests { - use super::{ensure_hero_connect_target, format_tokens, startup_reconcile_targets}; - use serde_json::json; - - #[test] - fn startup_reconciliation_requires_the_targets_own_backup() { - assert!(startup_reconcile_targets(&json!({})).is_empty()); - assert!(startup_reconcile_targets(&json!({ - "connectTargets": ["claude"], - "claudeBackup": null - })) - .is_empty()); - assert!(startup_reconcile_targets(&json!({ - "connectTargets": ["codex"], - "codexBackup": "not-a-backup" - })) - .is_empty()); - - assert_eq!( - startup_reconcile_targets(&json!({ - "connectTargets": ["claude", "codex"], - "claudeBackup": { "model": null, "env": {} }, - "codexBackup": null - })), - vec!["claude"] - ); - assert_eq!( - startup_reconcile_targets(&json!({ - "connectTargets": ["claude", "codex"], - "claudeBackup": null, - "codexBackup": { - "model": "gpt-5", - "model_provider": "openai", - "model_reasoning_effort": null - } - })), - vec!["codex"] - ); - } - - #[test] - fn hero_connect_defaults_to_claude_without_overriding_a_selection() { - let mut fresh = json!({ "connectTargets": [] }); - assert!(ensure_hero_connect_target(&mut fresh)); - assert_eq!(fresh["connectTargets"], json!(["claude"])); - - let mut selected = json!({ "connectTargets": ["codex"] }); - assert!(!ensure_hero_connect_target(&mut selected)); - assert_eq!(selected["connectTargets"], json!(["codex"])); - } - - #[test] - fn matches_js_format_tokens() { - assert_eq!(format_tokens(0), "0"); - assert_eq!(format_tokens(999), "999"); - assert_eq!(format_tokens(1000), "1K"); - assert_eq!(format_tokens(1234), "1.2K"); - assert_eq!(format_tokens(9999), "10K"); - assert_eq!(format_tokens(12_345), "12K"); - assert_eq!(format_tokens(1_000_000), "1M"); - assert_eq!(format_tokens(4_900_000), "4.9M"); - assert_eq!(format_tokens(12_000_000), "12M"); - assert_eq!(format_tokens(1_000_000_000), "1B"); - assert_eq!(format_tokens(4_892_112_447), "4.9B"); - } -} - -/// Set the macOS menu-bar tray title to the configured usage token count (or clear it when -/// trayUsage is off). Heavy work (config read + usage scan) runs on the caller's thread; -/// only the set_title call hops to the main thread, where macOS requires UI mutation. -fn update_tray_title(app: &tauri::AppHandle) { - let config = store::read_config(); - let tu = config.get("trayUsage").cloned().unwrap_or_else(|| json!({})); - let enabled = tu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); - let title: Option = if enabled { - let range = tu.get("range").and_then(|v| v.as_str()).unwrap_or("7d").to_string(); - // Same global scope as the popover — the tray count is a whole-machine number. - let tokens = usage::usage_get(&config, "all", &range) - .get("tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - Some(format!(" {}", format_tokens(tokens))) - } else { - None - }; - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(tray) = app2.tray_by_id("main") { - let _ = tray.set_title(title.as_deref()); - } - }); -} - -// ---- system tray: dynamic, localized context menu (parity with main.js buildTrayMenu) ---- -struct TrayLabels { - running_with: &'static str, - stopped: &'static str, - open_main: &'static str, - stop_gw: &'static str, - start_gw: &'static str, - quit: &'static str, - check_updates: &'static str, -} -fn tray_labels(lang: &str) -> TrayLabels { - match lang { - // config.language stores "zh" (store.rs normalize) — accept both spellings. - "zh" | "zh-CN" => TrayLabels { running_with: "● 网关运行中 · {name}", stopped: "○ 网关已停止", open_main: "打开主界面", stop_gw: "停止网关服务", start_gw: "启动网关服务", quit: "退出 CC Buddy", check_updates: "检查更新…" }, - "zh-TW" => TrayLabels { running_with: "● 閘道執行中 · {name}", stopped: "○ 閘道已停止", open_main: "開啟主視窗", stop_gw: "停止閘道服務", start_gw: "啟動閘道服務", quit: "結束 CC Buddy", check_updates: "檢查更新…" }, - "ja" => TrayLabels { running_with: "● ゲートウェイ稼働中 · {name}", stopped: "○ ゲートウェイ停止中", open_main: "メインウィンドウを開く", stop_gw: "ゲートウェイを停止", start_gw: "ゲートウェイを起動", quit: "CC Buddy を終了", check_updates: "更新を確認…" }, - "ko" => TrayLabels { running_with: "● 게이트웨이 실행 중 · {name}", stopped: "○ 게이트웨이 중지됨", open_main: "메인 창 열기", stop_gw: "게이트웨이 중지", start_gw: "게이트웨이 시작", quit: "CC Buddy 종료", check_updates: "업데이트 확인…" }, - _ => TrayLabels { running_with: "● Gateway running · {name}", stopped: "○ Gateway stopped", open_main: "Open main window", stop_gw: "Stop gateway service", start_gw: "Start gateway service", quit: "Quit CC Buddy", check_updates: "Check for updates…" }, - } -} -fn config_lang(config: &Value) -> String { - config.get("language").and_then(|v| v.as_str()).unwrap_or("en").to_string() -} -fn active_provider_name(config: &Value) -> String { - let id = match config.get("activeProviderId").and_then(|v| v.as_str()) { - Some(i) => i, - None => return String::new(), - }; - config - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| { - arr.iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id)) - .and_then(|p| p.get("name").and_then(|v| v.as_str())) - }) - .unwrap_or("") - .to_string() -} -fn build_tray_menu( - app: &tauri::AppHandle, - running: bool, - provider: &str, - lang: &str, -) -> tauri::Result> { - use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; - let l = tray_labels(lang); - let status_txt = if running { - let name = if provider.is_empty() { "CC Buddy" } else { provider }; - l.running_with.replace("{name}", name) - } else { - l.stopped.to_string() - }; - // Status row is disabled (it's an indicator, like main.js { enabled: false }). - let status_i = MenuItem::with_id(app, "tray_status", status_txt, false, None::<&str>)?; - let open_i = MenuItem::with_id(app, "tray_open", l.open_main, true, None::<&str>)?; - let conn_i = if running { - MenuItem::with_id(app, "tray_gw_stop", l.stop_gw, true, None::<&str>)? - } else { - MenuItem::with_id(app, "tray_gw_start", l.start_gw, true, None::<&str>)? - }; - let check_i = MenuItem::with_id(app, "tray_check", l.check_updates, true, None::<&str>)?; - let quit_i = MenuItem::with_id(app, "tray_quit", l.quit, true, None::<&str>)?; - let sep1 = PredefinedMenuItem::separator(app)?; - let sep2 = PredefinedMenuItem::separator(app)?; - Menu::with_items(app, &[&status_i, &sep1, &open_i, &conn_i, &check_i, &sep2, &quit_i]) -} -/// Rebuild the tray menu to reflect the gateway service state + locale + active provider. -fn refresh_tray_menu(app: &tauri::AppHandle) { - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - let config = store::read_config(); - let running = app2 - .try_state::>() - .map(|s| s.port_sync().is_some()) - .unwrap_or(false); - let provider = active_provider_name(&config); - let lang = config_lang(&config); - if let Ok(menu) = build_tray_menu(&app2, running, &provider, &lang) { - if let Some(tray) = app2.tray_by_id("main") { - let _ = tray.set_menu(Some(menu)); - } - } - }); -} -#[tauri::command] -async fn monitor_get( - gw: tauri::State<'_, std::sync::Arc>, - id: Value, -) -> Result { - let idn = id.as_i64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).unwrap_or(-1); - Ok(gw.monitor_get(idn).await) -} -#[tauri::command] -async fn monitor_clear( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - gw.monitor_clear().await; - Ok(json!(true)) -} -#[tauri::command] -fn logs_get(gw: tauri::State<'_, std::sync::Arc>) -> Value { - gw.logs_snapshot() -} -#[tauri::command] -fn logs_clear(gw: tauri::State<'_, std::sync::Arc>) -> Value { - gw.logs_clear(); - Value::Null -} - -// ---- window / app lifecycle ---- -/// macOS Dock icon follows the main window: Regular (Dock shown) while a window is open, -/// Accessory (menu-bar only) when it's closed. The popover floats over fullscreen apps via its -/// NSPanel regardless of this policy, so showing the Dock icon with the main window is safe. -fn set_dock_visible(app: &tauri::AppHandle, visible: bool) { - #[cfg(target_os = "macos")] - { - let app2 = app.clone(); - let _ = app.run_on_main_thread(move || { - let policy = if visible { - tauri::ActivationPolicy::Regular - } else { - tauri::ActivationPolicy::Accessory - }; - let _ = app2.set_activation_policy(policy); - }); - } -} -#[tauri::command] -fn app_open_main(app: tauri::AppHandle) -> Value { - if let Some(win) = app.get_webview_window("main") { - set_dock_visible(&app, true); - let _ = win.show(); - let _ = win.unminimize(); - let _ = win.set_focus(); - } - Value::Null -} -#[tauri::command] -fn app_quit(app: tauri::AppHandle) -> Value { - app.exit(0); - Value::Null -} -#[tauri::command] fn window_settings_mode(on: bool) -> Value { Value::Null } -#[tauri::command] -fn window_view_min_width(app: tauri::AppHandle, w: i64) -> Value { - if let Some(win) = app.get_webview_window("main") { - let min_w = std::cmp::max(600, if w > 0 { w } else { 900 }) as f64; - let _ = win.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize::new(min_w, 600.0)))); - } - Value::Null -} - -// ---- conversation history ---- -#[tauri::command] -fn history_projects() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!(history::list_projects(&cfg, &active)) -} -#[tauri::command] -fn history_list() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!(history::list_sessions(&cfg, &active, 400)) -} -#[tauri::command] -fn history_get(file: String) -> Value { - history::get_session(&file) -} -#[tauri::command] -async fn history_search(query: String) -> Result { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - // Content scan is read/parse heavy — keep it off the IPC thread so the UI stays responsive. - tauri::async_runtime::spawn_blocking(move || json!(history::search_sessions(&cfg, &active, &query, 120))) - .await - .map_err(|e| e.to_string()) -} -#[tauri::command] -fn history_dirs() -> Value { - let cfg = store::read_config(); - let active = cfg.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - json!({ "dirs": history::dir_stats(&cfg), "active": active }) -} -#[tauri::command] -async fn history_pick_dir() -> Result { - let folder = rfd::AsyncFileDialog::new().set_title("选择工作目录").pick_folder().await; - match folder { - // Return the picked path (home-collapsed to `~/…`) and let the renderer persist it - // via saveConfig, matching the renderer contract. - Some(f) => { - let mut picked = f.path().to_path_buf(); - // If the user drilled into a data subdir (projects/ = Claude, sessions/ = Codex), - // store its parent (the work dir) so both trees are probed correctly. - let name = picked.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()); - if matches!(name.as_deref(), Some("projects") | Some("sessions")) - && !picked.join(name.as_deref().unwrap()).is_dir() - { - if let Some(parent) = picked.parent() { - picked = parent.to_path_buf(); - } - } - let path = store::collapse_home(&picked.to_string_lossy()); - Ok(json!({ "ok": true, "path": path })) - } - None => Ok(json!({ "ok": false, "canceled": true })), - } -} -#[tauri::command] -fn history_set_active(app: tauri::AppHandle, id: String) -> Value { - let mut cfg = store::read_config(); - cfg["historyActive"] = json!(if id.is_empty() { "all".to_string() } else { id }); - let saved = store::write_config(cfg); - let _ = app.emit( - "history:changed", - json!({ "files": [], "active": saved.get("historyActive").cloned().unwrap_or(json!("all")) }), - ); - saved -} -#[tauri::command] -async fn history_import(app: tauri::AppHandle) -> Result { - match rfd::AsyncFileDialog::new().add_filter("对话记录 (.jsonl / .zip)", &["jsonl", "zip"]).set_title("导入对话记录").pick_files().await { - Some(files) => { - let paths: Vec = files.iter().map(|f| f.path().to_string_lossy().to_string()).collect(); - let r = history::import_paths(&paths); - let _ = app.emit("history:changed", json!({ "files": [] })); - Ok(r) - } - None => Ok(json!({ "canceled": true })), - } -} -#[tauri::command] -fn history_import_paths(app: tauri::AppHandle, paths: Value) -> Value { - let list: Vec = paths - .as_array() - .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - let r = history::import_paths(&list); - let _ = app.emit("history:changed", json!({ "files": [] })); - r -} -#[tauri::command] -fn history_remove_import(app: tauri::AppHandle, file: String) -> Value { - let r = history::remove_import(&file); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [] })); - } - r -} -#[tauri::command] -fn history_set_meta(app: tauri::AppHandle, file: String, patch: Value) -> Value { - let cfg = store::read_config(); - let r = history::set_ccbud(&file, &patch, &cfg); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [file] })); - } - r -} -#[tauri::command] -fn history_delete_forever(app: tauri::AppHandle, file: String) -> Value { - let cfg = store::read_config(); - let r = history::delete_session_file(&file, &cfg); - if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { - let _ = app.emit("history:changed", json!({ "files": [] })); - } - r -} -#[tauri::command] -async fn history_export_raw(file: String) -> Result { - let base = exporthtml::export_base_name(&file); - // Antigravity sessions are SQLite DBs (not text) — export the raw bytes as .db so the - // original conversation remains intact. Other foreign sources and Claude/Codex stay - // verbatim text (.jsonl); sessions with subagents keep the existing zip bundle. - let path = std::path::Path::new(&file); - if matches!( - history::foreign_kind(path), - Some(history::Foreign::Antigravity) - ) { - let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; - return match rfd::AsyncFileDialog::new() - .add_filter("SQLite", &["db"]) - .set_file_name(format!("{}.db", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, bytes).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) - } - None => Ok(json!({ "canceled": true })), - }; - } - // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); - // a plain session stays a verbatim .jsonl. import_paths accepts either. - if history::session_has_subagents(&file) { - let bytes = history::export_bundle(&file).map_err(|e| e.to_string())?; - match rfd::AsyncFileDialog::new() - .add_filter("ZIP", &["zip"]) - .set_file_name(format!("{}.zip", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, bytes).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": true })) - } - None => Ok(json!({ "canceled": true })), - } - } else { - let data = history::raw_session_bytes(&file).map_err(|e| e.to_string())?; - match rfd::AsyncFileDialog::new() - .add_filter("JSONL", &["jsonl"]) - .set_file_name(format!("{}.jsonl", base)) - .save_file() - .await - { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, data).map_err(|e| e.to_string())?; - Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) - } - None => Ok(json!({ "canceled": true })), - } - } -} -#[tauri::command] -async fn history_export_html(payload: Value) -> Result { - let file = payload - .get("file") - .and_then(|v| v.as_str()) - .or_else(|| payload.as_str()) - .ok_or("no file")? - .to_string(); - // Build the export data once, then reuse it for both the HTML body and the filename. - let data = exporthtml::build_data(&file); - // An unreadable main transcript surfaces as a command error (renderer reports it) instead of - // silently saving an empty viewer page. - if let Some(error) = data.get("error") { - return Err(error - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("session read failed") - .to_string()); - } - let html = exporthtml::html_from_data(&data); - let base = exporthtml::export_base_name_from_data(&data); - match rfd::AsyncFileDialog::new().set_file_name(format!("{}.html", base)).save_file().await { - Some(d) => { - let p = d.path().to_path_buf(); - std::fs::write(&p, html).map_err(|e| e.to_string())?; - // Open the freshly-exported viewer in the user's default browser (issue #7). - open_path_native(&p); - Ok(json!({ "canceled": false, "path": p.to_string_lossy() })) - } - None => Ok(json!({ "canceled": true })), - } -} - -// ---- utilities ---- -#[tauri::command] -fn util_copy(text: String) -> bool { - match arboard::Clipboard::new() { - Ok(mut cb) => cb.set_text(text).is_ok(), - Err(_) => false, - } -} -#[tauri::command] -fn util_open_external(url: String) -> bool { - if !(url.starts_with("http://") || url.starts_with("https://")) { - return false; - } - let spawned = { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(&url).spawn() - } - #[cfg(target_os = "windows")] - { - std::process::Command::new("cmd").args(["/C", "start", "", &url]).spawn() - } - #[cfg(target_os = "linux")] - { - std::process::Command::new("xdg-open").arg(&url).spawn() - } - }; - spawned.is_ok() -} - -// Open a local file with the OS default handler. Used to pop the freshly-exported HTML viewer in -// the user's browser so they don't have to hunt for it in the filesystem. Best-effort: a spawn -// failure must not fail the export. -fn open_path_native(path: &std::path::Path) { - #[cfg(target_os = "macos")] - let _ = std::process::Command::new("open").arg(path).spawn(); - #[cfg(target_os = "windows")] - let _ = std::process::Command::new("cmd").args(["/C", "start", ""]).arg(path).spawn(); - #[cfg(target_os = "linux")] - let _ = std::process::Command::new("xdg-open").arg(path).spawn(); -} - -// ---- in-app updates ---- -// In-app update state, mapped to the shape the renderer's about/update pane expects -// (runningVersion / latestVersion / mode / pending). Tauri's updater is in-app full → mode "hot". -static UPDATE_LATEST: std::sync::Mutex)>> = - std::sync::Mutex::new(None); -static UPDATE_CHECKED: std::sync::Mutex = std::sync::Mutex::new(false); -static UPDATE_STAGED: std::sync::Mutex = std::sync::Mutex::new(false); -// A download is in flight (manual or auto) — second caller gets "busy" instead of a duplicate. -static UPDATE_DOWNLOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -// Daily auto-check bookkeeping: the day (local YYYY-MM-DD) whose auto check already completed -// (in-memory mirror of the on-disk stamp), an in-flight guard, and the last attempt time so a -// failed attempt (offline) is retried on a later visibility change instead of on every focus. -static AUTO_UPDATE_DONE_DAY: std::sync::Mutex> = std::sync::Mutex::new(None); -static AUTO_UPDATE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); -static AUTO_UPDATE_LAST_TRY_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -const AUTO_UPDATE_RETRY_MS: i64 = 10 * 60 * 1000; - -/// Clears an in-flight flag on drop, so a panic/unwind or an early return can never leave -/// UPDATE_DOWNLOADING / AUTO_UPDATE_RUNNING stuck true for the rest of the process. -struct FlagGuard(&'static std::sync::atomic::AtomicBool); -impl Drop for FlagGuard { - fn drop(&mut self) { - self.0.store(false, std::sync::atomic::Ordering::SeqCst); - } -} - -fn build_update_state(app: &tauri::AppHandle) -> Value { - let cfg = store::read_config(); - let current = app.package_info().version.to_string(); - let latest = UPDATE_LATEST.lock().ok().and_then(|g| g.clone()); - let checked = UPDATE_CHECKED.lock().map(|g| *g).unwrap_or(false); - let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); - let (latest_v, notes, mode) = match (&latest, checked) { - (Some((v, n)), _) => (json!(v), n.clone().map(Value::String).unwrap_or(Value::Null), "hot"), - (None, true) => (Value::Null, Value::Null, "none"), - (None, false) => (Value::Null, Value::Null, "unknown"), - }; - json!({ - "ok": true, - "runningVersion": current, - "shellVersion": current, - "latestVersion": latest_v, - "mode": mode, - "notes": notes, - "pending": if staged { - json!({ "staged": true, "version": latest.as_ref().map(|(v, _)| v.clone()) }) - } else { - Value::Null - }, - "installMethod": "tauri", - "autoUpdate": cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })), - }) -} -#[tauri::command] -fn update_state(app: tauri::AppHandle) -> Value { - build_update_state(&app) -} -/// Hit the updater endpoint and sync UPDATE_CHECKED/UPDATE_LATEST + the renderer's -/// update:state. Shared by the manual update_check command and the daily auto check. -async fn run_update_check(app: &tauri::AppHandle) -> Result, String> { - use tauri_plugin_updater::UpdaterExt; - *UPDATE_CHECKED.lock().unwrap() = true; - let result = match app.updater() { - Ok(updater) => updater.check().await, - Err(e) => Err(e), - }; - match result { - Ok(found) => { - *UPDATE_LATEST.lock().unwrap() = - found.as_ref().map(|u| (u.version.clone(), u.body.clone())); - let _ = app.emit("update:state", build_update_state(app)); - Ok(found) - } - Err(e) => Err(e.to_string()), - } -} -#[tauri::command] -async fn update_check(app: tauri::AppHandle) -> Result { - match run_update_check(&app).await { - Ok(_) => Ok(build_update_state(&app)), - Err(e) => Ok(json!({ - "ok": false, - "error": e, - "runningVersion": app.package_info().version.to_string(), - })), - } -} -/// Download + stage the available update (restart applies it). Shared by the manual -/// update_download command and the daily auto flow; UPDATE_DOWNLOADING dedupes the two. -async fn run_update_download(app: &tauri::AppHandle) -> Result { - use tauri_plugin_updater::UpdaterExt; - if UPDATE_DOWNLOADING.swap(true, std::sync::atomic::Ordering::SeqCst) { - return Err("busy".to_string()); - } - let _busy = FlagGuard(&UPDATE_DOWNLOADING); - let updater = app.updater().map_err(|e| e.to_string())?; - match updater.check().await.map_err(|e| e.to_string())? { - Some(u) => { - u.download_and_install(|_chunk, _total| {}, || {}).await.map_err(|e| e.to_string())?; - *UPDATE_STAGED.lock().unwrap() = true; - let st = build_update_state(app); - let _ = app.emit("update:staged", st.clone()); - let _ = app.emit("update:state", st.clone()); - Ok(st) - } - None => Ok(json!({ "ok": true, "mode": "none" })), - } -} -#[tauri::command] -async fn update_download(app: tauri::AppHandle) -> Result { - run_update_download(&app).await -} -#[tauri::command] -fn update_apply(app: tauri::AppHandle) -> Value { - app.restart(); -} -#[tauri::command] -fn update_set_auto(patch: Value) -> Value { - let mut cfg = store::read_config(); - let mut au = cfg.get("autoUpdate").cloned().unwrap_or(json!({ "check": true, "autoDownload": true })); - if let Some(o) = au.as_object_mut() { - if let Some(c) = patch.get("check") { - o.insert("check".into(), c.clone()); - } - if let Some(d) = patch.get("autoDownload") { - o.insert("autoDownload".into(), d.clone()); - } - } - cfg["autoUpdate"] = au.clone(); - store::write_config(cfg); - au -} - -// ---- daily auto update (first time the app becomes visible each day) ---- -// The stamp lives in its own tiny file (NOT config.json) so the daily writer never races the -// renderer's whole-config round-trips through config_save. -fn auto_update_stamp_file() -> std::path::PathBuf { - store::ccbud_home().join("update-check.json") -} -fn today_local() -> String { - chrono::Local::now().format("%Y-%m-%d").to_string() -} -fn last_auto_update_day() -> String { - std::fs::read_to_string(auto_update_stamp_file()) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.get("lastAutoCheckDay").and_then(|d| d.as_str()).map(|s| s.to_string())) - .unwrap_or_default() -} -fn mark_auto_update_day(day: &str) { - if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { - *g = Some(day.to_string()); - } - let _ = std::fs::create_dir_all(store::ccbud_home()); - let _ = std::fs::write( - auto_update_stamp_file(), - serde_json::to_vec(&json!({ "lastAutoCheckDay": day })).unwrap_or_default(), - ); -} - -// Native restart prompt after an auto-downloaded update (localized like tray_labels — the main -// window may be hidden when the popover triggered the check, so this can't live in the renderer). -struct UpdatePromptLabels { - title: &'static str, - body: &'static str, // {v} → new version - restart: &'static str, - later: &'static str, -} -fn update_prompt_labels(lang: &str) -> UpdatePromptLabels { - match lang { - "zh" | "zh-CN" => UpdatePromptLabels { title: "更新已就绪", body: "新版本 {v} 已自动下载完成。是否立即重启以应用新版本?", restart: "立即重启", later: "稍后" }, - "zh-TW" => UpdatePromptLabels { title: "更新已就緒", body: "新版本 {v} 已自動下載完成。要立即重新啟動以套用新版本嗎?", restart: "立即重啟", later: "稍後" }, - "ja" => UpdatePromptLabels { title: "アップデートの準備ができました", body: "新しいバージョン {v} のダウンロードが完了しました。今すぐ再起動して適用しますか?", restart: "今すぐ再起動", later: "後で" }, - "ko" => UpdatePromptLabels { title: "업데이트 준비 완료", body: "새 버전 {v} 다운로드가 완료되었습니다. 지금 다시 시작하여 적용할까요?", restart: "지금 다시 시작", later: "나중에" }, - _ => UpdatePromptLabels { title: "Update ready", body: "Version {v} has been downloaded. Restart now to switch to the new version?", restart: "Restart now", later: "Later" }, - } -} -async fn prompt_restart_to_apply(app: &tauri::AppHandle) { - let version = UPDATE_LATEST - .lock() - .ok() - .and_then(|g| g.as_ref().map(|(v, _)| v.clone())) - .unwrap_or_default(); - let l = update_prompt_labels(&config_lang(&store::read_config())); - // On Linux this shells out to zenity; without it rfd logs an error and returns Cancel, - // degrading to the staged update applying on the next launch (About pane shows "restart"). - let res = rfd::AsyncMessageDialog::new() - .set_level(rfd::MessageLevel::Info) - .set_title(l.title) - .set_description(l.body.replace("{v}", &version)) - .set_buttons(rfd::MessageButtons::OkCancelCustom(l.restart.to_string(), l.later.to_string())) - .show() - .await; - if matches!(&res, rfd::MessageDialogResult::Custom(s) if s == l.restart) { - app.restart(); - } -} - -/// Called from every "app became visible" site (main window focus, popover show, launch). -/// The first such moment each day — with autoUpdate.check on — runs one update check; when an -/// update exists and autoUpdate.autoDownload is on it's downloaded, then the user is asked -/// whether to restart into the new version (declining leaves it staged for the next launch). -/// The day is stamped only after a flow that reached the network succeeds, so an offline -/// launch doesn't burn the day's only attempt — the next visibility (≥10 min later) retries. -fn auto_update_on_visible(app: &tauri::AppHandle) { - let today = today_local(); - if AUTO_UPDATE_DONE_DAY - .lock() - .map(|g| g.as_deref() == Some(today.as_str())) - .unwrap_or(false) - { - return; - } - if last_auto_update_day() == today { - // Stamped by a previous run of this process instance or a crashed one — mirror it. - if let Ok(mut g) = AUTO_UPDATE_DONE_DAY.lock() { - *g = Some(today); - } - return; - } - if now_ms() - AUTO_UPDATE_LAST_TRY_MS.load(std::sync::atomic::Ordering::Relaxed) < AUTO_UPDATE_RETRY_MS { - return; - } - if AUTO_UPDATE_RUNNING.swap(true, std::sync::atomic::Ordering::SeqCst) { - return; - } - let running = FlagGuard(&AUTO_UPDATE_RUNNING); - let au = store::read_config().get("autoUpdate").cloned().unwrap_or_else(|| json!({})); - if !au.get("check").and_then(|v| v.as_bool()).unwrap_or(true) { - return; // `running` drops here and clears the flag - } - AUTO_UPDATE_LAST_TRY_MS.store(now_ms(), std::sync::atomic::Ordering::Relaxed); - let auto_dl = au.get("autoDownload").and_then(|v| v.as_bool()).unwrap_or(true); - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let _running = running; // held until the task ends (cleared even on panic/unwind) - match run_update_check(&app).await { - Ok(None) => mark_auto_update_day(&today), - Ok(Some(_)) => { - let staged = UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false); - if staged { - // Downloaded on an earlier day but never restarted — just re-ask. - mark_auto_update_day(&today); - prompt_restart_to_apply(&app).await; - } else if !auto_dl { - mark_auto_update_day(&today); // surfaced in the About pane only - } else { - match run_update_download(&app).await { - Ok(_) => { - mark_auto_update_day(&today); - if UPDATE_STAGED.lock().map(|g| *g).unwrap_or(false) { - prompt_restart_to_apply(&app).await; - } - } - // A manual download is already in flight — the user took over today's - // update (the About pane drives the rest), so the day is done. - Err(e) if e == "busy" => mark_auto_update_day(&today), - Err(_) => {} // download failed → day left unstamped so a later visibility retries - } - } - } - Err(_) => {} // check failed (offline?) → retry on a later visibility - } - }); -} - -// ---- debug self-check (gated by CCBUD_SELFCHECK env; injected via on_page_load) ---- -#[tauri::command] -fn selfcheck_report(report: Value) { - let line = serde_json::to_string(&report).unwrap_or_default(); - eprintln!("[SELFCHECK] {}", line); - // Also append to a file when CCBUD_SELFCHECK_OUT is set — a GUI-session run - // (open .app via launchd) has no terminal-attached stderr to read. - if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { - let _ = writeln!(f, "{}", line); - } - } -} -#[tauri::command] -fn selfcheck_routing() -> Value { - gateway::routing_selftest() -} -#[tauri::command] -fn selfcheck_history() -> Value { - history::history_selftest(&store::ccbud_home()) -} -#[tauri::command] -fn selfcheck_import() -> Value { - history::import_selftest(&store::ccbud_home()) -} -#[tauri::command] -fn selfcheck_export() -> Value { - let base = store::ccbud_home(); - let _ = history::history_selftest(&base); - let file = base.join("test-claude").join("projects").join("-test-cwd").join("sess1.jsonl"); - let html = exporthtml::build_export_html(&file.to_string_lossy()); - json!({ - "len": html.len(), - "hasConv": html.contains("__CONV__"), - "hasContent": html.contains("hello world from selfcheck"), - "hasSkin": html.contains(""), - "embedded": html.len() > 180000, - "validHtml": html.starts_with(""), - }) -} -#[tauri::command] -fn selfcheck_popover(app: tauri::AppHandle) -> Value { - let pop = match app.get_webview_window("popover") { - Some(p) => p, - None => return json!({ "err": "no popover window" }), - }; - let mon = match pop.current_monitor() { - Ok(Some(m)) => m, - _ => return json!({ "err": "no monitor" }), - }; - let scale = mon.scale_factor(); - let pw = (424.0 * scale) as i32; - let sx = mon.position().x; - let sy = mon.position().y; - let sw = mon.size().width as i32; - let sh = mon.size().height as i32; - // Simulate a tray icon at the top-right of the menu bar, run the same placement - // math as the real tray click, then read back where the window actually lands. - let tray_cx = sx + sw - (12.0 * scale) as i32; - let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); - let y = sy + (26.0 * scale) as i32; - // macOS window ops must run on the main thread — the real tray callback already - // does; here we hop onto it explicitly and read back inside the same closure so - // the probe sees the post-move geometry without a cross-thread timing race. - let (tx, rx) = std::sync::mpsc::channel(); - let pop2 = pop.clone(); - let _ = app.run_on_main_thread(move || { - let _ = pop2.show(); - let _ = pop2.set_position(tauri::PhysicalPosition::new(x, y)); - let pos = pop2.outer_position().ok().map(|p| (p.x, p.y)); - let size = pop2.outer_size().ok().map(|s| (s.width as i32, s.height as i32)); - let _ = pop2.hide(); - let _ = tx.send((pos, size)); - }); - let (pos, size) = rx - .recv_timeout(std::time::Duration::from_millis(1500)) - .unwrap_or((None, None)); - let in_screen = match (pos, size) { - (Some((px, py)), Some((sw2, sh2))) => { - px >= sx && py >= sy && (px + sw2) <= (sx + sw + 2) && (py + sh2) <= (sy + sh + 2) - } - _ => false, - }; - json!({ - "scale": scale, - "monitor": [sx, sy, sw, sh], - "computed": [x, y], - "popPos": pos.map(|(a, b)| json!([a, b])), - "popSize": size.map(|(a, b)| json!([a, b])), - "inScreen": in_screen, - }) -} -#[tauri::command] -async fn selfcheck_gateway( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - // Mutates config (writes a mock provider) — only ever allowed in a throwaway self-check run. - if std::env::var("CCBUD_SELFCHECK").is_err() { - return Err("selfcheck disabled".into()); - } - let port = gw.current_port().await.unwrap_or(0); - let mut r = gateway::gateway_selftest(port).await; - let sse_ex = gw.monitor_recent().await; // last recorded by gateway_selftest = the SSE exchange - // Exercise HEAD / (mock 404 → gateway fallback 200 → recorded) to verify monitor detail + ms. - let head_status = reqwest::Client::new() - .head(format!("http://127.0.0.1:{}/", port)) - .send() - .await - .map(|x| x.status().as_u16()) - .unwrap_or(0); - tokio::time::sleep(std::time::Duration::from_millis(60)).await; - let head_ex = gw.monitor_recent().await; // now the HEAD exchange - if let Some(o) = r.as_object_mut() { - let req_ok = sse_ex.get("reqBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); - let res_ok = sse_ex.get("resBody").and_then(|b| b.get("text")).and_then(|t| t.as_str()).map(|s| !s.is_empty()).unwrap_or(false); - let redacted = sse_ex.get("reqHeaders").map(|h| h.to_string().contains("已隐藏")).unwrap_or(false); - o.insert("monitorReqBody".into(), json!(req_ok)); - o.insert("monitorResBody".into(), json!(res_ok)); - o.insert("monitorRedacted".into(), json!(redacted)); - o.insert("recordHasMs".into(), json!(sse_ex.get("ms").map(|v| v.is_number()).unwrap_or(false))); - o.insert("headStatus".into(), json!(head_status)); - o.insert( - "headMonitored".into(), - json!(head_ex.get("method").and_then(|m| m.as_str()) == Some("HEAD") - && head_ex.get("reqHeaders").map(|h| h.is_object()).unwrap_or(false) - && head_ex.get("ms").map(|v| v.is_number()).unwrap_or(false)), - ); - } - Ok(r) -} -const SELFCHECK_JS: &str = r#" -(function(){ - if (window.__ccbud_sc) return; window.__ccbud_sc = 1; - window.__ccbud_errors = []; - window.addEventListener('error', function(e){ try{window.__ccbud_errors.push(String((e&&e.message)||(e&&e.error)||e));}catch(_){} }, true); - window.addEventListener('unhandledrejection', function(e){ try{window.__ccbud_errors.push('promise:'+String((e.reason&&e.reason.message)||e.reason));}catch(_){} }); - function rep(o){ try{ window.__TAURI__.core.invoke('selfcheck_report',{report:o}); }catch(_){} } - setTimeout(async function(){ - var o={}; - try{ - o.hasCcbud=!!window.ccbud; - o.hasTauri=!!(window.__TAURI__&&window.__TAURI__.core); - o.bodyLen=(document.body&&document.body.innerHTML.length)||0; - o.navItems=document.querySelectorAll('.nav-item,[data-view],[data-nav]').length; - o.colorMix=!!(window.CSS&&CSS.supports&&CSS.supports('color','color-mix(in srgb,red,blue)')); - o.highlight=!!(window.CSS&&CSS.highlights); - // store round-trip — self-check runs point CCBUD_HOME at a throwaway dir - try{ - var before=await window.ccbud.getConfig(); - o.provBefore=((before&&before.providers)||[]).length; - var saved=await window.ccbud.upsertProvider({name:'SelfTest',baseUrl:'https://x.test',authToken:'tok',defaultModel:'m1',smallFastModel:'m1',extra:'shouldDrop'}); - o.provAfter=((saved&&saved.providers)||[]).length; - o.savedName=saved&&saved.providers&&saved.providers[0]&&saved.providers[0].name; - o.savedHasId=!!(saved&&saved.providers&&saved.providers[0]&&saved.providers[0].id); - o.savedActiveMatches=!!(saved&&saved.activeProviderId&&saved.providers[0]&&saved.activeProviderId===saved.providers[0].id); - o.droppedExtra=!(saved&&saved.providers&&saved.providers[0]&&('extra' in saved.providers[0])); - var reread=await window.ccbud.getConfig(); - o.rereadProv=((reread&&reread.providers)||[]).length; - }catch(e){ o.storeErr=String(e); } - try{ o.routing=await window.__TAURI__.core.invoke('selfcheck_routing'); }catch(e){ o.routingErr=String(e); } - try{ o.server=await window.ccbud.serverStatus(); }catch(e){ o.serverErr=String(e); } - try{ o.gateway=await window.__TAURI__.core.invoke('selfcheck_gateway'); }catch(e){ o.gatewayErr=String(e); } - try{ - o.histDirs=(await window.ccbud.historyDirs()).dirs.length; - var hl=await window.ccbud.historyList(); - o.histCount=(hl||[]).length; - o.histSample=hl&&hl[0]?{title:String(hl[0].title||'').slice(0,40),project:hl[0].project,hasCwd:!!hl[0].cwd,hasFile:!!hl[0].file}:null; - if(hl&&hl[0]){ var ss=await window.ccbud.historyGet(hl[0].file); o.histMsgs=ss&&ss.messages?ss.messages.length:-1; o.histTotals=ss&&ss.meta?ss.meta.totals:null; } - }catch(e){ o.histErr=String(e); } - try{ var ug=await window.ccbud.usageGet('all'); o.usage={tokens:ug.tokens,requests:ug.requests,fav:ug.favoriteModel,heatmap:(ug.heatmap||[]).length,byModel:(ug.byModel||[]).length,activeDays:ug.activeDays}; }catch(e){ o.usageErr=String(e); } - try{ var cc=await window.ccbud.connect(); var s1=await window.ccbud.serverStatus(); var dd=await window.ccbud.disconnect(); var s2=await window.ccbud.serverStatus(); o.claude={connOk:cc&&cc.ok,connected:s1.connected,discOk:dd&&dd.ok,afterDisc:s2.connected}; }catch(e){ o.claudeErr=String(e); } - try{ o.copyOk=await window.ccbud.copy('selfcheck-clip'); }catch(e){ o.copyErr=String(e); } - try{ o.histMeta=await window.__TAURI__.core.invoke('selfcheck_history'); }catch(e){ o.histMetaErr=String(e); } - try{ o.export=await window.__TAURI__.core.invoke('selfcheck_export'); }catch(e){ o.exportErr=String(e); } - try{ o.import=await window.__TAURI__.core.invoke('selfcheck_import'); }catch(e){ o.importErr=String(e); } - try{ var us=await window.ccbud.updateState(); var sa=await window.ccbud.updateSetAuto({check:false}); o.update={current:us.current,status:us.status,setAutoCheck:sa.check}; }catch(e){ o.updateErr=String(e); } - try{ o.drag={regions:document.querySelectorAll('.drag-region').length,wired:document.querySelectorAll('[data-tauri-drag-region]').length}; }catch(e){ o.dragErr=String(e); } - try{ var cs=getComputedStyle(document.body); o.userSelect=cs.webkitUserSelect||cs.userSelect; }catch(e){} - try{ var ep=document.getElementById('endpoint'); var eb=document.getElementById('exportBlock'); o.epSel=ep?getComputedStyle(ep).webkitUserSelect:'-'; o.ebSel=eb?getComputedStyle(eb).webkitUserSelect:'-'; }catch(e){} - try{ o.popoverPos=await window.__TAURI__.core.invoke('selfcheck_popover'); }catch(e){ o.popoverPosErr=String(e); } - o.errors=window.__ccbud_errors.slice(0,20); - }catch(e){o.fatal=String((e&&e.stack)||e);} - rep(o); - },2200); -})(); -"#; - -const POPOVER_SELFCHECK_JS: &str = r#" -(function(){ - setTimeout(async function(){ - var o={win:"popover"}; - try{ o.hasCcbud=!!window.ccbud; var u=await window.ccbud.usageGet("all"); o.usageTokens=u?u.tokens:"null"; o.heatmapLen=u&&u.heatmap?u.heatmap.length:-1; o.heatmapFilled=u&&u.heatmap?u.heatmap.filter(function(c){return c.level>0;}).length:-1; }catch(e){ o.usageErr=String(e); } - try{ var st=document.getElementById("sTokens"); o.sTokensText=st?st.textContent:"noel"; var hm=document.getElementById("heatmap"); o.heatCells=hm?hm.children.length:-1; }catch(e){} - try{ - o.innerW=window.innerWidth; o.innerH=window.innerHeight; o.scrollH=document.body.scrollHeight; - var st2=document.getElementById("sTokens"); if(st2){var r=st2.getBoundingClientRect(); o.sTokTop=Math.round(r.top); o.sTokVisible=(r.top>=0&&r.bottom<=window.innerHeight);} - var hm2=document.getElementById("heatmap"); if(hm2){var hr=hm2.getBoundingClientRect(); o.hmTop=Math.round(hr.top); o.hmBottom=Math.round(hr.bottom);} - o.bodyBg=getComputedStyle(document.body).backgroundColor; - var root=document.querySelector(".pop-body-root"); o.rootBg=root?getComputedStyle(root).backgroundColor:"noel"; - }catch(e){ o.visErr=String(e); } - try{ window.__TAURI__.core.invoke("selfcheck_report",{report:o}); }catch(_){} - }, 1500); -})(); -"#; - -/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The -/// in-app updater swaps the bundle's contents but never the folder itself, and -/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName -/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back -/// to the folder name. Rename the bundle once, relaunch from the new path so -/// Launch Services re-registers it, and exit. Bails out on any obstacle -/// (translocation, read-only volume, name already taken) and keeps running under -/// the old name. -#[cfg(target_os = "macos")] -fn migrate_legacy_bundle_name() { - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(_) => return, - }; - // exe = /.app/Contents/MacOS/ - let bundle = match exe.ancestors().nth(3) { - Some(p) - if matches!( - p.file_name().and_then(|n| n.to_str()), - Some("ccbud.app") | Some("CCBuddy.app") - ) => - { - p.to_path_buf() - } - _ => return, - }; - let target = match bundle.parent() { - Some(dir) => dir.join("CC Buddy.app"), - None => return, - }; - if target.exists() || std::fs::rename(&bundle, &target).is_err() { - return; - } - // `open -n` asks Launch Services to start a fresh instance from the new path - // (which also re-registers the name). Wait for its verdict rather than exiting - // on spawn: a refusal must restore the old name so the running process keeps a - // valid bundle path behind it instead of leaving the user with nothing open. - let launched = std::process::Command::new("/usr/bin/open") - .arg("-n") - .arg(&target) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if launched { - std::process::exit(0); - } - let _ = std::fs::rename(&target, &bundle); -} +// Every #[tauri::command] lives in a topic module under commands/; re-exported here so +// generate_handler! below can keep naming them unqualified, and so the paths other modules +// already use (crate::refresh_tray_menu, crate::update_tray_title, …) keep resolving. +pub(crate) use commands::*; +pub(crate) use tray::{refresh_tray_menu, update_tray_title}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Must run before the builder: it may rename the bundle and hand off to a // fresh instance at the new path. #[cfg(target_os = "macos")] - migrate_legacy_bundle_name(); + startup::migrate_legacy_bundle_name(); #[allow(unused_mut)] let mut builder = tauri::Builder::default() @@ -1832,411 +89,23 @@ pub fn run() { .build(), )?; } - // One-time migrations: detected installs of the other coding CLIs (Codex, Grok - // Build, Copilot CLI, Antigravity CLI, Qoder) and an XDG Claude tree join - // historyDirs as regular work dirs. Runs BEFORE the history watcher so their trees - // get watched. - store::ensure_codex_dir(); - store::ensure_xdg_claude_dir(); - store::ensure_grok_dir(); - store::ensure_copilot_dir(); - store::ensure_antigravity_dir(); - store::ensure_qoder_dir(); - - // Start the localhost gateway on the configured port (proxy.js parity). + // Gateway + plugin manager are managed immediately so the IPC surface is live the + // moment the webview loads. Every filesystem-heavy boot step (one-time migrations, + // plugin reconcile, CLI connection repair, gateway/plugin start, history watcher + // registration, usage-cache warm) runs off the main thread — see + // startup::spawn_background_boot — so the window paints and responds instantly. let gw = gateway::GatewayState::new(app.handle().clone()); app.manage(gw.clone()); - // Sidecar plugin manager (see plugin.rs) — discovers, launches, and health-gates - // coding-agent plugins, surfacing each installed one as a backend:"plugin" provider. let pm = plugin::PluginManager::new(); - pm.sync_providers(); // reconcile services with installed plugins on boot - let pm_boot = pm.clone(); - app.manage(pm); + app.manage(pm.clone()); let startup_cfg = store::read_config(); - // Repair previously managed targets that remain selected. A compatibility backup is - // required per target because old defaults could persist `["claude"]` without any - // connection action; startup never connects a first-time target or disconnects one. - reconcile_connections_on_startup(&startup_cfg); - // Rewrite the login item to the current exe path — in-place hot updates - // (and the one-time bundle rename above) otherwise leave it pointing at - // a binary that no longer exists. - if startup_cfg.get("openAtLogin").and_then(|v| v.as_bool()).unwrap_or(false) { - use tauri_plugin_autostart::ManagerExt; - let _ = app.autolaunch().enable(); - } - let port = startup_cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let enabled = startup_cfg.get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true); - // If the active service is plugin-backed, remember its plugin id so we can - // auto-start it on boot — otherwise the active service would be dead until - // the user re-enables the plugin. - let active_plugin_id = startup_cfg - .get("activeProviderId") - .and_then(|v| v.as_str()) - .and_then(|aid| { - startup_cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().find(|p| p.get("id").and_then(|v| v.as_str()) == Some(aid))) - }) - .filter(|p| p.get("backend").and_then(|v| v.as_str()) == Some("plugin")) - .and_then(|p| p.get("pluginId").and_then(|v| v.as_str())) - .map(|s| s.to_string()); - let app_for_tray = app.handle().clone(); - tauri::async_runtime::spawn(async move { - if enabled { - if let Err(e) = gw.start(port).await { - eprintln!("[ccbud] gateway start failed: {}", e); - } - } - if let Some(pid) = active_plugin_id { - if let Err(e) = pm_boot.start(&pid).await { - eprintln!("[ccbud] active plugin '{}' start failed: {}", pid, e); - } - } - refresh_tray_menu(&app_for_tray); - }); + startup::spawn_background_boot(app.handle().clone(), gw, pm, startup_cfg.clone()); - // System tray: icon + dynamic i18n menu (status / open / connect-or-disconnect / - // check-updates / quit, parity with main.js buildTrayMenu) + click-to-open popover. - { - use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; - let init_cfg = store::read_config(); - let menu = build_tray_menu(app.handle(), false, "", &config_lang(&init_cfg))?; - // Menu-bar icon: monochrome template (like other macOS apps), auto black/white. - let tray_img = tauri::image::Image::from_bytes(include_bytes!("../../build/iconTemplate.png")) - .unwrap_or_else(|_| app.default_window_icon().cloned().unwrap()); - let _ = TrayIconBuilder::with_id("main") - .icon(tray_img) - .icon_as_template(true) - .tooltip("CC Buddy") - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "tray_open" => { - if let Some(w) = app.get_webview_window("main") { - set_dock_visible(app, true); - let _ = w.show(); - let _ = w.unminimize(); - let _ = w.set_focus(); - } - } - // Tray toggles the gateway SERVICE (start/stop), never the CLI configs. - "tray_gw_start" | "tray_gw_stop" => { - let on = event.id.as_ref() == "tray_gw_start"; - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let mut cfg = store::read_config(); - cfg["gatewayEnabled"] = json!(on); - let saved = store::write_config(cfg); - let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - let gw = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(gw) = gw { - if on { - let _ = gw.start(port).await; - } else { - gw.stop().await; - } - let status = full_status(&gw).await; - gw.emit("gateway:status", status); - } - refresh_tray_menu(&app); - }); - } - "tray_check" => { - if let Some(w) = app.get_webview_window("main") { - set_dock_visible(app, true); - let _ = w.show(); - let _ = w.unminimize(); - let _ = w.set_focus(); - } - // Open the About/update pane shortly after the window is up (main.js parity). - let app2 = app.clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let _ = app2.emit("update:openPane", json!({})); - }); - } - "tray_quit" => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - rect, - .. - } = event - { - let app = tray.app_handle(); - if let Some(pop) = app.get_webview_window("popover") { - #[cfg(target_os = "macos")] - let vis_before = { - use tauri_nspanel::ManagerExt as _; - app.get_webview_panel("popover") - .map(|p| p.is_visible()) - .unwrap_or(false) - }; - #[cfg(not(target_os = "macos"))] - let vis_before = pop.is_visible().unwrap_or(false); - let debounced = now_ms() - - LAST_POPOVER_HIDE_MS - .load(std::sync::atomic::Ordering::Relaxed) - < 250; - let action; - if vis_before { - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt as _; - if let Ok(p) = app.get_webview_panel("popover") { - p.order_out(None); - } - } - #[cfg(not(target_os = "macos"))] - let _ = pop.hide(); - LAST_POPOVER_HIDE_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - action = "hide"; - } else if debounced { - // Debounce: clicking the tray first blurs (hides) the popover; - // without this the same click would re-show it instantly. - action = "debounce_skip"; - } else { - // Center under the tray icon, clamped to the monitor (rect + - // scale are physical px, so retina is handled correctly). - // - // Pick the monitor the TRAY icon sits on. pop.current_monitor() is the - // monitor the (hidden) popover window last sat on, which on a - // multi-display setup is often NOT the screen whose menu bar was - // clicked; using it clamps the popover to the wrong monitor's - // bounds. Find the monitor whose physical bounds contain the tray - // rect (each candidate's own scale converts the rect to px). - let mon = pop - .available_monitors() - .ok() - .and_then(|mons| { - mons.into_iter().find(|m| { - let p = rect - .position - .to_physical::(m.scale_factor()); - let mp = m.position(); - let ms = m.size(); - p.x >= mp.x as f64 - && p.x < mp.x as f64 + ms.width as f64 - && p.y >= mp.y as f64 - && p.y < mp.y as f64 + ms.height as f64 - }) - }) - .or_else(|| pop.current_monitor().ok().flatten()) - .or_else(|| pop.primary_monitor().ok().flatten()); - let geom = mon.map(|mon| { - let scale = mon.scale_factor(); - let pw = (424.0 * scale) as i32; - let sx = mon.position().x; - let sw = mon.size().width as i32; - let tray_pos = rect.position.to_physical::(scale); - let tray_size = rect.size.to_physical::(scale); - let tray_cx = (tray_pos.x + tray_size.width / 2.0) as i32; - let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); - let y = (tray_pos.y + tray_size.height + 2.0) as i32; - tauri::PhysicalPosition::new(x, y) - }); - if let Some(p) = geom { - let _ = pop.set_position(p); - } - // Show via the NSPanel: nonactivating, so it appears on the - // CURRENT Space (incl. a fullscreen app's) without activating - // ccbud or switching Spaces. - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt as _; - if let Ok(p) = app.get_webview_panel("popover") { - p.show(); - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = pop.show(); - let _ = pop.set_focus(); - } - if let Some(p) = geom { - let _ = pop.set_position(p); - } - let _ = app.emit("popover:show", ()); - LAST_POPOVER_SHOW_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - // The popover appearing counts as "app became visible today". - auto_update_on_visible(app); - action = "show"; - } - if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - { - let _ = writeln!( - f, - "{}", - json!({ "trayClick": action, "visBefore": vis_before }) - ); - } - } - } - } - }) - .build(app)?; - } + trayicon::install_tray(app, &startup_cfg)?; - // Popover behavior: (1) float on the current Space AND over fullscreen apps; - // (2) auto-hide when it loses focus — clicking anywhere else closes it. - if let Some(pop) = app.get_webview_window("popover") { - // macOS: convert the popover into a non-activating NSPanel. Unlike a plain window, - // a nonactivating panel can float on the CURRENT Space — including another app's - // fullscreen Space — and shows without activating ccbud or switching Spaces. - #[cfg(target_os = "macos")] - { - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior as CB; - use tauri_nspanel::WebviewWindowExt as _; - if let Ok(panel) = pop.to_panel() { - panel.set_style_mask((1 << 7) as i32); // NSWindowStyleMaskNonactivatingPanel - panel.set_collection_behaviour( - CB::NSWindowCollectionBehaviorCanJoinAllSpaces - | CB::NSWindowCollectionBehaviorFullScreenAuxiliary - | CB::NSWindowCollectionBehaviorStationary, - ); - panel.set_floating_panel(true); - panel.set_level(24); // ~NSMainMenuWindowLevel: above fullscreen content - panel.set_hides_on_deactivate(false); - panel.set_released_when_closed(false); - } - } - let pop2 = pop.clone(); - pop.on_window_event(move |event| { - // Bind + deref: `Focused(false)` as a literal pattern does NOT match against - // &WindowEvent here (match ergonomics), so the handler would never fire. - if let tauri::WindowEvent::Focused(focused) = event { - if !*focused { - // Grace period: a fullscreen app steals focus the instant the popover - // shows; ignore that blur so it isn't hidden before being seen. A real - // click-away blur arrives well after the show. - if now_ms() - - LAST_POPOVER_SHOW_MS.load(std::sync::atomic::Ordering::Relaxed) - >= 400 - { - let _ = pop2.hide(); - LAST_POPOVER_HIDE_MS - .store(now_ms(), std::sync::atomic::Ordering::Relaxed); - } - } - } - }); - } + popover::setup_popover(app); - // Daily auto update, triggered by the app becoming visible (see auto_update_on_visible). - // Main-window focus covers launch, tray "open main", Dock/taskbar switches and the - // single-instance re-open; the popover-show branch of the tray click covers tray-only days. - if let Some(main) = app.get_webview_window("main") { - let h = app.handle().clone(); - main.on_window_event(move |event| { - if let tauri::WindowEvent::Focused(focused) = event { - if *focused { - auto_update_on_visible(&h); - } - } - }); - } - // Launch counts as today's first visibility even if no focus event fires (e.g. an - // autostarted login launch that opens unfocused). Delayed a few seconds so the - // network/gateway are up before the first check. - { - let h = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let visible = h - .get_webview_window("main") - .and_then(|w| w.is_visible().ok()) - .unwrap_or(false); - if visible { - auto_update_on_visible(&h); - } - }); - } - - // Tray usage title: show the configured token count next to the menu-bar icon - // (macOS), refreshed on a timer so it tracks new usage without any user action. - { - let h = app.handle().clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(1500)); - loop { - update_tray_title(&h); - std::thread::sleep(std::time::Duration::from_secs(60)); - } - }); - } - - // History live-watch: fs events on the projects dirs → history:changed. - { - use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult}; - let app_w = app.handle().clone(); - if let Ok(mut deb) = new_debouncer( - std::time::Duration::from_millis(250), - move |res: DebounceEventResult| { - if let Ok(events) = res { - let files: Vec = events.iter().map(|e| e.path.to_string_lossy().to_string()).collect(); - let _ = app_w.emit("history:changed", json!({ "files": files })); - // History changed → drop the stale usage cache and re-warm off-thread - // (+ refresh the tray title) so the next popover open stays instant. - usage::invalidate_cache(); - let h = app_w.clone(); - std::thread::spawn(move || { - let cfg = store::read_config(); - usage::warm_cache(&cfg, "all"); - if let Some(g) = h.try_state::>() { - g.log("info", usage::diag(&cfg, "all")); - } - update_tray_title(&h); - }); - } - }, - ) { - for root in history::watch_roots(&store::read_config()) { - if root.is_dir() { - let _ = deb.watcher().watch(&root, RecursiveMode::Recursive); - } - } - std::mem::forget(deb); // keep watching for the app's lifetime - } - } - - // Warm the usage cache at startup (off the click path) so the FIRST popover open is - // instant instead of paying the ~0.5s cold-scan cost. - { - let cfg = store::read_config(); - let h = app.handle().clone(); - std::thread::spawn(move || { - usage::warm_cache(&cfg, "all"); - // Surface the scan shape in the settings Logs panel — the first place to look - // when the usage numbers look wrong. - if let Some(g) = h.try_state::>() { - g.log("info", usage::diag(&cfg, "all")); - } - }); - } - - // Reflect persisted Claude Code connection state in the tray menu on launch (the menu - // is built optimistically as "disconnected"; this corrects it if settings.json already - // points at us). - { - let h = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - refresh_tray_menu(&h); - }); - } + popover::setup_window_hooks(app); Ok(()) }) diff --git a/src-tauri/src/plugin.rs b/src-tauri/src/plugin.rs deleted file mode 100644 index 05005b6..0000000 --- a/src-tauri/src/plugin.rs +++ /dev/null @@ -1,959 +0,0 @@ -// Sidecar plugin manager. -// -// A ccbud plugin is a standalone local program that reuses some coding agent's -// subscription login (e.g. Grok) and exposes a standard inference endpoint on -// localhost. The host does not do protocol/vendor work for it — see -// docs/plugin-system.md. This module owns the piece the gateway can't: process -// lifecycle, port assignment, and health gating. -// -// Key design choice: a running plugin is surfaced as an ordinary provider whose -// baseUrl points at the plugin's localhost port. Enabling a plugin upserts a -// `backend:"plugin"` provider (id = `plugin:`); disabling only stops the process -// (the service stays, removed on uninstall). The -// gateway then routes to it with zero plugin-specific code. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use serde_json::{json, Value}; - -use crate::store; - -/// A plugin's parsed manifest (plugin.json). Only the fields the host needs. -pub struct Manifest { - pub dir: PathBuf, - pub id: String, - pub name: String, - pub version: String, - pub description: String, - /// Optional icon file relative to the plugin dir, e.g. "icon.svg". - pub icon: String, - /// endpoint.protocol → provider wire protocol. - pub protocol: String, - /// endpoint.basePath, e.g. "/v1". - pub base_path: String, - /// endpoint.healthPath, e.g. "/healthz". - pub health_path: String, - /// endpoint.readyTimeoutMs. - pub ready_timeout_ms: u64, - /// runtime.exec: { "-": "bin/..." }. - exec: Value, - /// runtime.args, with {port}/{home} placeholders. - args: Vec, - /// (alias, upstream) model pairs. - pub models: Vec<(String, String)>, - pub primary: String, - pub light: String, - /// Control-plane auth status path (read-only; the plugin reuses a CLI login). - pub auth_status_path: String, - /// source.git — upstream git repo used for install/update (optional). - pub source_git: String, - pub source_branch: String, - /// source.build — shell command run in the clone to produce the binary. - pub source_build: String, - /// ui.actions — plugin-declared buttons/forms. Raw objects: the renderer draws - /// them (label/kind/fields/url), the host reads submitPath/loadPath to forward - /// a click to the plugin's control plane. See docs/plugin-system.md. - pub actions: Vec, -} - -impl Manifest { - fn load(dir: PathBuf) -> Option { - let raw = std::fs::read(dir.join("plugin.json")).ok()?; - let v: Value = serde_json::from_slice(&raw).ok()?; - let id = v.get("id")?.as_str()?.to_string(); - - let s = |path: &[&str], default: &str| -> String { - let mut cur = &v; - for k in path { - match cur.get(*k) { - Some(next) => cur = next, - None => return default.to_string(), - } - } - cur.as_str().unwrap_or(default).to_string() - }; - - let args = v - .get("runtime") - .and_then(|r| r.get("args")) - .and_then(|a| a.as_array()) - .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) - .unwrap_or_else(|| vec!["serve".into(), "--port".into(), "{port}".into(), "--home".into(), "{home}".into()]); - - let mut models = vec![]; - if let Some(arr) = v.get("models").and_then(|m| m.as_array()) { - for m in arr { - let alias = m.get("alias").and_then(|x| x.as_str()).unwrap_or("").to_string(); - let upstream = m - .get("upstream") - .and_then(|x| x.as_str()) - .unwrap_or(alias.as_str()) - .to_string(); - if !alias.is_empty() { - models.push((alias, upstream)); - } - } - } - - let ready_timeout_ms = v - .get("endpoint") - .and_then(|e| e.get("readyTimeoutMs")) - .and_then(|x| x.as_u64()) - .unwrap_or(8000); - - // ui.actions: keep only well-formed objects that carry an id. - let actions = v - .get("ui") - .and_then(|u| u.get("actions")) - .and_then(|a| a.as_array()) - .map(|a| { - a.iter() - .filter(|x| x.get("id").and_then(|i| i.as_str()).map(|s| !s.is_empty()).unwrap_or(false)) - .cloned() - .collect() - }) - .unwrap_or_default(); - - Some(Manifest { - dir, - id, - name: s(&["name"], "Plugin"), - version: s(&["version"], "0.0.0"), - description: s(&["description"], ""), - icon: s(&["icon"], ""), - protocol: s(&["endpoint", "protocol"], "openai-responses"), - base_path: s(&["endpoint", "basePath"], "/v1"), - health_path: s(&["endpoint", "healthPath"], "/healthz"), - ready_timeout_ms, - exec: v.get("runtime").and_then(|r| r.get("exec")).cloned().unwrap_or(Value::Null), - args, - models, - primary: s(&["modelMapping", "primary"], ""), - light: s(&["modelMapping", "light"], ""), - auth_status_path: s(&["auth", "statusPath"], "/v1/plugin/auth"), - source_git: s(&["source", "git"], ""), - source_branch: { - let b = s(&["source", "branch"], ""); - if b.trim().is_empty() { "main".to_string() } else { b } - }, - source_build: s(&["source", "build"], ""), - actions, - }) - } - - /// Find a declared action by id. - fn action(&self, action_id: &str) -> Option<&Value> { - self.actions - .iter() - .find(|a| a.get("id").and_then(|x| x.as_str()) == Some(action_id)) - } - - /// Resolve (submitPath, loadPath) for an action, applying defaults. - /// submitPath defaults to `/v1/plugin/action/`; loadPath defaults to submitPath. - fn action_paths(&self, action_id: &str) -> Option<(String, String)> { - let a = self.action(action_id)?; - let default_submit = format!("/v1/plugin/action/{}", action_id); - let submit = a - .get("submitPath") - .or_else(|| a.get("path")) - .and_then(|x| x.as_str()) - .unwrap_or(default_submit.as_str()) - .to_string(); - let load = a - .get("loadPath") - .and_then(|x| x.as_str()) - .unwrap_or(submit.as_str()) - .to_string(); - Some((submit, load)) - } - - /// Actions as sent to the renderer: host-internal wiring (submitPath/loadPath/ - /// path) stripped, display fields (label/kind/url/fields/…) kept. - fn public_actions(&self) -> Vec { - self.actions - .iter() - .map(|a| { - let mut o = a.clone(); - if let Some(m) = o.as_object_mut() { - m.remove("submitPath"); - m.remove("loadPath"); - m.remove("path"); - } - o - }) - .collect() - } - - /// Absolute path to the executable for the current platform, if declared. - fn exec_path(&self) -> Option { - let rel = self.exec.get(platform_key()).and_then(|x| x.as_str())?; - Some(self.dir.join(rel)) - } - - fn resolved_args(&self, port: u16, home: &str) -> Vec { - self.args - .iter() - .map(|a| a.replace("{port}", &port.to_string()).replace("{home}", home)) - .collect() - } - - fn base_url(&self, port: u16) -> String { - format!("http://127.0.0.1:{}{}", port, self.base_path) - } - - /// The plugin's icon as a data URI (data:image/...;base64,...), if declared - /// and readable — lets a plugin ship its own logo for the UI. - fn icon_data_uri(&self) -> Option { - let rel = self.icon.trim(); - if rel.is_empty() { - return None; - } - let path = self.dir.join(rel); - let bytes = std::fs::read(&path).ok()?; - if bytes.is_empty() || bytes.len() > 512 * 1024 { - return None; - } - let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - let mime = match ext.as_deref() { - Some("svg") => "image/svg+xml", - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("webp") => "image/webp", - Some("gif") => "image/gif", - _ => return None, - }; - Some(format!("data:{};base64,{}", mime, base64_encode(&bytes))) - } -} - -struct RunningPlugin { - child: Child, - port: u16, -} - -/// Owns running plugin processes and their derived providers. -pub struct PluginManager { - running: Mutex>, - client: reqwest::Client, -} - -impl PluginManager { - pub fn new() -> Arc { - Arc::new(PluginManager { - running: Mutex::new(HashMap::new()), - client: reqwest::Client::new(), - }) - } - - fn plugins_dir(&self) -> PathBuf { - plugins_root() - } - - fn plugin_dir(&self, id: &str) -> PathBuf { - self.plugins_dir().join(id) - } - - fn manifest(&self, id: &str) -> Option { - Manifest::load(self.plugin_dir(id)) - } - - fn discover(&self) -> Vec { - let mut out = vec![]; - if let Ok(rd) = std::fs::read_dir(self.plugins_dir()) { - for e in rd.flatten() { - if e.path().is_dir() { - if let Some(m) = Manifest::load(e.path()) { - out.push(m); - } - } - } - } - out - } - - fn running_port(&self, id: &str) -> Option { - self.running.lock().unwrap().get(id).map(|rp| rp.port) - } - - /// True if the plugin process is alive; reaps and forgets an exited one. - pub fn is_running(&self, id: &str) -> bool { - let mut g = self.running.lock().unwrap(); - if let Some(rp) = g.get_mut(id) { - match rp.child.try_wait() { - Ok(Some(_)) => { - g.remove(id); - false - } - _ => true, - } - } else { - false - } - } - - /// Port for a plugin: the live one if running, else a remembered one from - /// runtime.json, else a freshly assigned free port (persisted). - fn port_for(&self, id: &str) -> u16 { - if let Some(p) = self.running_port(id) { - return p; - } - let rt = self.plugin_dir(id).join("runtime.json"); - if let Ok(raw) = std::fs::read(&rt) { - if let Ok(v) = serde_json::from_slice::(&raw) { - if let Some(p) = v.get("port").and_then(|x| x.as_u64()) { - if p > 0 { - return p as u16; - } - } - } - } - let p = free_port().unwrap_or(8899); - let _ = std::fs::create_dir_all(self.plugin_dir(id)); - let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": p })).unwrap_or_default()); - p - } - - /// A port we can actually bind for this plugin: the remembered one if it's free, - /// else a freshly assigned free port (persisted to runtime.json). Avoids colliding - /// with a stale sidecar squatting on the old port. - fn bindable_port(&self, id: &str) -> u16 { - let port = self.port_for(id); - if port_is_free(port) { - return port; - } - let fresh = free_port().unwrap_or(port); - let _ = std::fs::create_dir_all(self.plugin_dir(id)); - let rt = self.plugin_dir(id).join("runtime.json"); - let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": fresh })).unwrap_or_default()); - fresh - } - - /// Enable a plugin: spawn it, health-gate, then upsert its provider. - pub async fn start(&self, id: &str) -> Result<(), String> { - let man = self.manifest(id).ok_or_else(|| format!("plugin '{}' not found", id))?; - - if self.is_running(id) { - self.ensure_provider(&man, self.running_port(id).unwrap_or_else(|| self.port_for(id))); - return Ok(()); - } - - let exec = man - .exec_path() - .ok_or_else(|| format!("no binary for this platform ({})", platform_key()))?; - if !exec.exists() { - return Err(format!("plugin binary missing: {}", exec.display())); - } - - // Use the remembered port, but if it's already taken (e.g. a stale sidecar from a - // previous run still holding it), grab a fresh free port instead — otherwise our - // child can't bind and we'd falsely health-gate against the squatter. - let port = self.bindable_port(id); - let dir = self.plugin_dir(id); - let _ = std::fs::create_dir_all(&dir); - let home = dir.to_string_lossy().to_string(); - let args = man.resolved_args(port, &home); - - // stderr → plugin.log for diagnosis; stdout is the plugin's ready channel - // (we already know the port, so we discard it). - let stderr = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("plugin.log")) - .map(Stdio::from) - .unwrap_or_else(|_| Stdio::null()); - - let child = Command::new(&exec) - .args(&args) - .current_dir(&man.dir) - .stdout(Stdio::null()) - .stderr(stderr) - .spawn() - .map_err(|e| format!("spawn {}: {}", exec.display(), e))?; - - self.running.lock().unwrap().insert(id.to_string(), RunningPlugin { child, port }); - - if !self.wait_ready(port, &man.health_path, man.ready_timeout_ms).await { - let _ = self.stop(id); - return Err("plugin did not become ready (see plugin.log)".into()); - } - // Guard against a false positive: if our child died during startup (e.g. it still - // failed to bind) even though something answered /healthz, don't register a dead - // provider — surface the failure so the UI doesn't flash "enabled" then revert. - if !self.is_running(id) { - return Err("plugin exited during startup (see plugin.log)".into()); - } - - self.ensure_provider(&man, port); - Ok(()) - } - - /// Disable a plugin: kill the process and remove its provider. - pub fn stop(&self, id: &str) -> Result<(), String> { - if let Some(mut rp) = self.running.lock().unwrap().remove(id) { - let _ = rp.child.kill(); - let _ = rp.child.wait(); - } - // Keep the provider (the service mirrors install state, not running state), - // but if this stopped plugin was the active provider, switch away — it can no - // longer serve requests. Pick the first other provider, else clear. - let pid = provider_id(id); - let mut cfg = store::read_config(); - if cfg.get("activeProviderId").and_then(|v| v.as_str()) == Some(pid.as_str()) { - let next = cfg - .get("providers") - .and_then(|v| v.as_array()) - .and_then(|arr| { - arr.iter() - .find(|p| p.get("id").and_then(|v| v.as_str()) != Some(pid.as_str())) - .and_then(|p| p.get("id").and_then(|v| v.as_str())) - .map(|s| s.to_string()) - }); - cfg["activeProviderId"] = next.map(Value::String).unwrap_or(Value::Null); - store::write_config(cfg); - } - Ok(()) - } - - async fn wait_ready(&self, port: u16, health_path: &str, timeout_ms: u64) -> bool { - let url = format!("http://127.0.0.1:{}{}", port, health_path); - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - // Ramp the poll interval: a local sidecar usually starts in well under a - // second, so probe aggressively at first (catch "ready" the instant it - // happens) and back off toward 150ms to keep the tail cheap. Connection- - // refused before the server binds returns immediately, so early probes - // don't stall. - let mut delay = Duration::from_millis(20); - loop { - if let Ok(r) = self.client.get(&url).timeout(Duration::from_millis(1500)).send().await { - if r.status().is_success() { - return true; - } - } - if Instant::now() >= deadline { - return false; - } - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_millis(150)); - } - } - - /// Upsert the `backend:"plugin"` provider that fronts this plugin. The provider is - /// fully derived from the manifest each time: primary/light from modelMapping and - /// NO custom aliases — the plugin advertises the rest via its own /v1/models, so an - /// identity-alias list would just be noise. - fn ensure_provider(&self, man: &Manifest, port: u16) { - let pid = provider_id(&man.id); - let provider = json!({ - "id": pid, - "name": man.name, - "backend": "plugin", - "pluginId": man.id, - "baseUrl": man.base_url(port), - "authToken": "", - "protocol": man.protocol, - "defaultModel": man.primary, - "smallFastModel": man.light, - "mapDefaultModels": true, - "models": [], - "icon": man.icon_data_uri(), - }); - - let mut cfg = store::read_config(); - let arr = match cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { - Some(a) => a, - None => { - cfg["providers"] = json!([]); - cfg["providers"].as_array_mut().unwrap() - } - }; - if let Some(i) = arr - .iter() - .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(pid.as_str())) - { - arr[i] = provider; // keep position, refresh contents - } else { - arr.push(provider); - } - store::write_config(cfg); - } - - /// Reconcile the provider list with the set of installed plugins: add a provider - /// for every installed plugin (created even while stopped, so the service is - /// visible but — see provider_set_active — not switchable until running) and - /// prune plugin providers whose plugin is no longer installed. - pub fn sync_providers(&self) { - let discovered = self.discover(); - for man in &discovered { - let port = self.running_port(&man.id).unwrap_or_else(|| self.port_for(&man.id)); - self.ensure_provider(man, port); - } - let ids: std::collections::HashSet = discovered.iter().map(|m| m.id.clone()).collect(); - let mut cfg = store::read_config(); - if let Some(arr) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { - arr.retain(|p| { - if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { - p.get("pluginId") - .and_then(|v| v.as_str()) - .map(|pid| ids.contains(pid)) - .unwrap_or(false) - } else { - true - } - }); - } - store::write_config(cfg); // normalize fixes activeProviderId if it was pruned - } - - /// Full snapshot for the UI: install info + running + auth (queried live). - pub async fn status(&self, id: &str) -> Value { - let man = self.manifest(id); - let running = self.is_running(id); - let mut auth = Value::Null; - if running { - if let (Some(m), Some(port)) = (man.as_ref(), self.running_port(id)) { - let url = format!("http://127.0.0.1:{}{}", port, m.auth_status_path); - if let Ok(r) = self.client.get(&url).timeout(Duration::from_secs(3)).send().await { - if let Ok(v) = r.json::().await { - auth = v; - } - } - } - } - json!({ - "id": id, - "name": man.as_ref().map(|m| m.name.clone()).unwrap_or_default(), - "version": man.as_ref().map(|m| m.version.clone()).unwrap_or_default(), - "description": man.as_ref().map(|m| m.description.clone()).unwrap_or_default(), - "protocol": man.as_ref().map(|m| m.protocol.clone()).unwrap_or_default(), - "icon": man.as_ref().and_then(|m| m.icon_data_uri()), - "hasSource": man.as_ref().map(|m| !m.source_git.trim().is_empty()).unwrap_or(false), - "official": man.as_ref().map(|m| is_official_source(&m.source_git)).unwrap_or(false), - "providerId": provider_id(id), - "running": running, - "auth": auth, - "actions": man.as_ref().map(|m| m.public_actions()).unwrap_or_default(), - }) - } - - /// List all discovered plugins with their status. - pub async fn list(&self) -> Value { - let mut out = vec![]; - for m in self.discover() { - out.push(self.status(&m.id).await); - } - json!(out) - } - - /// Run a declarative UI action: POST the form `values` to the action's control - /// plane endpoint and return the plugin's JSON response ({ ok, message }). A - /// non-2xx status surfaces the plugin's `message` as an error to the UI. - pub async fn action(&self, id: &str, action_id: &str, values: Value) -> Result { - let man = self.manifest(id).ok_or("plugin not found")?; - let (submit, _) = man.action_paths(action_id).ok_or("action not found")?; - let port = self.running_port(id).ok_or("plugin not running")?; - let url = format!("http://127.0.0.1:{}{}", port, submit); - let r = self - .client - .post(&url) - .json(&values) - .timeout(Duration::from_secs(30)) - .send() - .await - .map_err(|e| e.to_string())?; - let ok = r.status().is_success(); - let body = r.json::().await.unwrap_or_else(|_| json!({})); - if !ok { - let msg = body - .get("message") - .and_then(|x| x.as_str()) - .unwrap_or("plugin returned an error"); - return Err(msg.to_string()); - } - Ok(body) - } - - /// Fetch current values to prefill a declarative form (GET the action's - /// loadPath). Returns the plugin's JSON, typically `{ values: { ... } }`. - pub async fn action_load(&self, id: &str, action_id: &str) -> Result { - let man = self.manifest(id).ok_or("plugin not found")?; - let (_, load) = man.action_paths(action_id).ok_or("action not found")?; - let port = self.running_port(id).ok_or("plugin not running")?; - let url = format!("http://127.0.0.1:{}{}", port, load); - let r = self - .client - .get(&url) - .timeout(Duration::from_secs(10)) - .send() - .await - .map_err(|e| e.to_string())?; - r.json::().await.map_err(|e| e.to_string()) - } - - /// Install a plugin from a local directory (must contain plugin.json) into - /// ~/.ccbud/plugins/. Reinstalling replaces the existing copy. Returns - /// the installed plugin id. - pub fn install(&self, src: &std::path::Path) -> Result { - let src_dir = if src.is_file() { - src.parent().map(|p| p.to_path_buf()).ok_or("无效的路径")? - } else { - src.to_path_buf() - }; - let man = Manifest::load(src_dir.clone()).ok_or("所选目录没有有效的 plugin.json")?; - let dst = self.plugin_dir(&man.id); - if self.is_running(&man.id) { - return Err("请先停用同名插件,再重新安装".into()); - } - // Picking the already-installed dir itself is a no-op, not a self-copy. - let same = src_dir.canonicalize().ok() == dst.canonicalize().ok(); - if same && dst.exists() { - return Ok(man.id); - } - if dst.exists() { - std::fs::remove_dir_all(&dst).map_err(|e| e.to_string())?; - } - copy_dir_all(&src_dir, &dst).map_err(|e| format!("拷贝失败: {}", e))?; - self.sync_providers(); // installing a plugin auto-adds its service - Ok(man.id) - } - - /// Uninstall a plugin: stop it, delete its directory, and drop its service. - pub fn uninstall(&self, id: &str) -> Result<(), String> { - let _ = self.stop(id); - let dir = self.plugin_dir(id); - if dir.exists() { - std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; - } - self.sync_providers(); // removing a plugin auto-removes its service - Ok(()) - } - - /// Install (or update) a plugin from a git repository: shallow-clone, run the - /// manifest's build command, verify the binary exists, then install. Returns - /// the plugin id. - /// - /// SECURITY: this clones and *builds* code from a user-supplied URL — i.e. it - /// runs arbitrary code. The UI warns the user to import only trusted sources. - pub fn install_from_git(&self, url: &str) -> Result { - let url = url.trim(); - if url.is_empty() { - return Err("git 地址为空".into()); - } - let _ = std::fs::create_dir_all(plugins_root()); - let tmp = plugins_root().join(format!(".import-{}", unique_suffix())); - let _ = std::fs::remove_dir_all(&tmp); - - let out = Command::new("git") - .args(["clone", "--depth", "1", url]) - .arg(&tmp) - .output() - .map_err(|e| format!("git 不可用: {}", e))?; - if !out.status.success() { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("git clone 失败: {}", String::from_utf8_lossy(&out.stderr).trim())); - } - - let man = match Manifest::load(tmp.clone()) { - Some(m) => m, - None => { - let _ = std::fs::remove_dir_all(&tmp); - return Err("仓库根目录没有有效的 plugin.json".into()); - } - }; - - // The shallow clone above fetched the repo's default branch. If the manifest - // pins a different source branch, switch to it so update() installs the code - // that check_update() compared against (both use source.branch). - let mut man = man; - let branch = man.source_branch.trim().to_string(); - if !branch.is_empty() && branch != "main" { - let fetched = Command::new("git") - .arg("-C").arg(&tmp) - .args(["fetch", "--depth", "1", "origin", &branch]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if fetched { - let _ = Command::new("git").arg("-C").arg(&tmp).args(["checkout", "FETCH_HEAD"]).output(); - if let Some(m) = Manifest::load(tmp.clone()) { - man = m; // re-read from the pinned branch - } - } - } - - if !man.source_build.trim().is_empty() { - let built = Command::new("sh") - .arg("-c") - .arg(man.source_build.trim()) - .current_dir(&tmp) - .env("PATH", build_env_path()) - .output(); - match built { - Ok(o) if o.status.success() => {} - Ok(o) => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!( - "构建失败 (`{}`): {}", - man.source_build.trim(), - String::from_utf8_lossy(&o.stderr).trim() - )); - } - Err(e) => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("执行构建命令失败: {}", e)); - } - } - } - - match man.exec_path() { - Some(p) if p.exists() => {} - _ => { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!( - "构建后未找到当前平台二进制 ({});请检查 plugin.json 的 runtime.exec / source.build", - platform_key() - )); - } - } - - let _ = self.stop(&man.id); - let dst = self.plugin_dir(&man.id); - if dst.exists() { - if let Err(e) = std::fs::remove_dir_all(&dst) { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("移除旧版本失败: {}", e)); - } - } - if let Err(e) = copy_dir_all(&tmp, &dst) { - let _ = std::fs::remove_dir_all(&tmp); - return Err(format!("安装失败: {}", e)); - } - let _ = std::fs::remove_dir_all(&tmp); - self.sync_providers(); // installing/updating from git auto-adds its service - Ok(man.id) - } - - /// Check the plugin's git source for a newer version by fetching the remote - /// plugin.json (GitHub raw) and comparing versions. - pub async fn check_update(&self, id: &str) -> Value { - let man = match self.manifest(id) { - Some(m) => m, - None => return json!({ "hasSource": false }), - }; - if man.source_git.trim().is_empty() { - return json!({ "hasSource": false, "current": man.version }); - } - let raw = match github_raw(&man.source_git, &man.source_branch, "plugin.json") { - Some(u) => u, - None => { - return json!({ "hasSource": true, "current": man.version, "error": "仅支持 github.com 来源的更新检查" }) - } - }; - let latest = match self.client.get(&raw).timeout(Duration::from_secs(10)).send().await { - Ok(r) if r.status().is_success() => match r.json::().await { - Ok(v) => v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(), - Err(_) => String::new(), - }, - _ => String::new(), - }; - if latest.is_empty() { - return json!({ "hasSource": true, "current": man.version, "error": "无法获取远端版本" }); - } - json!({ - "hasSource": true, - "current": man.version, - "latest": latest, - "updateAvailable": version_gt(&latest, &man.version), - }) - } - - /// Update a plugin by re-installing from its recorded git source. - pub fn update(&self, id: &str) -> Result { - let man = self.manifest(id).ok_or_else(|| format!("插件 '{}' 未找到", id))?; - if man.source_git.trim().is_empty() { - return Err("该插件没有 git 来源,无法更新".into()); - } - let url = man.source_git.clone(); - self.install_from_git(&url) - } -} - -fn provider_id(plugin_id: &str) -> String { - format!("plugin:{}", plugin_id) -} - -/// `-` matching plugin.json's runtime.exec keys. -fn platform_key() -> &'static str { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") => "darwin-arm64", - ("macos", "x86_64") => "darwin-amd64", - ("linux", "x86_64") => "linux-amd64", - ("linux", "aarch64") => "linux-arm64", - ("windows", "x86_64") => "windows-amd64", - _ => "unknown", - } -} - -fn free_port() -> Option { - std::net::TcpListener::bind("127.0.0.1:0") - .ok() - .and_then(|l| l.local_addr().ok()) - .map(|a| a.port()) -} - -/// True if we can bind 127.0.0.1:port right now (i.e. nothing else is holding it). -fn port_is_free(port: u16) -> bool { - port != 0 && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() -} - -/// ccbud config home (~/.ccbud, overridable via CCBUD_HOME) — mirrors store.rs. -fn ccbud_home() -> PathBuf { - if let Ok(v) = std::env::var("CCBUD_HOME") { - if !v.trim().is_empty() { - return PathBuf::from(v); - } - } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".ccbud") -} - -/// ~/.ccbud/plugins — where plugins are installed. -pub fn plugins_root() -> PathBuf { - ccbud_home().join("plugins") -} - -/// Minimal standard base64 — used only to embed a small plugin icon as a data URI. -fn base64_encode(input: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity((input.len() + 2) / 3 * 4); - for chunk in input.chunks(3) { - let b0 = chunk[0]; - let b1 = *chunk.get(1).unwrap_or(&0); - let b2 = *chunk.get(2).unwrap_or(&0); - out.push(T[(b0 >> 2) as usize] as char); - out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' }); - out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' }); - } - out -} - -/// Convert a github.com repo URL + branch into a raw file URL. -fn github_raw(git: &str, branch: &str, path: &str) -> Option { - let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); - let rest = g - .strip_prefix("https://github.com/") - .or_else(|| g.strip_prefix("http://github.com/")) - .or_else(|| g.strip_prefix("git@github.com:"))?; - let br = if branch.trim().is_empty() { "main" } else { branch.trim() }; - Some(format!("https://raw.githubusercontent.com/{}/{}/{}", rest, br, path)) -} - -/// True if a git URL points at the official `ccbud` org on github. -fn is_official_source(git: &str) -> bool { - let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); - g.strip_prefix("https://github.com/") - .or_else(|| g.strip_prefix("http://github.com/")) - .or_else(|| g.strip_prefix("git@github.com:")) - .and_then(|rest| rest.split('/').next()) - .map(|owner| owner.eq_ignore_ascii_case("ccbud")) - .unwrap_or(false) -} - -/// True if semver-ish `a` is strictly newer than `b` (e.g. "0.2.0" > "0.1.9"). -fn version_gt(a: &str, b: &str) -> bool { - parse_ver(a) > parse_ver(b) -} -fn parse_ver(v: &str) -> Vec { - v.trim() - .trim_start_matches('v') - .split(|c| c == '.' || c == '-' || c == '+') - .map(|s| s.parse::().unwrap_or(0)) - .collect() -} -/// PATH for `source.build` commands. A GUI app launched from Finder/the Dock -/// inherits launchd's minimal PATH (no Homebrew, no /usr/local/go/bin, …), so -/// builds die with e.g. "go: command not found" even though the toolchain works -/// fine in the user's terminal. Merge the inherited PATH with the login shell's -/// PATH plus well-known toolchain dirs. -fn build_env_path() -> std::ffi::OsString { - let mut dirs: Vec = std::env::var_os("PATH") - .map(|p| std::env::split_paths(&p).collect()) - .unwrap_or_default(); - if let Some(p) = login_shell_path() { - for d in std::env::split_paths(&p) { - if !dirs.contains(&d) { - dirs.push(d); - } - } - } - // Cover toolchains even when the login shell probe fails (or exports them - // only for interactive shells): Homebrew, the official Go installer, and - // per-user go/cargo/pip bin dirs. - let mut extras: Vec = - ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/go/bin"].iter().map(PathBuf::from).collect(); - if let Some(home) = std::env::var_os("HOME") { - let home = PathBuf::from(home); - extras.extend(["go/bin", ".cargo/bin", ".local/bin"].iter().map(|d| home.join(d))); - } - for d in extras { - if d.is_dir() && !dirs.contains(&d) { - dirs.push(d); - } - } - std::env::join_paths(dirs).unwrap_or_else(|_| std::env::var_os("PATH").unwrap_or_default()) -} - -/// Ask the user's login shell for its PATH (profiles are where Homebrew, Go, -/// cargo, … register themselves). Best-effort: any failure returns None. -fn login_shell_path() -> Option { - let shell = std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty())?; - let out = Command::new(shell) - .args(["-l", "-c", "env"]) - .stdin(Stdio::null()) - .output() - .ok()?; - if !out.status.success() { - return None; - } - // Profile scripts may print noise before `env` runs; take the last PATH= line. - String::from_utf8_lossy(&out.stdout) - .lines() - .rev() - .find_map(|l| l.strip_prefix("PATH=").map(|v| v.to_string())) - .filter(|p| !p.trim().is_empty()) -} - -/// A process-unique-ish suffix for temporary import directories. -fn unique_suffix() -> String { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{}-{}", std::process::id(), nanos) -} - -/// Recursively copy a directory tree (files + subdirs; symlinks skipped). -fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; // never copy VCS metadata into an install dir - } - let ty = entry.file_type()?; - let from = entry.path(); - let to = dst.join(entry.file_name()); - if ty.is_dir() { - copy_dir_all(&from, &to)?; - } else if ty.is_file() { - std::fs::copy(&from, &to)?; - } - } - Ok(()) -} diff --git a/src-tauri/src/plugin/git.rs b/src-tauri/src/plugin/git.rs new file mode 100644 index 0000000..3b29a87 --- /dev/null +++ b/src-tauri/src/plugin/git.rs @@ -0,0 +1,163 @@ +// Git-sourced plugins: clone + build + install, remote version check, and update. Moved +// verbatim from plugin.rs. + +use serde_json::{json, Value}; +use std::process::Command; +use std::time::Duration; + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::{ + build_env_path, copy_dir_all, github_raw, platform_key, plugins_root, unique_suffix, version_gt, +}; + +impl PluginManager { + /// Install (or update) a plugin from a git repository: shallow-clone, run the + /// manifest's build command, verify the binary exists, then install. Returns + /// the plugin id. + /// + /// SECURITY: this clones and *builds* code from a user-supplied URL — i.e. it + /// runs arbitrary code. The UI warns the user to import only trusted sources. + pub fn install_from_git(&self, url: &str) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("git 地址为空".into()); + } + let _ = std::fs::create_dir_all(plugins_root()); + let tmp = plugins_root().join(format!(".import-{}", unique_suffix())); + let _ = std::fs::remove_dir_all(&tmp); + + let out = Command::new("git") + .args(["clone", "--depth", "1", url]) + .arg(&tmp) + .output() + .map_err(|e| format!("git 不可用: {}", e))?; + if !out.status.success() { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("git clone 失败: {}", String::from_utf8_lossy(&out.stderr).trim())); + } + + let man = match Manifest::load(tmp.clone()) { + Some(m) => m, + None => { + let _ = std::fs::remove_dir_all(&tmp); + return Err("仓库根目录没有有效的 plugin.json".into()); + } + }; + + // The shallow clone above fetched the repo's default branch. If the manifest + // pins a different source branch, switch to it so update() installs the code + // that check_update() compared against (both use source.branch). + let mut man = man; + let branch = man.source_branch.trim().to_string(); + if !branch.is_empty() && branch != "main" { + let fetched = Command::new("git") + .arg("-C").arg(&tmp) + .args(["fetch", "--depth", "1", "origin", &branch]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if fetched { + let _ = Command::new("git").arg("-C").arg(&tmp).args(["checkout", "FETCH_HEAD"]).output(); + if let Some(m) = Manifest::load(tmp.clone()) { + man = m; // re-read from the pinned branch + } + } + } + + if !man.source_build.trim().is_empty() { + let built = Command::new("sh") + .arg("-c") + .arg(man.source_build.trim()) + .current_dir(&tmp) + .env("PATH", build_env_path()) + .output(); + match built { + Ok(o) if o.status.success() => {} + Ok(o) => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!( + "构建失败 (`{}`): {}", + man.source_build.trim(), + String::from_utf8_lossy(&o.stderr).trim() + )); + } + Err(e) => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("执行构建命令失败: {}", e)); + } + } + } + + match man.exec_path() { + Some(p) if p.exists() => {} + _ => { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!( + "构建后未找到当前平台二进制 ({});请检查 plugin.json 的 runtime.exec / source.build", + platform_key() + )); + } + } + + let _ = self.stop(&man.id); + let dst = self.plugin_dir(&man.id); + if dst.exists() { + if let Err(e) = std::fs::remove_dir_all(&dst) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("移除旧版本失败: {}", e)); + } + } + if let Err(e) = copy_dir_all(&tmp, &dst) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(format!("安装失败: {}", e)); + } + let _ = std::fs::remove_dir_all(&tmp); + self.sync_providers(); // installing/updating from git auto-adds its service + Ok(man.id) + } + + /// Check the plugin's git source for a newer version by fetching the remote + /// plugin.json (GitHub raw) and comparing versions. + pub async fn check_update(&self, id: &str) -> Value { + let man = match self.manifest(id) { + Some(m) => m, + None => return json!({ "hasSource": false }), + }; + if man.source_git.trim().is_empty() { + return json!({ "hasSource": false, "current": man.version }); + } + let raw = match github_raw(&man.source_git, &man.source_branch, "plugin.json") { + Some(u) => u, + None => { + return json!({ "hasSource": true, "current": man.version, "error": "仅支持 github.com 来源的更新检查" }) + } + }; + let latest = match self.client.get(&raw).timeout(Duration::from_secs(10)).send().await { + Ok(r) if r.status().is_success() => match r.json::().await { + Ok(v) => v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string(), + Err(_) => String::new(), + }, + _ => String::new(), + }; + if latest.is_empty() { + return json!({ "hasSource": true, "current": man.version, "error": "无法获取远端版本" }); + } + json!({ + "hasSource": true, + "current": man.version, + "latest": latest, + "updateAvailable": version_gt(&latest, &man.version), + }) + } + + /// Update a plugin by re-installing from its recorded git source. + pub fn update(&self, id: &str) -> Result { + let man = self.manifest(id).ok_or_else(|| format!("插件 '{}' 未找到", id))?; + if man.source_git.trim().is_empty() { + return Err("该插件没有 git 来源,无法更新".into()); + } + let url = man.source_git.clone(); + self.install_from_git(&url) + } +} diff --git a/src-tauri/src/plugin/install.rs b/src-tauri/src/plugin/install.rs new file mode 100644 index 0000000..d5a3bfe --- /dev/null +++ b/src-tauri/src/plugin/install.rs @@ -0,0 +1,45 @@ +// Local install / uninstall of a plugin directory. Moved verbatim from plugin.rs. + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::copy_dir_all; + +impl PluginManager { + /// Install a plugin from a local directory (must contain plugin.json) into + /// ~/.ccbud/plugins/. Reinstalling replaces the existing copy. Returns + /// the installed plugin id. + pub fn install(&self, src: &std::path::Path) -> Result { + let src_dir = if src.is_file() { + src.parent().map(|p| p.to_path_buf()).ok_or("无效的路径")? + } else { + src.to_path_buf() + }; + let man = Manifest::load(src_dir.clone()).ok_or("所选目录没有有效的 plugin.json")?; + let dst = self.plugin_dir(&man.id); + if self.is_running(&man.id) { + return Err("请先停用同名插件,再重新安装".into()); + } + // Picking the already-installed dir itself is a no-op, not a self-copy. + let same = src_dir.canonicalize().ok() == dst.canonicalize().ok(); + if same && dst.exists() { + return Ok(man.id); + } + if dst.exists() { + std::fs::remove_dir_all(&dst).map_err(|e| e.to_string())?; + } + copy_dir_all(&src_dir, &dst).map_err(|e| format!("拷贝失败: {}", e))?; + self.sync_providers(); // installing a plugin auto-adds its service + Ok(man.id) + } + + /// Uninstall a plugin: stop it, delete its directory, and drop its service. + pub fn uninstall(&self, id: &str) -> Result<(), String> { + let _ = self.stop(id); + let dir = self.plugin_dir(id); + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| e.to_string())?; + } + self.sync_providers(); // removing a plugin auto-removes its service + Ok(()) + } +} diff --git a/src-tauri/src/plugin/lifecycle.rs b/src-tauri/src/plugin/lifecycle.rs new file mode 100644 index 0000000..f9801fa --- /dev/null +++ b/src-tauri/src/plugin/lifecycle.rs @@ -0,0 +1,122 @@ +// Plugin process lifecycle: spawn + health gate (start), kill + deregister (stop). Moved +// verbatim from plugin.rs. + +use serde_json::Value; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use crate::store; + +use super::manager::{PluginManager, RunningPlugin}; +use super::util::{platform_key, provider_id}; + +impl PluginManager { + /// Enable a plugin: spawn it, health-gate, then upsert its provider. + pub async fn start(&self, id: &str) -> Result<(), String> { + let man = self.manifest(id).ok_or_else(|| format!("plugin '{}' not found", id))?; + + if self.is_running(id) { + self.ensure_provider(&man, self.running_port(id).unwrap_or_else(|| self.port_for(id))); + return Ok(()); + } + + let exec = man + .exec_path() + .ok_or_else(|| format!("no binary for this platform ({})", platform_key()))?; + if !exec.exists() { + return Err(format!("plugin binary missing: {}", exec.display())); + } + + // Use the remembered port, but if it's already taken (e.g. a stale sidecar from a + // previous run still holding it), grab a fresh free port instead — otherwise our + // child can't bind and we'd falsely health-gate against the squatter. + let port = self.bindable_port(id); + let dir = self.plugin_dir(id); + let _ = std::fs::create_dir_all(&dir); + let home = dir.to_string_lossy().to_string(); + let args = man.resolved_args(port, &home); + + // stderr → plugin.log for diagnosis; stdout is the plugin's ready channel + // (we already know the port, so we discard it). + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("plugin.log")) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()); + + let child = Command::new(&exec) + .args(&args) + .current_dir(&man.dir) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|e| format!("spawn {}: {}", exec.display(), e))?; + + self.running.lock().unwrap().insert(id.to_string(), RunningPlugin { child, port }); + + if !self.wait_ready(port, &man.health_path, man.ready_timeout_ms).await { + let _ = self.stop(id); + return Err("plugin did not become ready (see plugin.log)".into()); + } + // Guard against a false positive: if our child died during startup (e.g. it still + // failed to bind) even though something answered /healthz, don't register a dead + // provider — surface the failure so the UI doesn't flash "enabled" then revert. + if !self.is_running(id) { + return Err("plugin exited during startup (see plugin.log)".into()); + } + + self.ensure_provider(&man, port); + Ok(()) + } + + /// Disable a plugin: kill the process and remove its provider. + pub fn stop(&self, id: &str) -> Result<(), String> { + if let Some(mut rp) = self.running.lock().unwrap().remove(id) { + let _ = rp.child.kill(); + let _ = rp.child.wait(); + } + // Keep the provider (the service mirrors install state, not running state), + // but if this stopped plugin was the active provider, switch away — it can no + // longer serve requests. Pick the first other provider, else clear. + let pid = provider_id(id); + let mut cfg = store::read_config(); + if cfg.get("activeProviderId").and_then(|v| v.as_str()) == Some(pid.as_str()) { + let next = cfg + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) != Some(pid.as_str())) + .and_then(|p| p.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + }); + cfg["activeProviderId"] = next.map(Value::String).unwrap_or(Value::Null); + store::write_config(cfg); + } + Ok(()) + } + + pub(super) async fn wait_ready(&self, port: u16, health_path: &str, timeout_ms: u64) -> bool { + let url = format!("http://127.0.0.1:{}{}", port, health_path); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + // Ramp the poll interval: a local sidecar usually starts in well under a + // second, so probe aggressively at first (catch "ready" the instant it + // happens) and back off toward 150ms to keep the tail cheap. Connection- + // refused before the server binds returns immediately, so early probes + // don't stall. + let mut delay = Duration::from_millis(20); + loop { + if let Ok(r) = self.client.get(&url).timeout(Duration::from_millis(1500)).send().await { + if r.status().is_success() { + return true; + } + } + if Instant::now() >= deadline { + return false; + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_millis(150)); + } + } +} diff --git a/src-tauri/src/plugin/manager.rs b/src-tauri/src/plugin/manager.rs new file mode 100644 index 0000000..15b92b2 --- /dev/null +++ b/src-tauri/src/plugin/manager.rs @@ -0,0 +1,114 @@ +// The manager type itself: running processes, plugin discovery and port assignment. Moved +// verbatim from plugin.rs. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Child; +use std::sync::{Arc, Mutex}; + +use super::manifest::Manifest; +use super::util::{free_port, plugins_root, port_is_free}; + +pub(super) struct RunningPlugin { + pub(super) child: Child, + pub(super) port: u16, +} + +/// Owns running plugin processes and their derived providers. +pub struct PluginManager { + pub(super) running: Mutex>, + pub(super) client: reqwest::Client, +} + +impl PluginManager { + pub fn new() -> Arc { + Arc::new(PluginManager { + running: Mutex::new(HashMap::new()), + client: reqwest::Client::new(), + }) + } + + pub(super) fn plugins_dir(&self) -> PathBuf { + plugins_root() + } + + pub(super) fn plugin_dir(&self, id: &str) -> PathBuf { + self.plugins_dir().join(id) + } + + pub(super) fn manifest(&self, id: &str) -> Option { + Manifest::load(self.plugin_dir(id)) + } + + pub(super) fn discover(&self) -> Vec { + let mut out = vec![]; + if let Ok(rd) = std::fs::read_dir(self.plugins_dir()) { + for e in rd.flatten() { + if e.path().is_dir() { + if let Some(m) = Manifest::load(e.path()) { + out.push(m); + } + } + } + } + out + } + + pub(super) fn running_port(&self, id: &str) -> Option { + self.running.lock().unwrap().get(id).map(|rp| rp.port) + } + + /// True if the plugin process is alive; reaps and forgets an exited one. + pub fn is_running(&self, id: &str) -> bool { + let mut g = self.running.lock().unwrap(); + if let Some(rp) = g.get_mut(id) { + match rp.child.try_wait() { + Ok(Some(_)) => { + g.remove(id); + false + } + _ => true, + } + } else { + false + } + } + + /// Port for a plugin: the live one if running, else a remembered one from + /// runtime.json, else a freshly assigned free port (persisted). + pub(super) fn port_for(&self, id: &str) -> u16 { + if let Some(p) = self.running_port(id) { + return p; + } + let rt = self.plugin_dir(id).join("runtime.json"); + if let Ok(raw) = std::fs::read(&rt) { + if let Ok(v) = serde_json::from_slice::(&raw) { + if let Some(p) = v.get("port").and_then(|x| x.as_u64()) { + if p > 0 { + return p as u16; + } + } + } + } + let p = free_port().unwrap_or(8899); + let _ = std::fs::create_dir_all(self.plugin_dir(id)); + let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": p })).unwrap_or_default()); + p + } + + /// A port we can actually bind for this plugin: the remembered one if it's free, + /// else a freshly assigned free port (persisted to runtime.json). Avoids colliding + /// with a stale sidecar squatting on the old port. + pub(super) fn bindable_port(&self, id: &str) -> u16 { + let port = self.port_for(id); + if port_is_free(port) { + return port; + } + let fresh = free_port().unwrap_or(port); + let _ = std::fs::create_dir_all(self.plugin_dir(id)); + let rt = self.plugin_dir(id).join("runtime.json"); + let _ = std::fs::write(&rt, serde_json::to_vec(&json!({ "port": fresh })).unwrap_or_default()); + fresh + } +} diff --git a/src-tauri/src/plugin/manifest.rs b/src-tauri/src/plugin/manifest.rs new file mode 100644 index 0000000..90eafd5 --- /dev/null +++ b/src-tauri/src/plugin/manifest.rs @@ -0,0 +1,127 @@ +// The parsed plugin.json manifest and its loader. Moved verbatim from plugin.rs. + +use serde_json::Value; +use std::path::PathBuf; + +/// A plugin's parsed manifest (plugin.json). Only the fields the host needs. +pub struct Manifest { + pub dir: PathBuf, + pub id: String, + pub name: String, + pub version: String, + pub description: String, + /// Optional icon file relative to the plugin dir, e.g. "icon.svg". + pub icon: String, + /// endpoint.protocol → provider wire protocol. + pub protocol: String, + /// endpoint.basePath, e.g. "/v1". + pub base_path: String, + /// endpoint.healthPath, e.g. "/healthz". + pub health_path: String, + /// endpoint.readyTimeoutMs. + pub ready_timeout_ms: u64, + /// runtime.exec: { "-": "bin/..." }. + pub(super) exec: Value, + /// runtime.args, with {port}/{home} placeholders. + pub(super) args: Vec, + /// (alias, upstream) model pairs. + pub models: Vec<(String, String)>, + pub primary: String, + pub light: String, + /// Control-plane auth status path (read-only; the plugin reuses a CLI login). + pub auth_status_path: String, + /// source.git — upstream git repo used for install/update (optional). + pub source_git: String, + pub source_branch: String, + /// source.build — shell command run in the clone to produce the binary. + pub source_build: String, + /// ui.actions — plugin-declared buttons/forms. Raw objects: the renderer draws + /// them (label/kind/fields/url), the host reads submitPath/loadPath to forward + /// a click to the plugin's control plane. See docs/plugin-system.md. + pub actions: Vec, +} +impl Manifest { + pub(super) fn load(dir: PathBuf) -> Option { + let raw = std::fs::read(dir.join("plugin.json")).ok()?; + let v: Value = serde_json::from_slice(&raw).ok()?; + let id = v.get("id")?.as_str()?.to_string(); + + let s = |path: &[&str], default: &str| -> String { + let mut cur = &v; + for k in path { + match cur.get(*k) { + Some(next) => cur = next, + None => return default.to_string(), + } + } + cur.as_str().unwrap_or(default).to_string() + }; + + let args = v + .get("runtime") + .and_then(|r| r.get("args")) + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()) + .unwrap_or_else(|| vec!["serve".into(), "--port".into(), "{port}".into(), "--home".into(), "{home}".into()]); + + let mut models = vec![]; + if let Some(arr) = v.get("models").and_then(|m| m.as_array()) { + for m in arr { + let alias = m.get("alias").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let upstream = m + .get("upstream") + .and_then(|x| x.as_str()) + .unwrap_or(alias.as_str()) + .to_string(); + if !alias.is_empty() { + models.push((alias, upstream)); + } + } + } + + let ready_timeout_ms = v + .get("endpoint") + .and_then(|e| e.get("readyTimeoutMs")) + .and_then(|x| x.as_u64()) + .unwrap_or(8000); + + // ui.actions: keep only well-formed objects that carry an id. + let actions = v + .get("ui") + .and_then(|u| u.get("actions")) + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter(|x| x.get("id").and_then(|i| i.as_str()).map(|s| !s.is_empty()).unwrap_or(false)) + .cloned() + .collect() + }) + .unwrap_or_default(); + + Some(Manifest { + dir, + id, + name: s(&["name"], "Plugin"), + version: s(&["version"], "0.0.0"), + description: s(&["description"], ""), + icon: s(&["icon"], ""), + protocol: s(&["endpoint", "protocol"], "openai-responses"), + base_path: s(&["endpoint", "basePath"], "/v1"), + health_path: s(&["endpoint", "healthPath"], "/healthz"), + ready_timeout_ms, + exec: v.get("runtime").and_then(|r| r.get("exec")).cloned().unwrap_or(Value::Null), + args, + models, + primary: s(&["modelMapping", "primary"], ""), + light: s(&["modelMapping", "light"], ""), + auth_status_path: s(&["auth", "statusPath"], "/v1/plugin/auth"), + source_git: s(&["source", "git"], ""), + source_branch: { + let b = s(&["source", "branch"], ""); + if b.trim().is_empty() { "main".to_string() } else { b } + }, + source_build: s(&["source", "build"], ""), + actions, + }) + } +} diff --git a/src-tauri/src/plugin/manifest_meta.rs b/src-tauri/src/plugin/manifest_meta.rs new file mode 100644 index 0000000..869a536 --- /dev/null +++ b/src-tauri/src/plugin/manifest_meta.rs @@ -0,0 +1,94 @@ +// Manifest accessors the manager reads through: declared actions, the resolved executable and +// its argv, the plugin's base URL and its icon. Moved verbatim from plugin.rs. + +use serde_json::Value; +use std::path::PathBuf; + +use super::manifest::Manifest; +use super::util::{base64_encode, platform_key}; + +impl Manifest { + /// Find a declared action by id. + pub(super) fn action(&self, action_id: &str) -> Option<&Value> { + self.actions + .iter() + .find(|a| a.get("id").and_then(|x| x.as_str()) == Some(action_id)) + } + + /// Resolve (submitPath, loadPath) for an action, applying defaults. + /// submitPath defaults to `/v1/plugin/action/`; loadPath defaults to submitPath. + pub(super) fn action_paths(&self, action_id: &str) -> Option<(String, String)> { + let a = self.action(action_id)?; + let default_submit = format!("/v1/plugin/action/{}", action_id); + let submit = a + .get("submitPath") + .or_else(|| a.get("path")) + .and_then(|x| x.as_str()) + .unwrap_or(default_submit.as_str()) + .to_string(); + let load = a + .get("loadPath") + .and_then(|x| x.as_str()) + .unwrap_or(submit.as_str()) + .to_string(); + Some((submit, load)) + } + + /// Actions as sent to the renderer: host-internal wiring (submitPath/loadPath/ + /// path) stripped, display fields (label/kind/url/fields/…) kept. + pub(super) fn public_actions(&self) -> Vec { + self.actions + .iter() + .map(|a| { + let mut o = a.clone(); + if let Some(m) = o.as_object_mut() { + m.remove("submitPath"); + m.remove("loadPath"); + m.remove("path"); + } + o + }) + .collect() + } + + /// Absolute path to the executable for the current platform, if declared. + pub(super) fn exec_path(&self) -> Option { + let rel = self.exec.get(platform_key()).and_then(|x| x.as_str())?; + Some(self.dir.join(rel)) + } + + pub(super) fn resolved_args(&self, port: u16, home: &str) -> Vec { + self.args + .iter() + .map(|a| a.replace("{port}", &port.to_string()).replace("{home}", home)) + .collect() + } + + pub(super) fn base_url(&self, port: u16) -> String { + format!("http://127.0.0.1:{}{}", port, self.base_path) + } + + /// The plugin's icon as a data URI (data:image/...;base64,...), if declared + /// and readable — lets a plugin ship its own logo for the UI. + pub(super) fn icon_data_uri(&self) -> Option { + let rel = self.icon.trim(); + if rel.is_empty() { + return None; + } + let path = self.dir.join(rel); + let bytes = std::fs::read(&path).ok()?; + if bytes.is_empty() || bytes.len() > 512 * 1024 { + return None; + } + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let mime = match ext.as_deref() { + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => return None, + }; + Some(format!("data:{};base64,{}", mime, base64_encode(&bytes))) + } +} diff --git a/src-tauri/src/plugin/mod.rs b/src-tauri/src/plugin/mod.rs new file mode 100644 index 0000000..1e9a768 --- /dev/null +++ b/src-tauri/src/plugin/mod.rs @@ -0,0 +1,25 @@ +// Sidecar plugin manager. +// +// A ccbud plugin is a standalone local program that reuses some coding agent's +// subscription login (e.g. Grok) and exposes a standard inference endpoint on +// localhost. The host does not do protocol/vendor work for it — see +// docs/plugin-system.md. This module owns the piece the gateway can't: process +// lifecycle, port assignment, and health gating. +// +// Key design choice: a running plugin is surfaced as an ordinary provider whose +// baseUrl points at the plugin's localhost port. Enabling a plugin upserts a +// `backend:"plugin"` provider (id = `plugin:`); disabling only stops the process +// (the service stays, removed on uninstall). The +// gateway then routes to it with zero plugin-specific code. +mod git; +mod install; +mod lifecycle; +mod manager; +mod manifest; +mod manifest_meta; +mod providers; +mod status; +mod util; + +pub use manager::PluginManager; +pub use util::plugins_root; diff --git a/src-tauri/src/plugin/providers.rs b/src-tauri/src/plugin/providers.rs new file mode 100644 index 0000000..9e0cdaf --- /dev/null +++ b/src-tauri/src/plugin/providers.rs @@ -0,0 +1,79 @@ +// The derived `backend:"plugin"` providers: upsert for one plugin, reconcile for all. Moved +// verbatim from plugin.rs. + +use serde_json::json; + +use crate::store; + +use super::manager::PluginManager; +use super::manifest::Manifest; +use super::util::provider_id; + +impl PluginManager { + /// Upsert the `backend:"plugin"` provider that fronts this plugin. The provider is + /// fully derived from the manifest each time: primary/light from modelMapping and + /// NO custom aliases — the plugin advertises the rest via its own /v1/models, so an + /// identity-alias list would just be noise. + pub(super) fn ensure_provider(&self, man: &Manifest, port: u16) { + let pid = provider_id(&man.id); + let provider = json!({ + "id": pid, + "name": man.name, + "backend": "plugin", + "pluginId": man.id, + "baseUrl": man.base_url(port), + "authToken": "", + "protocol": man.protocol, + "defaultModel": man.primary, + "smallFastModel": man.light, + "mapDefaultModels": true, + "models": [], + "icon": man.icon_data_uri(), + }); + + let mut cfg = store::read_config(); + let arr = match cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { + Some(a) => a, + None => { + cfg["providers"] = json!([]); + cfg["providers"].as_array_mut().unwrap() + } + }; + if let Some(i) = arr + .iter() + .position(|x| x.get("id").and_then(|v| v.as_str()) == Some(pid.as_str())) + { + arr[i] = provider; // keep position, refresh contents + } else { + arr.push(provider); + } + store::write_config(cfg); + } + + /// Reconcile the provider list with the set of installed plugins: add a provider + /// for every installed plugin (created even while stopped, so the service is + /// visible but — see provider_set_active — not switchable until running) and + /// prune plugin providers whose plugin is no longer installed. + pub fn sync_providers(&self) { + let discovered = self.discover(); + for man in &discovered { + let port = self.running_port(&man.id).unwrap_or_else(|| self.port_for(&man.id)); + self.ensure_provider(man, port); + } + let ids: std::collections::HashSet = discovered.iter().map(|m| m.id.clone()).collect(); + let mut cfg = store::read_config(); + if let Some(arr) = cfg.get_mut("providers").and_then(|v| v.as_array_mut()) { + arr.retain(|p| { + if p.get("backend").and_then(|v| v.as_str()) == Some("plugin") { + p.get("pluginId") + .and_then(|v| v.as_str()) + .map(|pid| ids.contains(pid)) + .unwrap_or(false) + } else { + true + } + }); + } + store::write_config(cfg); // normalize fixes activeProviderId if it was pruned + } +} diff --git a/src-tauri/src/plugin/status.rs b/src-tauri/src/plugin/status.rs new file mode 100644 index 0000000..2a974f9 --- /dev/null +++ b/src-tauri/src/plugin/status.rs @@ -0,0 +1,95 @@ +// UI-facing snapshots (status / list) and the declarative action calls. Moved verbatim from +// plugin.rs. + +use serde_json::{json, Value}; +use std::time::Duration; + +use super::manager::PluginManager; +use super::util::{is_official_source, provider_id}; + +impl PluginManager { + /// Full snapshot for the UI: install info + running + auth (queried live). + pub async fn status(&self, id: &str) -> Value { + let man = self.manifest(id); + let running = self.is_running(id); + let mut auth = Value::Null; + if running { + if let (Some(m), Some(port)) = (man.as_ref(), self.running_port(id)) { + let url = format!("http://127.0.0.1:{}{}", port, m.auth_status_path); + if let Ok(r) = self.client.get(&url).timeout(Duration::from_secs(3)).send().await { + if let Ok(v) = r.json::().await { + auth = v; + } + } + } + } + json!({ + "id": id, + "name": man.as_ref().map(|m| m.name.clone()).unwrap_or_default(), + "version": man.as_ref().map(|m| m.version.clone()).unwrap_or_default(), + "description": man.as_ref().map(|m| m.description.clone()).unwrap_or_default(), + "protocol": man.as_ref().map(|m| m.protocol.clone()).unwrap_or_default(), + "icon": man.as_ref().and_then(|m| m.icon_data_uri()), + "hasSource": man.as_ref().map(|m| !m.source_git.trim().is_empty()).unwrap_or(false), + "official": man.as_ref().map(|m| is_official_source(&m.source_git)).unwrap_or(false), + "providerId": provider_id(id), + "running": running, + "auth": auth, + "actions": man.as_ref().map(|m| m.public_actions()).unwrap_or_default(), + }) + } + + /// List all discovered plugins with their status. + pub async fn list(&self) -> Value { + let mut out = vec![]; + for m in self.discover() { + out.push(self.status(&m.id).await); + } + json!(out) + } + + /// Run a declarative UI action: POST the form `values` to the action's control + /// plane endpoint and return the plugin's JSON response ({ ok, message }). A + /// non-2xx status surfaces the plugin's `message` as an error to the UI. + pub async fn action(&self, id: &str, action_id: &str, values: Value) -> Result { + let man = self.manifest(id).ok_or("plugin not found")?; + let (submit, _) = man.action_paths(action_id).ok_or("action not found")?; + let port = self.running_port(id).ok_or("plugin not running")?; + let url = format!("http://127.0.0.1:{}{}", port, submit); + let r = self + .client + .post(&url) + .json(&values) + .timeout(Duration::from_secs(30)) + .send() + .await + .map_err(|e| e.to_string())?; + let ok = r.status().is_success(); + let body = r.json::().await.unwrap_or_else(|_| json!({})); + if !ok { + let msg = body + .get("message") + .and_then(|x| x.as_str()) + .unwrap_or("plugin returned an error"); + return Err(msg.to_string()); + } + Ok(body) + } + + /// Fetch current values to prefill a declarative form (GET the action's + /// loadPath). Returns the plugin's JSON, typically `{ values: { ... } }`. + pub async fn action_load(&self, id: &str, action_id: &str) -> Result { + let man = self.manifest(id).ok_or("plugin not found")?; + let (_, load) = man.action_paths(action_id).ok_or("action not found")?; + let port = self.running_port(id).ok_or("plugin not running")?; + let url = format!("http://127.0.0.1:{}{}", port, load); + let r = self + .client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|e| e.to_string())?; + r.json::().await.map_err(|e| e.to_string()) + } +} diff --git a/src-tauri/src/plugin/util.rs b/src-tauri/src/plugin/util.rs new file mode 100644 index 0000000..6f015f4 --- /dev/null +++ b/src-tauri/src/plugin/util.rs @@ -0,0 +1,183 @@ +// Free helpers shared by the manager: ids, platform keys, ports, paths, base64, GitHub raw +// URLs, version comparison, the child-process PATH, and a recursive copy. Moved verbatim from +// plugin.rs. + +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub(super) fn provider_id(plugin_id: &str) -> String { + format!("plugin:{}", plugin_id) +} + +/// `-` matching plugin.json's runtime.exec keys. +pub(super) fn platform_key() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "darwin-arm64", + ("macos", "x86_64") => "darwin-amd64", + ("linux", "x86_64") => "linux-amd64", + ("linux", "aarch64") => "linux-arm64", + ("windows", "x86_64") => "windows-amd64", + _ => "unknown", + } +} + +pub(super) fn free_port() -> Option { + std::net::TcpListener::bind("127.0.0.1:0") + .ok() + .and_then(|l| l.local_addr().ok()) + .map(|a| a.port()) +} + +/// True if we can bind 127.0.0.1:port right now (i.e. nothing else is holding it). +pub(super) fn port_is_free(port: u16) -> bool { + port != 0 && std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() +} + +/// ccbud config home (~/.ccbud, overridable via CCBUD_HOME) — mirrors store.rs. +pub(super) fn ccbud_home() -> PathBuf { + if let Ok(v) = std::env::var("CCBUD_HOME") { + if !v.trim().is_empty() { + return PathBuf::from(v); + } + } + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".ccbud") +} + +/// ~/.ccbud/plugins — where plugins are installed. +pub fn plugins_root() -> PathBuf { + ccbud_home().join("plugins") +} + +/// Minimal standard base64 — used only to embed a small plugin icon as a data URI. +pub(super) fn base64_encode(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((input.len() + 2) / 3 * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(T[(b0 >> 2) as usize] as char); + out.push(T[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { T[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char } else { '=' }); + out.push(if chunk.len() > 2 { T[(b2 & 0x3f) as usize] as char } else { '=' }); + } + out +} + +/// Convert a github.com repo URL + branch into a raw file URL. +pub(super) fn github_raw(git: &str, branch: &str, path: &str) -> Option { + let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); + let rest = g + .strip_prefix("https://github.com/") + .or_else(|| g.strip_prefix("http://github.com/")) + .or_else(|| g.strip_prefix("git@github.com:"))?; + let br = if branch.trim().is_empty() { "main" } else { branch.trim() }; + Some(format!("https://raw.githubusercontent.com/{}/{}/{}", rest, br, path)) +} + +/// True if a git URL points at the official `ccbud` org on github. +pub(super) fn is_official_source(git: &str) -> bool { + let g = git.trim().trim_end_matches('/').trim_end_matches(".git"); + g.strip_prefix("https://github.com/") + .or_else(|| g.strip_prefix("http://github.com/")) + .or_else(|| g.strip_prefix("git@github.com:")) + .and_then(|rest| rest.split('/').next()) + .map(|owner| owner.eq_ignore_ascii_case("ccbud")) + .unwrap_or(false) +} + +/// True if semver-ish `a` is strictly newer than `b` (e.g. "0.2.0" > "0.1.9"). +pub(super) fn version_gt(a: &str, b: &str) -> bool { + parse_ver(a) > parse_ver(b) +} +pub(super) fn parse_ver(v: &str) -> Vec { + v.trim() + .trim_start_matches('v') + .split(|c| c == '.' || c == '-' || c == '+') + .map(|s| s.parse::().unwrap_or(0)) + .collect() +} +/// PATH for `source.build` commands. A GUI app launched from Finder/the Dock +/// inherits launchd's minimal PATH (no Homebrew, no /usr/local/go/bin, …), so +/// builds die with e.g. "go: command not found" even though the toolchain works +/// fine in the user's terminal. Merge the inherited PATH with the login shell's +/// PATH plus well-known toolchain dirs. +pub(super) fn build_env_path() -> std::ffi::OsString { + let mut dirs: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + if let Some(p) = login_shell_path() { + for d in std::env::split_paths(&p) { + if !dirs.contains(&d) { + dirs.push(d); + } + } + } + // Cover toolchains even when the login shell probe fails (or exports them + // only for interactive shells): Homebrew, the official Go installer, and + // per-user go/cargo/pip bin dirs. + let mut extras: Vec = + ["/usr/local/bin", "/opt/homebrew/bin", "/usr/local/go/bin"].iter().map(PathBuf::from).collect(); + if let Some(home) = std::env::var_os("HOME") { + let home = PathBuf::from(home); + extras.extend(["go/bin", ".cargo/bin", ".local/bin"].iter().map(|d| home.join(d))); + } + for d in extras { + if d.is_dir() && !dirs.contains(&d) { + dirs.push(d); + } + } + std::env::join_paths(dirs).unwrap_or_else(|_| std::env::var_os("PATH").unwrap_or_default()) +} + +/// Ask the user's login shell for its PATH (profiles are where Homebrew, Go, +/// cargo, … register themselves). Best-effort: any failure returns None. +pub(super) fn login_shell_path() -> Option { + let shell = std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty())?; + let out = Command::new(shell) + .args(["-l", "-c", "env"]) + .stdin(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + // Profile scripts may print noise before `env` runs; take the last PATH= line. + String::from_utf8_lossy(&out.stdout) + .lines() + .rev() + .find_map(|l| l.strip_prefix("PATH=").map(|v| v.to_string())) + .filter(|p| !p.trim().is_empty()) +} + +/// A process-unique-ish suffix for temporary import directories. +pub(super) fn unique_suffix() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{}-{}", std::process::id(), nanos) +} + +/// Recursively copy a directory tree (files + subdirs; symlinks skipped). +pub(super) fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; // never copy VCS metadata into an install dir + } + let ty = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&from, &to)?; + } else if ty.is_file() { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} diff --git a/src-tauri/src/popover.rs b/src-tauri/src/popover.rs new file mode 100644 index 0000000..981884c --- /dev/null +++ b/src-tauri/src/popover.rs @@ -0,0 +1,116 @@ +// Popover window wiring: the show/hide debounce timestamps, the macOS NSPanel conversion and +// the blur auto-hide hook, plus the main-window visibility hooks that drive the daily auto +// update and the tray usage title. +// +// The two setup fns are extracted verbatim from the `setup()` closure in lib.rs `run()` so both +// files stay under the split's size cap; each is called exactly once, from run()'s setup. + +use tauri::Manager; + +use crate::commands::auto_update_on_visible; +use crate::tray::update_tray_title; + +// Timestamp (ms since epoch) of the last popover hide — used to debounce the tray click, +// which would otherwise re-show the popover on the very click that blurred it shut. +pub(crate) static LAST_POPOVER_HIDE_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +// Timestamp of the last popover show — a fullscreen app steals focus the instant the popover +// appears, so we ignore blur within a grace window after show (else it hides before being seen). +pub(crate) static LAST_POPOVER_SHOW_MS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); +pub(crate) fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +pub(crate) fn setup_popover(app: &tauri::App) { + // Popover behavior: (1) float on the current Space AND over fullscreen apps; + // (2) auto-hide when it loses focus — clicking anywhere else closes it. + if let Some(pop) = app.get_webview_window("popover") { + // macOS: convert the popover into a non-activating NSPanel. Unlike a plain window, + // a nonactivating panel can float on the CURRENT Space — including another app's + // fullscreen Space — and shows without activating ccbud or switching Spaces. + #[cfg(target_os = "macos")] + { + use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior as CB; + use tauri_nspanel::WebviewWindowExt as _; + if let Ok(panel) = pop.to_panel() { + panel.set_style_mask((1 << 7) as i32); // NSWindowStyleMaskNonactivatingPanel + panel.set_collection_behaviour( + CB::NSWindowCollectionBehaviorCanJoinAllSpaces + | CB::NSWindowCollectionBehaviorFullScreenAuxiliary + | CB::NSWindowCollectionBehaviorStationary, + ); + panel.set_floating_panel(true); + panel.set_level(24); // ~NSMainMenuWindowLevel: above fullscreen content + panel.set_hides_on_deactivate(false); + panel.set_released_when_closed(false); + } + } + let pop2 = pop.clone(); + pop.on_window_event(move |event| { + // Bind + deref: `Focused(false)` as a literal pattern does NOT match against + // &WindowEvent here (match ergonomics), so the handler would never fire. + if let tauri::WindowEvent::Focused(focused) = event { + if !*focused { + // Grace period: a fullscreen app steals focus the instant the popover + // shows; ignore that blur so it isn't hidden before being seen. A real + // click-away blur arrives well after the show. + if now_ms() + - LAST_POPOVER_SHOW_MS.load(std::sync::atomic::Ordering::Relaxed) + >= 400 + { + let _ = pop2.hide(); + LAST_POPOVER_HIDE_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + } + } + } + }); + } +} + +pub(crate) fn setup_window_hooks(app: &tauri::App) { + // Daily auto update, triggered by the app becoming visible (see auto_update_on_visible). + // Main-window focus covers launch, tray "open main", Dock/taskbar switches and the + // single-instance re-open; the popover-show branch of the tray click covers tray-only days. + if let Some(main) = app.get_webview_window("main") { + let h = app.handle().clone(); + main.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(focused) = event { + if *focused { + auto_update_on_visible(&h); + } + } + }); + } + // Launch counts as today's first visibility even if no focus event fires (e.g. an + // autostarted login launch that opens unfocused). Delayed a few seconds so the + // network/gateway are up before the first check. + { + let h = app.handle().clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let visible = h + .get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + if visible { + auto_update_on_visible(&h); + } + }); + } + + // Tray usage title: show the configured token count next to the menu-bar icon + // (macOS), refreshed on a timer so it tracks new usage without any user action. + { + let h = app.handle().clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + loop { + update_tray_title(&h); + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + } +} diff --git a/src-tauri/src/protocol/anthropic.rs b/src-tauri/src/protocol/anthropic.rs deleted file mode 100644 index e65c7c6..0000000 --- a/src-tauri/src/protocol/anthropic.rs +++ /dev/null @@ -1,479 +0,0 @@ -// The "Anthropic server-side" halves that llm-connector (a client library) doesn't provide: -// - decode_request: Anthropic Messages REQUEST json → llm-connector ChatRequest IR -// - encode_response: llm-connector ChatResponse IR → Anthropic Messages RESPONSE json -// -// Mapping follows the same shape LiteLLM / musistudio use: Anthropic content blocks are flattened -// into the OpenAI-style IR — `tool_use` blocks become assistant `Message.tool_calls`, `tool_result` -// blocks become separate `role:tool` messages, `system` becomes a leading system message. The IR is -// then encoded to OpenAI Chat (or Responses) by the crate. The reverse rebuilds Anthropic content -// blocks from the IR's tool_calls + text. -// -// Claude Code footguns handled explicitly (LiteLLM shipped bugs on these): user/system content -// blocks arrive as `{"type":"input_text"}` (not `text`) and MUST be recognized, else content is -// silently dropped → upstream 422. - -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall, -}; -use serde_json::{json, Value}; - -/// Pull plain text out of an Anthropic content value (string, or array of text/input_text blocks). -fn blocks_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - let arr = match content.as_array() { - Some(a) => a, - None => return String::new(), - }; - let mut out: Vec = vec![]; - for b in arr { - match b.get("type").and_then(|t| t.as_str()) { - // Claude Code sends `input_text`; the Anthropic API also uses `text`. Accept both. - Some("text") | Some("input_text") => { - if let Some(t) = b.get("text").and_then(|t| t.as_str()) { - out.push(t.to_string()); - } - } - _ => {} - } - } - out.join("\n") -} - -/// Image blocks in an Anthropic content array → IR image blocks (base64 or url). -fn image_blocks(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("image") { - continue; - } - let src = b.get("source").cloned().unwrap_or(Value::Null); - match src.get("type").and_then(|t| t.as_str()) { - Some("base64") => { - let mt = src.get("media_type").and_then(|v| v.as_str()).unwrap_or("image/png"); - let data = src.get("data").and_then(|v| v.as_str()).unwrap_or(""); - if !data.is_empty() { - out.push(MessageBlock::image_base64(mt, data)); - } - } - Some("url") => { - if let Some(u) = src.get("url").and_then(|v| v.as_str()) { - out.push(MessageBlock::image_url_anthropic(u)); - } - } - _ => {} - } - } - out -} - -/// tool_result blocks in a user turn → their own `role:tool` IR messages (OpenAI shape). Anthropic -/// nests tool results inside a user message; OpenAI wants each as a standalone tool message. -fn tool_result_messages(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("tool_result") { - continue; - } - let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - // tool_result content is a string or an array of text blocks. - let text = match b.get("content") { - Some(Value::String(s)) => s.clone(), - Some(c @ Value::Array(_)) => blocks_text(c), - _ => String::new(), - }; - out.push(Message::tool(text, id)); - } - out -} - -/// tool_use blocks in an assistant turn → IR ToolCalls (OpenAI function-call shape). -fn tool_use_calls(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for b in arr { - if b.get("type").and_then(|t| t.as_str()) != Some("tool_use") { - continue; - } - let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let args = b.get("input").cloned().unwrap_or_else(|| json!({})); - out.push(ToolCall { - id, - call_type: "function".to_string(), - function: FunctionCall { - name, - arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()), - thought_signature: None, - }, - index: None, - thought_signature: None, - }); - } - out -} - -/// Anthropic `system` (string or array of text blocks) → a leading system Message. -fn system_message(req: &Value) -> Option { - let sys = req.get("system")?; - let text = if sys.is_string() { sys.as_str().unwrap_or("").to_string() } else { blocks_text(sys) }; - let text = text.trim(); - if text.is_empty() { - None - } else { - Some(Message::text(Role::System, text)) - } -} - -/// Anthropic `tools` → IR function tools. `input_schema` maps to the function `parameters`. -fn tools(req: &Value) -> Option> { - let arr = req.get("tools").and_then(|v| v.as_array())?; - let mut out = vec![]; - for t in arr { - let name = t.get("name").and_then(|v| v.as_str())?; - let desc = t.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let params = t.get("input_schema").cloned().unwrap_or_else(|| json!({ "type": "object" })); - out.push(Tool::function(name, desc, params)); - } - if out.is_empty() { - None - } else { - Some(out) - } -} - -/// Decode an Anthropic Messages REQUEST json into the llm-connector IR. `model` is left as the -/// request's model (gateway.rs already rewrote it to the provider's outgoing model before this). -pub fn decode_request(req: &Value) -> Result { - let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let mut messages: Vec = vec![]; - - if let Some(sys) = system_message(req) { - messages.push(sys); - } - - let turns = req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - for m in &turns { - let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("user"); - let content = m.get("content").cloned().unwrap_or(Value::Null); - match role { - "assistant" => { - // assistant turn: text (+ optional thinking) + tool_use → tool_calls - let mut blocks: Vec = vec![]; - let text = blocks_text(&content); - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - let calls = tool_use_calls(&content); - let mut msg = Message::new(Role::Assistant, blocks); - if !calls.is_empty() { - msg.tool_calls = Some(calls); - } - messages.push(msg); - } - _ => { - // user turn: tool_result blocks split off into their own tool messages FIRST - // (they answer the prior assistant tool_calls), then any remaining text/images. - for tm in tool_result_messages(&content) { - messages.push(tm); - } - let mut blocks: Vec = vec![]; - let text = blocks_text(&content); - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - blocks.extend(image_blocks(&content)); - if !blocks.is_empty() { - messages.push(Message::new(Role::User, blocks)); - } - } - } - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - if let Some(stop) = req.get("stop_sequences").and_then(|v| v.as_array()) { - let v: Vec = stop.iter().filter_map(|s| s.as_str().map(|x| x.to_string())).collect(); - if !v.is_empty() { - cr = cr.with_stop(v); - } - } - if let Some(ts) = tools(req) { - cr = cr.with_tools(ts); - } - // Anthropic extended thinking → IR thinking budget (+ enable). Downstream OpenAI-chat drops it; - // Responses maps the budget to reasoning.effort (handled in the responses codec). - if let Some(th) = req.get("thinking") { - let enabled = th.get("type").and_then(|v| v.as_str()) == Some("enabled"); - if enabled { - cr = cr.with_enable_thinking(true); - if let Some(b) = th.get("budget_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_thinking_budget(b as u32); - } - } - } - - Ok(cr) -} - -/// Map an OpenAI/IR finish_reason to an Anthropic stop_reason. -fn stop_reason(finish: Option<&str>, had_tool_calls: bool) -> &'static str { - match finish { - Some("length") => "max_tokens", - Some("tool_calls") | Some("function_call") => "tool_use", - Some("content_filter") => "end_turn", - _ if had_tool_calls => "tool_use", - _ => "end_turn", - } -} - -/// Encode the IR response back into an Anthropic Messages RESPONSE json. `client_model` is the name -/// the client asked for (so Claude Code sees its own model, not the upstream's). -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - - let mut content: Vec = vec![]; - // assistant thinking (if the provider surfaced reasoning) → an Anthropic thinking block first. - if let Some(m) = msg { - if let Some(reasoning) = m.reasoning_any() { - if !reasoning.trim().is_empty() { - content.push(json!({ "type": "thinking", "thinking": reasoning })); - } - } - } - // assistant text. The crate parks text in choices[].message.content normally, but when a turn - // ALSO has tool_calls it keeps the text only in the top-level ChatResponse.content — so fall - // back to that (else assistant prose is dropped whenever a tool is called in the same turn). - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { resp.content.clone() } else { t } - }; - if !text.is_empty() { - content.push(json!({ "type": "text", "text": text })); - } - // tool calls → tool_use blocks - let mut had_tool_calls = false; - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - for tc in calls { - had_tool_calls = true; - let input: Value = tc.arguments_value().unwrap_or_else(|_| json!({})); - content.push(json!({ - "type": "tool_use", - "id": if tc.id.is_empty() { format!("toolu_{}", content.len()) } else { tc.id.clone() }, - "name": tc.function.name, - "input": input, - })); - } - } - } - if content.is_empty() { - content.push(json!({ "type": "text", "text": "" })); - } - - let finish = choice.and_then(|c| c.finish_reason.as_deref()); - let usage = resp.usage.as_ref(); - let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0); - let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0); - - json!({ - // never a constant fallback — clients persist this id and usage de-dupes by it - "id": if resp.id.is_empty() { super::uid("msg_ccbud") } else { resp.id.clone() }, - "type": "message", - "role": "assistant", - "model": client_model, - "content": content, - "stop_reason": stop_reason(finish, had_tool_calls), - "stop_sequence": Value::Null, - "usage": { "input_tokens": input_tokens, "output_tokens": output_tokens }, - }) -} - -/// Synthesize a complete Anthropic Messages SSE event sequence from a finished IR response. Used -/// when the client (Claude Code) asked to stream but the upstream was translated buffered — the -/// client still gets a valid, ordered `message_start → content_block_* → message_delta → -/// message_stop` stream, just delivered at once. True token-by-token transcoding is P2. -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - let full = encode_response(resp, client_model); - let content = full.get("content").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - let stop_reason = full.get("stop_reason").cloned().unwrap_or(json!("end_turn")); - let usage = full.get("usage").cloned().unwrap_or(json!({ "input_tokens": 0, "output_tokens": 0 })); - let id = full.get("id").cloned().unwrap_or(json!("msg_ccbud")); - let input_tokens = usage.get("input_tokens").cloned().unwrap_or(json!(0)); - let output_tokens = usage.get("output_tokens").cloned().unwrap_or(json!(0)); - - let ev = |event: &str, data: Value| { - format!("event: {}\ndata: {}\n\n", event, serde_json::to_string(&data).unwrap_or_default()) - }; - let mut out = String::new(); - - // message_start (usage input tokens known up front; output filled at message_delta) - out.push_str(&ev( - "message_start", - json!({ "type": "message_start", "message": { - "id": id, "type": "message", "role": "assistant", "model": client_model, - "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, - "usage": { "input_tokens": input_tokens, "output_tokens": 0 }, - }}), - )); - - for (i, block) in content.iter().enumerate() { - let bt = block.get("type").and_then(|v| v.as_str()).unwrap_or("text"); - match bt { - "text" => { - let text = block.get("text").and_then(|v| v.as_str()).unwrap_or(""); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "text", "text": "" } }))); - if !text.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "text_delta", "text": text } }))); - } - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - "thinking" => { - let think = block.get("thinking").and_then(|v| v.as_str()).unwrap_or(""); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "thinking", "thinking": "" } }))); - if !think.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "thinking_delta", "thinking": think } }))); - } - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - "tool_use" => { - let empty = json!({}); - let input = block.get("input").unwrap_or(&empty); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "tool_use", "id": block.get("id").cloned().unwrap_or(json!("")), "name": block.get("name").cloned().unwrap_or(json!("")), "input": {} } }))); - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "input_json_delta", "partial_json": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()) } }))); - out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); - } - _ => {} - } - } - - out.push_str(&ev( - "message_delta", - json!({ "type": "message_delta", "delta": { "stop_reason": stop_reason, "stop_sequence": Value::Null }, "usage": { "output_tokens": output_tokens } }), - )); - out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::openai::OpenAIProtocol; - - // A representative Claude Code request: system + a user prose turn (input_text blocks), an - // assistant tool_use, and the user's tool_result — the shape the messages→chat path must map. - fn claude_request() -> Value { - json!({ - "model": "claude-sonnet-4-6", - "max_tokens": 1024, - "system": "You are a helpful coding assistant.", - "tools": [{ "name": "read_file", "description": "Read a file", - "input_schema": { "type": "object", "properties": { "path": { "type": "string" } } } }], - "messages": [ - { "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] }, - { "role": "assistant", "content": [ - { "type": "text", "text": "Reading it." }, - { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "a.txt" } } - ] }, - { "role": "user", "content": [ - { "type": "tool_result", "tool_use_id": "toolu_1", "content": "hello world" } - ] } - ] - }) - } - - #[test] - fn decodes_anthropic_request_to_openai_chat_body() { - let ir = decode_request(&claude_request()).unwrap(); - // system prepended, tool_result split into its own tool message, ordering preserved. - let roles: Vec<_> = ir.messages.iter().map(|m| format!("{:?}", m.role)).collect(); - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool"]); - // input_text was recognized (not dropped → this is the Claude Code footgun). - assert_eq!(ir.messages[1].content_as_text(), "read a.txt"); - // assistant tool_use → tool_calls - let calls = ir.messages[2].tool_calls.as_ref().unwrap(); - assert_eq!(calls[0].function.name, "read_file"); - assert!(calls[0].function.arguments.contains("a.txt")); - // tool_result → tool message carrying the id + output - assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("toolu_1")); - assert_eq!(ir.messages[3].content_as_text(), "hello world"); - // tools carried through - assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "read_file"); - - // The crate encodes the IR to a real OpenAI Chat body — proves the reused half works. - let body = OpenAIProtocol::new("k").build_chat_request_body(&ir).unwrap(); - let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); - assert_eq!(msgs[0]["role"], "system"); - assert!(body.get("tools").is_some()); - } - - #[test] - fn encodes_openai_chat_response_to_anthropic() { - // A real OpenAI Chat response with a tool call, decoded by the crate → IR → Anthropic. - let openai = r#"{ - "id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"Sure.", - "tool_calls":[{"id":"call_9","type":"function", - "function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}}]}}], - "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} - }"#; - let ir = OpenAIProtocol::new("k").parse_response(openai).unwrap(); - let out = encode_response(&ir, "claude-sonnet-4-6"); - - assert_eq!(out["type"], "message"); - assert_eq!(out["role"], "assistant"); - assert_eq!(out["model"], "claude-sonnet-4-6"); // client-facing model, not gpt-4o - assert_eq!(out["stop_reason"], "tool_use"); - assert_eq!(out["usage"]["input_tokens"], 11); - assert_eq!(out["usage"]["output_tokens"], 7); - let content = out["content"].as_array().unwrap(); - assert!(content.iter().any(|b| b["type"] == "text" && b["text"] == "Sure.")); - let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); - assert_eq!(tu["name"], "read_file"); - assert_eq!(tu["input"]["path"], "a.txt"); - assert_eq!(tu["id"], "call_9"); - } - - #[test] - fn plain_text_round_trip() { - let req = json!({ - "model": "claude-x", "max_tokens": 100, - "messages": [{ "role": "user", "content": "hi there" }] - }); - let ir = decode_request(&req).unwrap(); - assert_eq!(ir.messages.len(), 1); - assert_eq!(ir.messages[0].content_as_text(), "hi there"); - - let openai = r#"{"id":"c1","object":"chat.completion","created":1,"model":"gpt","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hello!"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#; - let ir2 = OpenAIProtocol::new("k").parse_response(openai).unwrap(); - let out = encode_response(&ir2, "claude-x"); - assert_eq!(out["stop_reason"], "end_turn"); - assert_eq!(out["content"][0]["text"], "hello!"); - } -} diff --git a/src-tauri/src/protocol/anthropic/blocks.rs b/src-tauri/src/protocol/anthropic/blocks.rs new file mode 100644 index 0000000..c666664 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/blocks.rs @@ -0,0 +1,143 @@ +// Anthropic content blocks → IR pieces. Claude Code sends user/system blocks as +// `{"type":"input_text"}` (not `text`) — recognizing that is load-bearing: missing it silently +// drops content and the upstream answers 422. + +use llm_connector::types::{FunctionCall, Message, MessageBlock, Role, Tool, ToolCall}; +use serde_json::{json, Value}; + +/// Pull plain text out of an Anthropic content value (string, or array of text/input_text blocks). +pub(super) fn blocks_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + let arr = match content.as_array() { + Some(a) => a, + None => return String::new(), + }; + let mut out: Vec = vec![]; + for b in arr { + match b.get("type").and_then(|t| t.as_str()) { + // Claude Code sends `input_text`; the Anthropic API also uses `text`. Accept both. + Some("text") | Some("input_text") => { + if let Some(t) = b.get("text").and_then(|t| t.as_str()) { + out.push(t.to_string()); + } + } + _ => {} + } + } + out.join("\n") +} + +/// Image blocks in an Anthropic content array → IR image blocks (base64 or url). +pub(super) fn image_blocks(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("image") { + continue; + } + let src = b.get("source").cloned().unwrap_or(Value::Null); + match src.get("type").and_then(|t| t.as_str()) { + Some("base64") => { + let mt = src.get("media_type").and_then(|v| v.as_str()).unwrap_or("image/png"); + let data = src.get("data").and_then(|v| v.as_str()).unwrap_or(""); + if !data.is_empty() { + out.push(MessageBlock::image_base64(mt, data)); + } + } + Some("url") => { + if let Some(u) = src.get("url").and_then(|v| v.as_str()) { + out.push(MessageBlock::image_url_anthropic(u)); + } + } + _ => {} + } + } + out +} + +/// tool_result blocks in a user turn → their own `role:tool` IR messages (OpenAI shape). Anthropic +/// nests tool results inside a user message; OpenAI wants each as a standalone tool message. +pub(super) fn tool_result_messages(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + let id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + // tool_result content is a string or an array of text blocks. + let text = match b.get("content") { + Some(Value::String(s)) => s.clone(), + Some(c @ Value::Array(_)) => blocks_text(c), + _ => String::new(), + }; + out.push(Message::tool(text, id)); + } + out +} + +/// tool_use blocks in an assistant turn → IR ToolCalls (OpenAI function-call shape). +pub(super) fn tool_use_calls(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for b in arr { + if b.get("type").and_then(|t| t.as_str()) != Some("tool_use") { + continue; + } + let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let args = b.get("input").cloned().unwrap_or_else(|| json!({})); + out.push(ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name, + arguments: serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()), + thought_signature: None, + }, + index: None, + thought_signature: None, + }); + } + out +} + +/// Anthropic `system` (string or array of text blocks) → a leading system Message. +pub(super) fn system_message(req: &Value) -> Option { + let sys = req.get("system")?; + let text = if sys.is_string() { sys.as_str().unwrap_or("").to_string() } else { blocks_text(sys) }; + let text = text.trim(); + if text.is_empty() { + None + } else { + Some(Message::text(Role::System, text)) + } +} + +/// Anthropic `tools` → IR function tools. `input_schema` maps to the function `parameters`. +pub(super) fn tools(req: &Value) -> Option> { + let arr = req.get("tools").and_then(|v| v.as_array())?; + let mut out = vec![]; + for t in arr { + let name = t.get("name").and_then(|v| v.as_str())?; + let desc = t.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let params = t.get("input_schema").cloned().unwrap_or_else(|| json!({ "type": "object" })); + out.push(Tool::function(name, desc, params)); + } + if out.is_empty() { + None + } else { + Some(out) + } +} diff --git a/src-tauri/src/protocol/anthropic/decode.rs b/src-tauri/src/protocol/anthropic/decode.rs new file mode 100644 index 0000000..79a819c --- /dev/null +++ b/src-tauri/src/protocol/anthropic/decode.rs @@ -0,0 +1,90 @@ +// Anthropic Messages REQUEST json → llm-connector ChatRequest IR. + +use super::blocks::{blocks_text, image_blocks, system_message, tool_result_messages, tool_use_calls, tools}; +use llm_connector::types::{ChatRequest, Message, MessageBlock, Role}; +use serde_json::Value; + +/// Decode an Anthropic Messages REQUEST json into the llm-connector IR. `model` is left as the +/// request's model (gateway.rs already rewrote it to the provider's outgoing model before this). +pub fn decode_request(req: &Value) -> Result { + let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mut messages: Vec = vec![]; + + if let Some(sys) = system_message(req) { + messages.push(sys); + } + + let turns = req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + for m in &turns { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = m.get("content").cloned().unwrap_or(Value::Null); + match role { + "assistant" => { + // assistant turn: text (+ optional thinking) + tool_use → tool_calls + let mut blocks: Vec = vec![]; + let text = blocks_text(&content); + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + let calls = tool_use_calls(&content); + let mut msg = Message::new(Role::Assistant, blocks); + if !calls.is_empty() { + msg.tool_calls = Some(calls); + } + messages.push(msg); + } + _ => { + // user turn: tool_result blocks split off into their own tool messages FIRST + // (they answer the prior assistant tool_calls), then any remaining text/images. + for tm in tool_result_messages(&content) { + messages.push(tm); + } + let mut blocks: Vec = vec![]; + let text = blocks_text(&content); + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + blocks.extend(image_blocks(&content)); + if !blocks.is_empty() { + messages.push(Message::new(Role::User, blocks)); + } + } + } + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + if let Some(stop) = req.get("stop_sequences").and_then(|v| v.as_array()) { + let v: Vec = stop.iter().filter_map(|s| s.as_str().map(|x| x.to_string())).collect(); + if !v.is_empty() { + cr = cr.with_stop(v); + } + } + if let Some(ts) = tools(req) { + cr = cr.with_tools(ts); + } + // Anthropic extended thinking → IR thinking budget (+ enable). Downstream OpenAI-chat drops it; + // Responses maps the budget to reasoning.effort (handled in the responses codec). + if let Some(th) = req.get("thinking") { + let enabled = th.get("type").and_then(|v| v.as_str()) == Some("enabled"); + if enabled { + cr = cr.with_enable_thinking(true); + if let Some(b) = th.get("budget_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_thinking_budget(b as u32); + } + } + } + + Ok(cr) +} diff --git a/src-tauri/src/protocol/anthropic/encode.rs b/src-tauri/src/protocol/anthropic/encode.rs new file mode 100644 index 0000000..b4814a2 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/encode.rs @@ -0,0 +1,144 @@ +// llm-connector ChatResponse IR → Anthropic Messages RESPONSE json, buffered and as SSE. + +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// Map an OpenAI/IR finish_reason to an Anthropic stop_reason. +fn stop_reason(finish: Option<&str>, had_tool_calls: bool) -> &'static str { + match finish { + Some("length") => "max_tokens", + Some("tool_calls") | Some("function_call") => "tool_use", + Some("content_filter") => "end_turn", + _ if had_tool_calls => "tool_use", + _ => "end_turn", + } +} + +/// Encode the IR response back into an Anthropic Messages RESPONSE json. `client_model` is the name +/// the client asked for (so Claude Code sees its own model, not the upstream's). +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + + let mut content: Vec = vec![]; + // assistant thinking (if the provider surfaced reasoning) → an Anthropic thinking block first. + if let Some(m) = msg { + if let Some(reasoning) = m.reasoning_any() { + if !reasoning.trim().is_empty() { + content.push(json!({ "type": "thinking", "thinking": reasoning })); + } + } + } + // assistant text. The crate parks text in choices[].message.content normally, but when a turn + // ALSO has tool_calls it keeps the text only in the top-level ChatResponse.content — so fall + // back to that (else assistant prose is dropped whenever a tool is called in the same turn). + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { resp.content.clone() } else { t } + }; + if !text.is_empty() { + content.push(json!({ "type": "text", "text": text })); + } + // tool calls → tool_use blocks + let mut had_tool_calls = false; + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + for tc in calls { + had_tool_calls = true; + let input: Value = tc.arguments_value().unwrap_or_else(|_| json!({})); + content.push(json!({ + "type": "tool_use", + "id": if tc.id.is_empty() { format!("toolu_{}", content.len()) } else { tc.id.clone() }, + "name": tc.function.name, + "input": input, + })); + } + } + } + if content.is_empty() { + content.push(json!({ "type": "text", "text": "" })); + } + + let finish = choice.and_then(|c| c.finish_reason.as_deref()); + let usage = resp.usage.as_ref(); + let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0); + let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0); + + json!({ + // never a constant fallback — clients persist this id and usage de-dupes by it + "id": if resp.id.is_empty() { crate::protocol::uid("msg_ccbud") } else { resp.id.clone() }, + "type": "message", + "role": "assistant", + "model": client_model, + "content": content, + "stop_reason": stop_reason(finish, had_tool_calls), + "stop_sequence": Value::Null, + "usage": { "input_tokens": input_tokens, "output_tokens": output_tokens }, + }) +} + +/// Synthesize a complete Anthropic Messages SSE event sequence from a finished IR response. Used +/// when the client (Claude Code) asked to stream but the upstream was translated buffered — the +/// client still gets a valid, ordered `message_start → content_block_* → message_delta → +/// message_stop` stream, just delivered at once. True token-by-token transcoding is P2. +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + let full = encode_response(resp, client_model); + let content = full.get("content").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let stop_reason = full.get("stop_reason").cloned().unwrap_or(json!("end_turn")); + let usage = full.get("usage").cloned().unwrap_or(json!({ "input_tokens": 0, "output_tokens": 0 })); + let id = full.get("id").cloned().unwrap_or(json!("msg_ccbud")); + let input_tokens = usage.get("input_tokens").cloned().unwrap_or(json!(0)); + let output_tokens = usage.get("output_tokens").cloned().unwrap_or(json!(0)); + + let ev = |event: &str, data: Value| { + format!("event: {}\ndata: {}\n\n", event, serde_json::to_string(&data).unwrap_or_default()) + }; + let mut out = String::new(); + + // message_start (usage input tokens known up front; output filled at message_delta) + out.push_str(&ev( + "message_start", + json!({ "type": "message_start", "message": { + "id": id, "type": "message", "role": "assistant", "model": client_model, + "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, + "usage": { "input_tokens": input_tokens, "output_tokens": 0 }, + }}), + )); + + for (i, block) in content.iter().enumerate() { + let bt = block.get("type").and_then(|v| v.as_str()).unwrap_or("text"); + match bt { + "text" => { + let text = block.get("text").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "text", "text": "" } }))); + if !text.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "text_delta", "text": text } }))); + } + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + "thinking" => { + let think = block.get("thinking").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "thinking", "thinking": "" } }))); + if !think.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "thinking_delta", "thinking": think } }))); + } + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + "tool_use" => { + let empty = json!({}); + let input = block.get("input").unwrap_or(&empty); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": i, "content_block": { "type": "tool_use", "id": block.get("id").cloned().unwrap_or(json!("")), "name": block.get("name").cloned().unwrap_or(json!("")), "input": {} } }))); + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": i, "delta": { "type": "input_json_delta", "partial_json": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()) } }))); + out.push_str(&ev("content_block_stop", json!({ "type": "content_block_stop", "index": i }))); + } + _ => {} + } + } + + out.push_str(&ev( + "message_delta", + json!({ "type": "message_delta", "delta": { "stop_reason": stop_reason, "stop_sequence": Value::Null }, "usage": { "output_tokens": output_tokens } }), + )); + out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); + out +} diff --git a/src-tauri/src/protocol/anthropic/mod.rs b/src-tauri/src/protocol/anthropic/mod.rs new file mode 100644 index 0000000..50d78e5 --- /dev/null +++ b/src-tauri/src/protocol/anthropic/mod.rs @@ -0,0 +1,22 @@ +// The "Anthropic server-side" halves that llm-connector (a client library) doesn't provide: +// - decode_request: Anthropic Messages REQUEST json → llm-connector ChatRequest IR +// - encode_response: llm-connector ChatResponse IR → Anthropic Messages RESPONSE json +// +// Mapping follows the same shape LiteLLM / musistudio use: Anthropic content blocks are flattened +// into the OpenAI-style IR — `tool_use` blocks become assistant `Message.tool_calls`, `tool_result` +// blocks become separate `role:tool` messages, `system` becomes a leading system message. The IR is +// then encoded to OpenAI Chat (or Responses) by the crate. The reverse rebuilds Anthropic content +// blocks from the IR's tool_calls + text. +// +// Claude Code footguns handled explicitly (LiteLLM shipped bugs on these): user/system content +// blocks arrive as `{"type":"input_text"}` (not `text`) and MUST be recognized, else content is +// silently dropped → upstream 422. + +mod blocks; +mod decode; +mod encode; +#[cfg(test)] +mod tests; + +pub use decode::decode_request; +pub use encode::{encode_response, encode_response_sse}; diff --git a/src-tauri/src/protocol/anthropic/tests.rs b/src-tauri/src/protocol/anthropic/tests.rs new file mode 100644 index 0000000..c979d8f --- /dev/null +++ b/src-tauri/src/protocol/anthropic/tests.rs @@ -0,0 +1,97 @@ +use super::decode::decode_request; +use super::encode::encode_response; +use serde_json::{json, Value}; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; + +// A representative Claude Code request: system + a user prose turn (input_text blocks), an +// assistant tool_use, and the user's tool_result — the shape the messages→chat path must map. +fn claude_request() -> Value { + json!({ + "model": "claude-sonnet-4-6", + "max_tokens": 1024, + "system": "You are a helpful coding assistant.", + "tools": [{ "name": "read_file", "description": "Read a file", + "input_schema": { "type": "object", "properties": { "path": { "type": "string" } } } }], + "messages": [ + { "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] }, + { "role": "assistant", "content": [ + { "type": "text", "text": "Reading it." }, + { "type": "tool_use", "id": "toolu_1", "name": "read_file", "input": { "path": "a.txt" } } + ] }, + { "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": "toolu_1", "content": "hello world" } + ] } + ] + }) +} + +#[test] +fn decodes_anthropic_request_to_openai_chat_body() { + let ir = decode_request(&claude_request()).unwrap(); + // system prepended, tool_result split into its own tool message, ordering preserved. + let roles: Vec<_> = ir.messages.iter().map(|m| format!("{:?}", m.role)).collect(); + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool"]); + // input_text was recognized (not dropped → this is the Claude Code footgun). + assert_eq!(ir.messages[1].content_as_text(), "read a.txt"); + // assistant tool_use → tool_calls + let calls = ir.messages[2].tool_calls.as_ref().unwrap(); + assert_eq!(calls[0].function.name, "read_file"); + assert!(calls[0].function.arguments.contains("a.txt")); + // tool_result → tool message carrying the id + output + assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("toolu_1")); + assert_eq!(ir.messages[3].content_as_text(), "hello world"); + // tools carried through + assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "read_file"); + + // The crate encodes the IR to a real OpenAI Chat body — proves the reused half works. + let body = OpenAIProtocol::new("k").build_chat_request_body(&ir).unwrap(); + let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); + assert_eq!(msgs[0]["role"], "system"); + assert!(body.get("tools").is_some()); +} + +#[test] +fn encodes_openai_chat_response_to_anthropic() { + // A real OpenAI Chat response with a tool call, decoded by the crate → IR → Anthropic. + let openai = r#"{ + "id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"Sure.", + "tool_calls":[{"id":"call_9","type":"function", + "function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}}]}}], + "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} + }"#; + let ir = OpenAIProtocol::new("k").parse_response(openai).unwrap(); + let out = encode_response(&ir, "claude-sonnet-4-6"); + + assert_eq!(out["type"], "message"); + assert_eq!(out["role"], "assistant"); + assert_eq!(out["model"], "claude-sonnet-4-6"); // client-facing model, not gpt-4o + assert_eq!(out["stop_reason"], "tool_use"); + assert_eq!(out["usage"]["input_tokens"], 11); + assert_eq!(out["usage"]["output_tokens"], 7); + let content = out["content"].as_array().unwrap(); + assert!(content.iter().any(|b| b["type"] == "text" && b["text"] == "Sure.")); + let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); + assert_eq!(tu["name"], "read_file"); + assert_eq!(tu["input"]["path"], "a.txt"); + assert_eq!(tu["id"], "call_9"); +} + +#[test] +fn plain_text_round_trip() { + let req = json!({ + "model": "claude-x", "max_tokens": 100, + "messages": [{ "role": "user", "content": "hi there" }] + }); + let ir = decode_request(&req).unwrap(); + assert_eq!(ir.messages.len(), 1); + assert_eq!(ir.messages[0].content_as_text(), "hi there"); + + let openai = r#"{"id":"c1","object":"chat.completion","created":1,"model":"gpt","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"hello!"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#; + let ir2 = OpenAIProtocol::new("k").parse_response(openai).unwrap(); + let out = encode_response(&ir2, "claude-x"); + assert_eq!(out["stop_reason"], "end_turn"); + assert_eq!(out["content"][0]["text"], "hello!"); +} diff --git a/src-tauri/src/protocol/codec.rs b/src-tauri/src/protocol/codec.rs new file mode 100644 index 0000000..ce0a7cf --- /dev/null +++ b/src-tauri/src/protocol/codec.rs @@ -0,0 +1,123 @@ +// The translation pipeline itself: decode(client wire) → IR → encode(provider wire) on the way +// out, and the mirror on the way back. The identity case (client and provider speak the same +// protocol) never enters here — the gateway keeps its verbatim passthrough fast path for it. + +use super::signatures::{ + ensure_chat_tool_call_reasoning_content, normalize_openai_request_thought_signatures, + normalize_openai_response_thought_signatures, +}; +use super::{anthropic, openai_chat_client, openai_responses, stream}; +use super::Wire; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::{ChatRequest, ChatResponse}; +use serde_json::{json, Value}; + +/// Decode an inbound client request (in its wire format) into the unified IR. +pub fn decode_client_request(client: Wire, body: &Value) -> Result { + match client { + Wire::Anthropic => anthropic::decode_request(body), + Wire::OpenAiChat => openai_chat_client::decode_request(body), + // Hand-rolled (not the crate's responses_request_to_chat_request, which drops + // function_call / function_call_output / assistant items and rejects flattened tools — + // fatal for Codex). + Wire::OpenAiResponses => openai_responses::decode_request(body), + } +} + +/// Encode the IR into the upstream provider's request BODY. `outgoing_model` is the provider's real +/// model (gateway already resolved it); `stream` requests SSE from the upstream. For the first cut +/// we translate cross-protocol responses buffered, so callers pass stream=false here and synthesize +/// the client SSE from the full response (true incremental transcoding is P2). +pub fn encode_upstream_request( + provider: Wire, + ir: &ChatRequest, + outgoing_model: &str, + stream: bool, +) -> Result { + let mut ir = ir.clone(); + ir.model = outgoing_model.to_string(); + ir.stream = Some(stream); + match provider { + Wire::OpenAiChat => { + let mut body = OpenAIProtocol::new("") + .build_chat_request_body(&ir) + .map_err(|e| e.to_string())?; + let lower_model = outgoing_model.to_ascii_lowercase(); + if lower_model.contains("gemini") { + normalize_openai_request_thought_signatures(&mut body); + } + // GLM's OpenAI-compatible coding endpoint uses its native `thinking` switch rather + // than the OpenAI `reasoning_effort` field emitted by the generic connector. + if lower_model.contains("glm") || lower_model.contains("zhipu") || lower_model.contains("z-ai") { + if let Some(object) = body.as_object_mut() { + object.remove("reasoning_effort"); + } + if ir.enable_thinking == Some(true) { + body["thinking"] = json!({ "type": "enabled" }); + } + } + ensure_chat_tool_call_reasoning_content(&mut body); + Ok(body) + } + Wire::OpenAiResponses => Ok(openai_responses::encode_request(&ir, outgoing_model, stream)), + // Reverse direction: an OpenAI/Codex client → an Anthropic upstream. The crate encodes the + // IR into an Anthropic Messages request (tool_calls→tool_use blocks, etc.). Anthropic + // requires max_tokens; OpenAI-family clients (Codex) usually omit it and the crate's + // fallback (1024) truncates agent turns — default to a workable ceiling instead. + Wire::Anthropic => { + if ir.max_tokens.is_none() { + ir.max_tokens = Some(8192); + } + AnthropicProtocol::new("") + .build_chat_request_body(&ir) + .map_err(|e| e.to_string()) + } + } +} + +/// Decode an upstream provider RESPONSE (its wire format, buffered) into the IR. +pub fn decode_upstream_response(provider: Wire, text: &str) -> Result { + match provider { + Wire::OpenAiChat => { + let normalized = match serde_json::from_str::(text) { + Ok(mut body) => { + normalize_openai_response_thought_signatures(&mut body); + body.to_string() + } + Err(_) => text.to_string(), + }; + OpenAIProtocol::new("").parse_response(&normalized).map_err(|e| e.to_string()) + } + Wire::OpenAiResponses => openai_responses::decode_response(text), + Wire::Anthropic => AnthropicProtocol::new("").parse_response(text).map_err(|e| e.to_string()), + } +} + +/// Encode the IR response back to the client's wire format as a buffered JSON body. +pub fn encode_client_response(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { + match client { + Wire::Anthropic => Ok(anthropic::encode_response(ir, client_model)), + Wire::OpenAiChat => Ok(openai_chat_client::encode_response(ir, client_model)), + // Hand-rolled (not the crate's chat_response_to_responses_response, which drops + // tool_calls from the output — Codex would never see a function call). + Wire::OpenAiResponses => Ok(openai_responses::encode_response(ir, client_model)), + } +} + +/// Whether we have an incremental (event-by-event) stream transcoder from `provider` to `client`. +/// When false, cross-protocol streaming falls back to buffer-upstream + synthesize-client-SSE. +pub fn can_transcode_stream(provider: Wire, client: Wire) -> bool { + stream::Transcoder::supports(provider, client) +} + +/// Encode the IR response to the client's wire format as a full SSE stream body (used when the +/// client asked to stream but we translated the upstream buffered — synthesize the event sequence). +pub fn encode_client_response_sse(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { + match client { + Wire::Anthropic => Ok(anthropic::encode_response_sse(ir, client_model)), + Wire::OpenAiChat => Ok(openai_chat_client::encode_response_sse(ir, client_model)), + Wire::OpenAiResponses => Ok(openai_responses::encode_response_sse(ir, client_model)), + } +} diff --git a/src-tauri/src/protocol/codex_history.rs b/src-tauri/src/protocol/codex_history.rs deleted file mode 100644 index ac147e8..0000000 --- a/src-tauri/src/protocol/codex_history.rs +++ /dev/null @@ -1,2148 +0,0 @@ -//! Cross-request history for bridging Codex Responses requests to chat-style upstreams. -//! -//! Responses clients may continue a tool turn with only -//! `previous_response_id + new input`. Chat-style protocols do not implement that -//! server-side continuation, so they need the previous request input and assistant -//! output restored recursively into the next request. Tool outputs additionally need -//! the original assistant call, including its name, arguments, and reasoning metadata. -//! This store records that model-visible context and restores it before conversion. - -use serde_json::Value; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::{self, Write}; -use tokio::sync::RwLock; - -const MAX_CACHED_RESPONSES: usize = 512; -// Count-bounding alone is not enough once every entry carries the cumulative transcript: a long -// conversation would otherwise make the cache grow quadratically. This is a logical serialized -// size ceiling (including the duplicated call lookup values), which keeps resident memory in the -// same order of magnitude while still leaving ample room for large model contexts. -const MAX_CACHED_HISTORY_BYTES: usize = 32 * 1024 * 1024; - -type ScopedResponseId = (String, String); -type ScopedCallId = (String, String); - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum ResponseOrigin { - #[default] - Local, - Native(String), -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ResponseMetadata { - pub origin: ResponseOrigin, - pub materializable: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct HistoryResolution { - pub changed: usize, - pub had_previous_response_id: bool, - pub previous_found: bool, - pub previous_materialized: bool, - pub previous_origin: Option, -} - -#[derive(Debug, Clone, Default)] -struct CachedResponse { - /// Full model-visible input used to create this response. For an incremental - /// `previous_response_id` request this already includes every earlier request and response. - request_input: Vec, - output: Vec, - calls_by_id: HashMap, - call_order: Vec, - serialized_bytes: usize, - origin: ResponseOrigin, - materializable: bool, -} - -#[derive(Debug, Default)] -struct HistoryInner { - responses: HashMap, - response_order: VecDeque, - /// Reverse index used only when `previous_response_id` is absent or stale. - /// A fallback is safe only when a call id resolves to exactly one response. - call_index: HashMap>, - cached_bytes: usize, -} - -#[derive(Debug, Clone, Default)] -struct CachedLookup { - previous: Option, - fallback: CachedResponse, -} - -/// Thread-safe, bounded Responses conversation-history store. -#[derive(Debug, Default)] -pub struct CodexHistoryStore { - inner: RwLock, -} - -impl CodexHistoryStore { - /// Record the full translated request input plus supported assistant-output items from a - /// resumable terminal Responses response (`completed` or `incomplete`). - /// - /// Returns the number of cached output items. Responses without an id are ignored; an otherwise - /// empty response is still retained so provider ownership remains known. - pub async fn record_response(&self, request: &Value, response: &Value) -> usize { - self.record_response_scoped("", request, response).await - } - - /// Scoped variant used by the gateway so response/call ids from different client sessions can - /// never satisfy one another while the same conversation can survive a provider switch. - pub async fn record_response_scoped( - &self, - scope: &str, - request: &Value, - response: &Value, - ) -> usize { - // Preserve the original store API for internal callers/tests that predate Responses - // terminal statuses. Gateway-owned/native recording uses the metadata variant below, - // which requires an explicit resumable terminal status. - let mut legacy_terminal; - let response = if response.get("status").is_none() { - legacy_terminal = response.clone(); - legacy_terminal["status"] = Value::String("completed".to_string()); - &legacy_terminal - } else { - response - }; - self.record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - request, - response, - ) - .await - } - - pub async fn record_response_scoped_with_metadata( - &self, - scope: &str, - origin: ResponseOrigin, - materializable: bool, - request: &Value, - response: &Value, - ) -> usize { - if response - .get("object") - .and_then(Value::as_str) - .is_some_and(|object| object != "response") - { - return 0; - } - if !matches!( - response.get("status").and_then(Value::as_str), - Some("completed" | "incomplete") - ) { - return 0; - } - - let Some(response_id) = response - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return 0; - }; - - let request_input = request_input_items(request); - let request_input_is_complete = request_input_is_materializable(request); - let (output, output_is_complete) = match response.get("output").and_then(Value::as_array) { - Some(items) => { - let output = items - .iter() - .filter_map(cached_output_item) - .collect::>(); - let output_is_complete = - output.len() == items.len() && items.iter().all(history_item_is_materializable); - (output, output_is_complete) - } - None => (Vec::new(), false), - }; - // Preserve ownership for a response containing an item this bridge cannot replay, but - // never advertise that partial transcript as safe to move to another provider. - let materializable = materializable && request_input_is_complete && output_is_complete; - - self.inner.write().await.insert_response_with_metadata( - scope, - response_id, - request_input, - output, - origin, - materializable, - ) - } - - pub async fn response_metadata( - &self, - scope: &str, - response_id: &str, - ) -> Option { - let response_id = response_id.trim(); - if response_id.is_empty() { - return None; - } - self.inner - .read() - .await - .responses - .get(&(scope.to_string(), response_id.to_string())) - .map(|response| ResponseMetadata { - origin: response.origin.clone(), - materializable: response.materializable, - }) - } - - /// Restore or enrich call items required by a follow-up Responses request. - /// - /// Missing calls are inserted immediately before the first matching output. - /// Parallel calls from the same response are restored as one ordered group. - /// Existing call items are enriched when fields such as `name`, `arguments`, - /// or `reasoning_content` are missing. - /// - /// The primary lookup uses `previous_response_id`. If that id is absent or - /// stale, a call-id fallback is used only when the caller supplied a safe scope and the call id - /// is unique inside that client session. Returns the number of restored or enriched items. - pub async fn enrich_request(&self, body: &mut Value) -> usize { - self.enrich_request_scoped("", false, body).await - } - - /// Scoped variant. Missing/stale-`previous_response_id` call-id recovery is allowed only when - /// the caller can provide a client-session scope; otherwise orphan validation must fail. - pub async fn enrich_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - body: &mut Value, - ) -> usize { - self.resolve_request_scoped(scope, allow_call_id_fallback, false, body) - .await - .changed - } - - pub async fn materialize_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - body: &mut Value, - ) -> HistoryResolution { - self.resolve_request_scoped(scope, allow_call_id_fallback, true, body) - .await - } - - async fn resolve_request_scoped( - &self, - scope: &str, - allow_call_id_fallback: bool, - strip_materialized_previous_id: bool, - body: &mut Value, - ) -> HistoryResolution { - let previous_response_id = body - .get("previous_response_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string); - let had_previous_response_id = previous_response_id.is_some(); - - let input_was_missing = body.get("input").is_none(); - let original_input = body.get_mut("input").map(std::mem::take); - let original_was_object = original_input.as_ref().is_some_and(Value::is_object); - let mut original_string = None; - let mut unsupported_input = None; - let items = match original_input { - Some(Value::Array(items)) => items, - Some(Value::Object(object)) => vec![Value::Object(object)], - Some(Value::String(value)) => { - original_string = Some(value.clone()); - vec![serde_json::json!({ - "type": "message", - "role": "user", - "content": value, - })] - } - Some(other) => { - unsupported_input = Some(other); - Vec::new() - } - None => Vec::new(), - }; - - let output_call_ids = items - .iter() - .filter(|item| { - item.get("type") - .and_then(Value::as_str) - .is_some_and(is_call_output_item_type) - }) - .filter_map(response_item_call_id) - .collect::>(); - let existing_call_ids = items - .iter() - .filter(|item| { - item.get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - }) - .filter_map(response_item_call_id) - .collect::>(); - let requested_call_ids = output_call_ids - .union(&existing_call_ids) - .cloned() - .collect::>(); - - let lookup = self - .lookup( - scope, - previous_response_id.as_deref(), - &requested_call_ids, - allow_call_id_fallback, - ) - .await; - let previous_found = lookup.previous.is_some(); - let previous_origin = lookup - .previous - .as_ref() - .map(|response| response.origin.clone()); - let previous_materialized = lookup - .previous - .as_ref() - .is_some_and(|response| response.materializable); - - if let Some(original_input) = unsupported_input { - if let Some(object) = body.as_object_mut() { - object.insert("input".to_string(), original_input); - } - return HistoryResolution { - changed: 0, - had_previous_response_id, - previous_found, - previous_materialized: false, - previous_origin, - }; - } - - // A native provider may still own a continuation that the gateway observed only after a - // restart. Keep that request byte-for-byte intact for same-provider passthrough; callers - // must reject it before cross-wire/provider-switch forwarding because its prefix is absent. - if previous_found && !previous_materialized { - if !input_was_missing { - let restored_input = if original_string.is_some() && items.len() == 1 { - Value::String(original_string.unwrap_or_default()) - } else if original_was_object && items.len() == 1 { - items.into_iter().next().unwrap_or(Value::Null) - } else { - Value::Array(items) - }; - if let Some(object) = body.as_object_mut() { - object.insert("input".to_string(), restored_input); - } - } - return HistoryResolution { - changed: 0, - had_previous_response_id, - previous_found, - previous_materialized, - previous_origin, - }; - } - let replay_context = lookup - .previous - .as_ref() - .or_else(|| lookup.fallback.materializable.then_some(&lookup.fallback)); - let (items, restored) = merge_previous_context(items, replay_context); - let mut enriched = 0usize; - let mut new_items = Vec::with_capacity(items.len()); - - for mut item in items { - if item - .get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - { - if let Some(call_id) = response_item_call_id(&item) { - if let Some(cached) = lookup.call(&call_id) { - if enrich_call_item_from_cache(&mut item, cached) { - enriched += 1; - } - } - } - } - new_items.push(item); - } - - let changed = restored + enriched; - let resolved_input = if changed == 0 && original_string.is_some() && new_items.len() == 1 { - Some(Value::String(original_string.unwrap_or_default())) - } else if changed == 0 && original_was_object && new_items.len() == 1 { - Some(new_items.into_iter().next().unwrap_or(Value::Null)) - } else if input_was_missing && changed == 0 { - None - } else { - Some(Value::Array(new_items)) - }; - if let (Some(object), Some(resolved_input)) = (body.as_object_mut(), resolved_input) { - object.insert("input".to_string(), resolved_input); - } - if strip_materialized_previous_id && previous_materialized { - if let Some(object) = body.as_object_mut() { - object.remove("previous_response_id"); - } - } - HistoryResolution { - changed, - had_previous_response_id, - previous_found, - previous_materialized, - previous_origin, - } - } - - async fn lookup( - &self, - scope: &str, - previous_response_id: Option<&str>, - requested_call_ids: &HashSet, - allow_call_id_fallback: bool, - ) -> CachedLookup { - let inner = self.inner.read().await; - let previous = previous_response_id.and_then(|id| { - inner - .responses - .get(&(scope.to_string(), id.to_string())) - .cloned() - }); - let fallback = if allow_call_id_fallback { - inner.unique_fallback_response(scope, requested_call_ids, previous.as_ref()) - } else { - CachedResponse::default() - }; - CachedLookup { previous, fallback } - } -} - -impl HistoryInner { - fn insert_response( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - ) -> usize { - self.insert_response_with_metadata( - scope, - response_id, - request_input, - output, - ResponseOrigin::Local, - true, - ) - } - - fn insert_response_with_metadata( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - origin: ResponseOrigin, - materializable: bool, - ) -> usize { - self.insert_response_with_metadata_and_limits( - scope, - response_id, - request_input, - output, - origin, - materializable, - MAX_CACHED_RESPONSES, - MAX_CACHED_HISTORY_BYTES, - ) - } - - fn insert_response_with_limits( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - max_responses: usize, - max_bytes: usize, - ) -> usize { - self.insert_response_with_metadata_and_limits( - scope, - response_id, - request_input, - output, - ResponseOrigin::Local, - true, - max_responses, - max_bytes, - ) - } - - #[allow(clippy::too_many_arguments)] - fn insert_response_with_metadata_and_limits( - &mut self, - scope: &str, - response_id: &str, - request_input: Vec, - output: Vec, - origin: ResponseOrigin, - materializable: bool, - max_responses: usize, - max_bytes: usize, - ) -> usize { - let mut cached_response = CachedResponse { - request_input, - output, - origin, - materializable, - ..CachedResponse::default() - }; - for item in &cached_response.output { - if let Some((call_id, item)) = cached_call_item(item) { - if !cached_response.calls_by_id.contains_key(&call_id) { - cached_response.call_order.push(call_id.clone()); - } - cached_response.calls_by_id.insert(call_id, item); - } - } - cached_response.serialized_bytes = - cached_response_size(scope, response_id, &cached_response); - let cached_count = cached_response.output.len(); - let response_key = (scope.to_string(), response_id.to_string()); - - // Never let one pathological request flush every useful older entry before being evicted - // itself. A same-id response is authoritative, though, so an oversized replacement drops - // only its stale predecessor rather than leaving old history addressable under that id. - if cached_response.serialized_bytes > max_bytes { - if self.remove_response(&response_key) { - self.response_order - .retain(|cached_id| cached_id != &response_key); - } - return 0; - } - - let replacing = self.responses.contains_key(&response_key); - if !replacing { - self.response_order.push_back(response_key.clone()); - } - - // A completed response is authoritative. Replacing an already-seen id keeps - // retry/replay recording idempotent and prevents stale call-index entries. - self.remove_response(&response_key); - - for call_id in &cached_response.call_order { - self.index_call(scope, &call_id, &response_key); - } - self.cached_bytes = self - .cached_bytes - .checked_add(cached_response.serialized_bytes) - .unwrap_or(usize::MAX); - self.responses.insert(response_key.clone(), cached_response); - self.prune_to_limits(max_responses, max_bytes); - if self.responses.contains_key(&response_key) { - cached_count - } else { - 0 - } - } - - fn prune_to_limits(&mut self, max_responses: usize, max_bytes: usize) { - while self.response_order.len() > max_responses || self.cached_bytes > max_bytes { - let Some(response_id) = self.response_order.pop_front() else { - break; - }; - self.remove_response(&response_id); - } - } - - fn remove_response(&mut self, response_id: &ScopedResponseId) -> bool { - self.remove_response_from_call_index(response_id); - let Some(response) = self.responses.remove(response_id) else { - return false; - }; - self.cached_bytes = self.cached_bytes.saturating_sub(response.serialized_bytes); - true - } - - fn index_call(&mut self, scope: &str, call_id: &str, response_id: &ScopedResponseId) { - let response_ids = self - .call_index - .entry((scope.to_string(), call_id.to_string())) - .or_default(); - if !response_ids - .iter() - .any(|cached_id| cached_id == response_id) - { - response_ids.push_back(response_id.clone()); - } - } - - fn remove_response_from_call_index(&mut self, response_id: &ScopedResponseId) { - for response_ids in self.call_index.values_mut() { - response_ids.retain(|cached_id| cached_id != response_id); - } - self.call_index - .retain(|_, response_ids| !response_ids.is_empty()); - } - - fn unique_fallback_response( - &self, - scope: &str, - requested_call_ids: &HashSet, - previous: Option<&CachedResponse>, - ) -> CachedResponse { - // A resolved previous_response_id is authoritative. Grafting calls from another cached - // branch onto it would create history that no provider ever observed. - if previous.is_some() || requested_call_ids.is_empty() { - return CachedResponse::default(); - } - - let mut source_response_id: Option = None; - for call_id in requested_call_ids { - let Some(response_id) = self.unique_response_for_call(scope, call_id) else { - return CachedResponse::default(); - }; - if source_response_id - .as_ref() - .is_some_and(|source| source != &response_id) - { - return CachedResponse::default(); - } - source_response_id = Some(response_id); - } - - source_response_id - .and_then(|response_id| self.responses.get(&response_id).cloned()) - .unwrap_or_default() - } - - fn unique_response_for_call(&self, scope: &str, call_id: &str) -> Option { - let response_ids = self - .call_index - .get(&(scope.to_string(), call_id.to_string()))?; - let mut found: Option<&ScopedResponseId> = None; - for response_id in response_ids { - let Some(response) = self.responses.get(response_id) else { - continue; - }; - // An owner-only native response may contain just a delta after an unavailable prefix. - // Its calls are useful to that provider via previous_response_id, but are never a safe - // source for session fallback because doing so would manufacture a truncated chain. - if !response.materializable { - continue; - } - if !response.calls_by_id.contains_key(call_id) { - continue; - } - if found.is_some() { - return None; - } - found = Some(response_id); - } - found.cloned() - } - - fn unique_call(&self, scope: &str, call_id: &str) -> Option<&Value> { - let response_id = self.unique_response_for_call(scope, call_id)?; - self.responses - .get(&response_id) - .and_then(|response| response.calls_by_id.get(call_id)) - } -} - -#[derive(Default)] -struct ByteCounter { - bytes: usize, -} - -impl Write for ByteCounter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.bytes = self.bytes.saturating_add(buf.len()); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -fn serialized_size(value: &T) -> usize { - let mut counter = ByteCounter::default(); - if serde_json::to_writer(&mut counter, value).is_err() { - usize::MAX - } else { - counter.bytes - } -} - -fn cached_response_size(scope: &str, response_id: &str, response: &CachedResponse) -> usize { - let origin_bytes = match &response.origin { - ResponseOrigin::Local => 1, - ResponseOrigin::Native(provider_id) => 1usize.saturating_add(serialized_size(provider_id)), - }; - serialized_size(scope) - .saturating_add(serialized_size(response_id)) - .saturating_add(serialized_size(&response.request_input)) - .saturating_add(serialized_size(&response.output)) - .saturating_add(serialized_size(&response.calls_by_id)) - .saturating_add(serialized_size(&response.call_order)) - .saturating_add(origin_bytes) - .saturating_add(1) -} - -impl CachedLookup { - fn call(&self, call_id: &str) -> Option<&Value> { - self.previous - .as_ref() - .and_then(|previous| previous.calls_by_id.get(call_id)) - .or_else(|| self.fallback.calls_by_id.get(call_id)) - } -} - -/// Merge the directly referenced response's complete model-visible context into the new input. -/// -/// The cached request prefix is always restored before the previous response output. A client that -/// already sent the full prefix is detected by a matching request prefix plus at least one prior -/// output anchor, while a coincidentally repeated new user message is still treated as a delta. -/// Every supported previous output item is restored. Filtering unmatched calls would no longer be -/// equivalent to provider-side `previous_response_id` continuation and could silently turn an -/// invalid continuation into a different, truncated conversation. -fn merge_previous_context( - items: Vec, - previous: Option<&CachedResponse>, -) -> (Vec, usize) { - let Some(previous) = previous else { - return (items, 0); - }; - - let eligible_output = previous.output.clone(); - let request_prefix_len = previous.request_input.len(); - let has_request_prefix = request_prefix_len <= items.len() - && previous - .request_input - .iter() - .zip(&items) - .all(|(cached, input)| cached_item_matches_input(cached, input)); - let has_output_anchor = has_request_prefix - && !previous.output.is_empty() - && previous.output.iter().any(|cached| { - items[request_prefix_len..] - .iter() - .any(|input| cached_item_matches_input(cached, input)) - }); - - let (prefix, tail, restored_input) = if has_request_prefix && has_output_anchor { - ( - items[..request_prefix_len].to_vec(), - items[request_prefix_len..].to_vec(), - 0, - ) - } else { - ( - previous.request_input.clone(), - items, - previous.request_input.len(), - ) - }; - let (tail, restored_output) = merge_cached_output(tail, &eligible_output); - let mut merged = Vec::with_capacity(prefix.len() + tail.len()); - merged.extend(prefix); - merged.extend(tail); - (merged, restored_input + restored_output) -} - -fn merge_cached_output(items: Vec, eligible: &[Value]) -> (Vec, usize) { - if eligible.is_empty() { - return (items, 0); - } - - // Match explicit prior-output items monotonically. Legal explicit history keeps - // response order, and monotonic matching avoids treating a coincidentally reused - // text value later in the request as the prior item. - let mut matches = HashMap::::new(); - let mut next_input = 0usize; - for (cached_index, cached) in eligible.iter().enumerate() { - let Some(relative_index) = items[next_input..] - .iter() - .position(|item| cached_item_matches_input(cached, item)) - else { - continue; - }; - let input_index = next_input + relative_index; - matches.insert(input_index, cached_index); - next_input = input_index + 1; - } - - if matches.is_empty() { - let restored = eligible.len(); - let mut merged = Vec::with_capacity(restored + items.len()); - merged.extend(eligible.iter().cloned()); - merged.extend(items); - return (merged, restored); - } - - let last_match = matches.keys().copied().max().unwrap_or(0); - let mut merged = Vec::with_capacity(eligible.len() + items.len()); - let mut cached_cursor = 0usize; - let mut restored = 0usize; - for (input_index, item) in items.into_iter().enumerate() { - if let Some(&cached_index) = matches.get(&input_index) { - while cached_cursor < cached_index { - merged.push(eligible[cached_cursor].clone()); - cached_cursor += 1; - restored += 1; - } - // The explicit item wins (and may intentionally contain richer content). - merged.push(item); - cached_cursor = cached_index + 1; - if input_index == last_match { - while cached_cursor < eligible.len() { - merged.push(eligible[cached_cursor].clone()); - cached_cursor += 1; - restored += 1; - } - } - } else { - merged.push(item); - } - } - (merged, restored) -} - -fn request_input_items(request: &Value) -> Vec { - match request.get("input") { - Some(Value::Array(items)) => items.clone(), - Some(Value::Object(object)) => vec![Value::Object(object.clone())], - Some(Value::String(value)) => vec![serde_json::json!({ - "type": "message", - "role": "user", - "content": value, - })], - _ => Vec::new(), - } -} - -fn request_input_is_materializable(request: &Value) -> bool { - match request.get("input") { - None | Some(Value::String(_)) => true, - Some(Value::Array(items)) => items.iter().all(history_item_is_materializable), - Some(item @ Value::Object(_)) => history_item_is_materializable(item), - _ => false, - } -} - -fn history_item_is_materializable(item: &Value) -> bool { - let Some(object) = item.as_object() else { - return false; - }; - let item_type = object - .get("type") - .and_then(Value::as_str) - .unwrap_or_else(|| { - if object.get("role").is_some() { - "message" - } else { - "" - } - }); - match item_type { - // Responses Lite carries request-scoped tool declarations as a developer input item. - // They are ordinary JSON tool definitions and can be replayed when CC Buddy has to - // materialize a previous response across providers. - "additional_tools" => object.get("tools").is_some_and(Value::is_array), - "message" => object - .get("content") - .map_or(true, history_content_is_materializable), - "reasoning" => { - let has_opaque_reasoning = object - .get("encrypted_content") - .is_some_and(|value| !is_empty_value(value)); - !has_opaque_reasoning - && object - .get("summary") - .map_or(true, history_content_is_materializable) - && object - .get("content") - .map_or(true, history_content_is_materializable) - } - item_type if is_call_item_type(item_type) || is_call_output_item_type(item_type) => true, - _ => false, - } -} - -fn history_content_is_materializable(content: &Value) -> bool { - match content { - Value::Null | Value::String(_) => true, - Value::Array(parts) => parts.iter().all(|part| { - let Some(part_type) = part.get("type").and_then(Value::as_str) else { - return false; - }; - match part_type { - "input_text" | "output_text" | "text" | "summary_text" => { - part.get("text").is_some_and(Value::is_string) - } - "input_image" => part - .get("image_url") - .and_then(|value| { - value - .as_str() - .or_else(|| value.get("url").and_then(Value::as_str)) - }) - .is_some(), - _ => false, - } - }), - _ => false, - } -} - -fn cached_item_matches_input(cached: &Value, input: &Value) -> bool { - let cached_type = cached.get("type").and_then(Value::as_str); - let input_type = input.get("type").and_then(Value::as_str); - if cached_type.is_some_and(is_call_item_type) { - return input_type.is_some_and(is_call_item_type) - && response_item_call_id(cached).is_some_and(|call_id| { - response_item_call_id(input).as_deref() == Some(call_id.as_str()) - }); - } - - if let Some(id) = cached - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty()) - { - return cached_type == input_type - && input - .get("id") - .and_then(Value::as_str) - .is_some_and(|input_id| input_id.trim() == id); - } - cached == input -} - -fn cached_call_item(item: &Value) -> Option<(String, Value)> { - if !item - .get("type") - .and_then(Value::as_str) - .is_some_and(is_call_item_type) - { - return None; - } - let call_id = response_item_call_id(item)?; - Some((call_id, item.clone())) -} - -fn cached_output_item(item: &Value) -> Option { - match item.get("type").and_then(Value::as_str) { - Some("reasoning") => Some(item.clone()), - Some("message") - if item - .get("role") - .and_then(Value::as_str) - .map_or(true, |role| role == "assistant") => - { - Some(item.clone()) - } - Some(item_type) if is_call_item_type(item_type) => Some(item.clone()), - _ => None, - } -} - -fn response_item_call_id(item: &Value) -> Option { - item.get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn is_call_item_type(item_type: &str) -> bool { - matches!( - item_type, - "function_call" | "custom_tool_call" | "tool_search_call" - ) -} - -fn is_call_output_item_type(item_type: &str) -> bool { - matches!( - item_type, - "function_call_output" | "custom_tool_call_output" | "tool_search_output" - ) -} - -fn is_empty_value(value: &Value) -> bool { - match value { - Value::Null => true, - Value::String(value) => value.trim().is_empty(), - Value::Array(value) => value.is_empty(), - Value::Object(value) => value.is_empty(), - _ => false, - } -} - -fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool { - let mut changed = false; - for key in [ - "name", - "namespace", - "arguments", - "input", - "status", - "execution", - "reasoning_content", - "reasoning", - ] { - if item.get(key).is_some_and(|value| !is_empty_value(value)) { - continue; - } - let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else { - continue; - }; - if let Some(object) = item.as_object_mut() { - object.insert(key.to_string(), value.clone()); - changed = true; - } - } - changed -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::sync::Arc; - - #[test] - fn responses_lite_additional_tools_are_materializable() { - let request = json!({ - "input": [{ - "type": "additional_tools", - "role": "developer", - "tools": [ - { "type": "custom", "name": "exec" }, - { - "type": "namespace", - "name": "collaboration", - "tools": [{ "type": "function", "name": "spawn_agent" }] - } - ] - }] - }); - assert!(request_input_is_materializable(&request)); - } - - #[tokio::test] - async fn restores_call_before_output_from_previous_response() { - let history = CodexHistoryStore::default(); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type": "function_call", - "call_id": "call_1", - "name": "read_file", - "arguments": "{\"path\":\"README.md\"}", - "reasoning_content": "Need to inspect the file." - }] - }) - ) - .await, - 1 - ); - - let mut request = json!({ - "previous_response_id": "resp_1", - "input": [{ - "type": "function_call_output", - "call_id": "call_1", - "output": "ok" - }] - }); - - assert_eq!(history.enrich_request(&mut request).await, 1); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["name"], "read_file"); - assert_eq!(input[0]["reasoning_content"], "Need to inspect the file."); - assert_eq!(input[1]["type"], "function_call_output"); - - // The restored item-level reasoning must survive the JSON → chat IR half, - // not merely remain present in the enriched request body. - let decoded = crate::protocol::openai_responses::decode_request(&request).unwrap(); - assert_eq!( - decoded.messages[0].reasoning_content.as_deref(), - Some("Need to inspect the file.") - ); - assert_eq!( - decoded.messages[0].tool_calls.as_ref().unwrap()[0].id, - "call_1" - ); - } - - #[tokio::test] - async fn restores_text_continuation_and_deduplicates_explicit_prior_output() { - let history = CodexHistoryStore::default(); - let reasoning = json!({ - "type":"reasoning", - "id":"rs_text", - "summary":[{"type":"summary_text","text":"continue the thought"}] - }); - let assistant = json!({ - "type":"message", - "id":"msg_text", - "role":"assistant", - "content":[{"type":"output_text","text":"First answer."}] - }); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_text", - "output":[reasoning.clone(), assistant.clone()] - }) - ) - .await, - 2 - ); - - let mut continuation = json!({ - "previous_response_id":"resp_text", - "input":"Continue." - }); - assert_eq!(history.enrich_request(&mut continuation).await, 2); - let input = continuation["input"].as_array().unwrap(); - assert_eq!(input.len(), 3); - assert_eq!(input[0]["id"], "rs_text"); - assert_eq!(input[1]["id"], "msg_text"); - assert_eq!(input[2]["role"], "user"); - - let decoded = crate::protocol::openai_responses::decode_request(&continuation).unwrap(); - assert_eq!(decoded.messages.len(), 2); - assert_eq!(decoded.messages[0].content_as_text(), "First answer."); - assert_eq!( - decoded.messages[0].reasoning_content.as_deref(), - Some("continue the thought") - ); - assert_eq!(decoded.messages[1].content_as_text(), "Continue."); - - // A client may send explicit history even while retaining previous_response_id. - // Stable item ids anchor that history, so the cache must not duplicate it. - let mut explicit = json!({ - "previous_response_id":"resp_text", - "input":[ - reasoning, - assistant, - {"type":"message","role":"user","content":"Continue."} - ] - }); - assert_eq!(history.enrich_request(&mut explicit).await, 0); - assert_eq!(explicit["input"].as_array().unwrap().len(), 3); - } - - #[tokio::test] - async fn restores_request_and_response_context_across_multiple_hops() { - let history = CodexHistoryStore::default(); - let first_request = json!({ - "input":[{"type":"message","role":"user","content":"First question."}] - }); - let first_response = json!({ - "id":"resp_first", - "output":[{ - "type":"message","id":"msg_first","role":"assistant", - "content":[{"type":"output_text","text":"First answer."}] - }] - }); - assert_eq!( - history - .record_response(&first_request, &first_response) - .await, - 1 - ); - - let mut second_request = json!({ - "previous_response_id":"resp_first", - "input":[{"type":"message","role":"user","content":"Second question."}] - }); - assert_eq!(history.enrich_request(&mut second_request).await, 2); - let second_response = json!({ - "id":"resp_second", - "output":[{ - "type":"message","id":"msg_second","role":"assistant", - "content":[{"type":"output_text","text":"Second answer."}] - }] - }); - assert_eq!( - history - .record_response(&second_request, &second_response) - .await, - 1 - ); - - let mut third_request = json!({ - "previous_response_id":"resp_second", - "input":[{"type":"message","role":"user","content":"Third question."}] - }); - assert_eq!(history.enrich_request(&mut third_request).await, 4); - let decoded = crate::protocol::openai_responses::decode_request(&third_request).unwrap(); - let transcript = decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(); - assert_eq!( - transcript, - vec![ - "First question.", - "First answer.", - "Second question.", - "Second answer.", - "Third question.", - ] - ); - - // Full explicit history plus previous_response_id must remain idempotent. - let before = third_request.clone(); - assert_eq!(history.enrich_request(&mut third_request).await, 0); - assert_eq!(third_request, before); - } - - #[tokio::test] - async fn restores_parallel_calls_as_one_ordered_group() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_parallel", - "output": [ - {"type":"function_call","call_id":"call_a","name":"first","arguments":"{}"}, - {"type":"function_call","call_id":"call_b","name":"second","arguments":"{}"} - ] - }), - ) - .await; - - // Outputs may arrive in a different order. The assistant call group must - // retain the order in which the response originally emitted the calls. - let mut request = json!({ - "previous_response_id": "resp_parallel", - "input": [ - {"type":"function_call_output","call_id":"call_b","output":"two"}, - {"type":"function_call_output","call_id":"call_a","output":"one"} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 2); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["call_id"], "call_a"); - assert_eq!(input[1]["call_id"], "call_b"); - assert_eq!(input[2]["type"], "function_call_output"); - assert_eq!(input[3]["type"], "function_call_output"); - } - - #[tokio::test] - async fn same_client_session_recovers_across_provider_switches() { - let history = CodexHistoryStore::default(); - // The scope deliberately contains no provider identity: switching the active provider - // must not sever the client's previous_response_id chain. - let scope = "session-1"; - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type":"function_call", - "call_id":"unique_call", - "name":"lookup", - "arguments":"{}" - }] - }), - ) - .await; - - for previous in [None, Some("stale_response"), Some("resp_1")] { - let mut request = json!({ - "input": [{ - "type":"function_call_output", - "call_id":"unique_call", - "output":"ok" - }] - }); - if let Some(previous) = previous { - request["previous_response_id"] = json!(previous); - } - - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 1 - ); - assert_eq!(request["input"][0]["type"], "function_call"); - assert_eq!(request["input"][0]["name"], "lookup"); - } - } - - #[tokio::test] - async fn missing_previous_response_fallback_requires_a_safe_scope() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_1", - "output":[{ - "type":"function_call","call_id":"call_1", - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - let mut request = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_1","output":"ok" - }] - }); - - assert_eq!(history.enrich_request(&mut request).await, 0); - assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); - } - - #[tokio::test] - async fn call_id_fallback_never_crosses_client_session_scope() { - let history = CodexHistoryStore::default(); - history - .record_response_scoped( - "session-a", - &json!({"input":[]}), - &json!({ - "id":"resp_a", - "output":[{ - "type":"function_call","call_id":"call_a", - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - let mut request = json!({ - "previous_response_id":"stale", - "input":[{ - "type":"function_call_output","call_id":"call_a","output":"ok" - }] - }); - - assert_eq!( - history - .enrich_request_scoped("session-b", true, &mut request) - .await, - 0 - ); - assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); - } - - #[tokio::test] - async fn ambiguous_call_id_does_not_use_fallback() { - let history = CodexHistoryStore::default(); - let scope = "session-1"; - for response_id in ["resp_1", "resp_2"] { - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": response_id, - "output": [{ - "type":"function_call", - "call_id":"shared_call", - "name":"lookup", - "arguments":"{}" - }] - }), - ) - .await; - } - - let mut request = json!({ - "input": [{ - "type":"function_call_output", - "call_id":"shared_call", - "output":"ok" - }] - }); - - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 0 - ); - assert_eq!(request["input"].as_array().unwrap().len(), 1); - assert_eq!(request["input"][0]["type"], "function_call_output"); - let error = crate::protocol::openai_responses::decode_request(&request).unwrap_err(); - assert!(error.contains("shared_call")); - } - - #[tokio::test] - async fn enriches_existing_call_without_duplicating_it() { - let history = CodexHistoryStore::default(); - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id": "resp_1", - "output": [{ - "type":"function_call", - "call_id":"call_1", - "name":"read_file", - "arguments":"{\"path\":\"README.md\"}", - "reasoning_content":"Need the file." - }] - }), - ) - .await; - - let mut request = json!({ - "previous_response_id":"resp_1", - "input":[ - {"type":"function_call","call_id":"call_1"}, - {"type":"function_call_output","call_id":"call_1","output":"ok"} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 1); - let input = request["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["name"], "read_file"); - assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}"); - assert_eq!(input[0]["reasoning_content"], "Need the file."); - } - - #[tokio::test] - async fn restores_custom_and_tool_search_calls() { - let history = CodexHistoryStore::default(); - assert_eq!( - history - .record_response( - &json!({ "input": [] }), - &json!({ - "id":"resp_tools", - "output":[ - { - "type":"custom_tool_call", - "call_id":"call_patch", - "name":"apply_patch", - "input":"*** Begin Patch\n*** End Patch" - }, - { - "type":"tool_search_call", - "call_id":"call_search", - "status":"completed", - "execution":"client", - "arguments":{"query":"mail tools"} - } - ] - }) - ) - .await, - 2 - ); - - let mut request = json!({ - "previous_response_id":"resp_tools", - "input":[ - {"type":"custom_tool_call_output","call_id":"call_patch","output":"patched"}, - {"type":"tool_search_output","call_id":"call_search","tools":[]} - ] - }); - - assert_eq!(history.enrich_request(&mut request).await, 2); - let input = request["input"].as_array().unwrap(); - assert_eq!(input[0]["type"], "custom_tool_call"); - assert_eq!(input[0]["input"], "*** Begin Patch\n*** End Patch"); - assert_eq!(input[1]["type"], "tool_search_call"); - assert_eq!(input[2]["type"], "custom_tool_call_output"); - assert_eq!(input[3]["type"], "tool_search_output"); - } - - #[tokio::test] - async fn preserves_scalar_and_single_object_input_when_no_change_is_needed() { - let history = CodexHistoryStore::default(); - let mut scalar_request = json!({"input":"hello"}); - assert_eq!(history.enrich_request(&mut scalar_request).await, 0); - assert_eq!(scalar_request["input"], "hello"); - - let mut request = json!({ - "input": {"type":"message","role":"user","content":"hello"} - }); - - assert_eq!(history.enrich_request(&mut request).await, 0); - assert!(request["input"].is_object()); - assert_eq!(request["input"]["content"], "hello"); - } - - #[tokio::test] - async fn concurrent_recording_is_safe_and_searchable() { - let history = Arc::new(CodexHistoryStore::default()); - let scope = "session-1"; - let mut tasks = Vec::new(); - for index in 0..16 { - let history = history.clone(); - tasks.push(tokio::spawn(async move { - history - .record_response_scoped( - scope, - &json!({ "input": [] }), - &json!({ - "id": format!("resp_{index}"), - "output": [{ - "type":"function_call", - "call_id":format!("call_{index}"), - "name":"work", - "arguments":"{}" - }] - }), - ) - .await - })); - } - for task in tasks { - assert_eq!(task.await.unwrap(), 1); - } - - let mut request = json!({ - "input":[{ - "type":"function_call_output", - "call_id":"call_9", - "output":"done" - }] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut request) - .await, - 1 - ); - assert_eq!(request["input"][0]["call_id"], "call_9"); - assert_eq!(request["input"][0]["name"], "work"); - } - - #[test] - fn byte_budget_evicts_oldest_responses_and_cleans_call_index() { - let old_request = vec![json!({ - "type":"message","role":"user","content":"old request" - })]; - let old_output = vec![json!({ - "type":"function_call", - "call_id":"shared_call", - "name":"old_tool", - "arguments":"{\"value\":\"old\"}" - })]; - let new_request = vec![json!({ - "type":"message","role":"user","content":"new request" - })]; - let new_output = vec![json!({ - "type":"function_call", - "call_id":"shared_call", - "name":"new_tool", - "arguments":"{\"value\":\"new\"}" - })]; - let mut probe = HistoryInner::default(); - let scope = "session-1"; - let new_key = (scope.to_string(), "resp_new".to_string()); - let old_key = (scope.to_string(), "resp_old".to_string()); - probe.insert_response(scope, "resp_new", new_request.clone(), new_output.clone()); - let newest_size = probe.responses[&new_key].serialized_bytes; - - let mut inner = HistoryInner::default(); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_old", - old_request, - old_output, - MAX_CACHED_RESPONSES, - usize::MAX, - ), - 1 - ); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_new", - new_request, - new_output, - MAX_CACHED_RESPONSES, - newest_size, - ), - 1 - ); - - assert_eq!(inner.cached_bytes, newest_size); - assert!(!inner.responses.contains_key(&old_key)); - assert!(inner.responses.contains_key(&new_key)); - assert_eq!( - inner - .unique_call(scope, "shared_call") - .and_then(|item| item.get("name")) - .and_then(Value::as_str), - Some("new_tool") - ); - } - - #[test] - fn same_id_replacement_keeps_exact_accounting_and_no_stale_call_index() { - let mut inner = HistoryInner::default(); - let scope = "session-1"; - let response_key = (scope.to_string(), "resp_same".to_string()); - let call_key = (scope.to_string(), "call_new".to_string()); - assert_eq!( - inner.insert_response( - scope, - "resp_same", - vec![json!({"type":"message","role":"user","content":"short"})], - vec![json!({ - "type":"function_call","call_id":"call_old", - "name":"old_tool","arguments":"{}" - })], - ), - 1 - ); - assert_eq!( - inner.insert_response( - scope, - "resp_same", - vec![json!({ - "type":"message","role":"user", - "content":"a longer authoritative replacement" - })], - vec![json!({ - "type":"function_call","call_id":"call_new", - "name":"new_tool","arguments":"{\"ok\":true}" - })], - ), - 1 - ); - - let replacement_bytes = inner.responses[&response_key].serialized_bytes; - assert_eq!(inner.response_order.len(), 1); - assert_eq!(inner.cached_bytes, replacement_bytes); - assert!(inner.unique_call(scope, "call_old").is_none()); - assert_eq!( - inner - .unique_call(scope, "call_new") - .and_then(|item| item.get("name")) - .and_then(Value::as_str), - Some("new_tool") - ); - - // Replaying the same completed response must not duplicate order/index entries or bytes. - let request = inner.responses[&response_key].request_input.clone(); - let output = inner.responses[&response_key].output.clone(); - assert_eq!( - inner.insert_response(scope, "resp_same", request, output), - 1 - ); - assert_eq!(inner.response_order.len(), 1); - assert_eq!(inner.cached_bytes, replacement_bytes); - assert_eq!(inner.call_index[&call_key].len(), 1); - } - - #[test] - fn oversized_insert_preserves_unrelated_entries_and_drops_stale_replacement() { - let keep_request = vec![json!({ - "type":"message","role":"user","content":"keep" - })]; - let keep_output = vec![json!({ - "type":"function_call","call_id":"call_keep", - "name":"keep_tool","arguments":"{}" - })]; - let oversized_request = vec![json!({ - "type":"message","role":"user","content":"x".repeat(2048) - })]; - let oversized_output = vec![json!({ - "type":"function_call","call_id":"call_huge", - "name":"huge_tool","arguments":"y".repeat(2048) - })]; - - let scope = "session-1"; - let keep_key = (scope.to_string(), "resp_keep".to_string()); - let mut probe = HistoryInner::default(); - probe.insert_response( - scope, - "resp_keep", - keep_request.clone(), - keep_output.clone(), - ); - let budget = probe.responses[&keep_key].serialized_bytes; - - let mut inner = HistoryInner::default(); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_keep", - keep_request, - keep_output, - MAX_CACHED_RESPONSES, - budget, - ), - 1 - ); - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_huge", - oversized_request.clone(), - oversized_output.clone(), - MAX_CACHED_RESPONSES, - budget, - ), - 0 - ); - assert!(inner.responses.contains_key(&keep_key)); - assert_eq!(inner.cached_bytes, budget); - assert!(inner.unique_call(scope, "call_huge").is_none()); - - assert_eq!( - inner.insert_response_with_limits( - scope, - "resp_keep", - oversized_request, - oversized_output, - MAX_CACHED_RESPONSES, - budget, - ), - 0 - ); - assert!(inner.responses.is_empty()); - assert!(inner.response_order.is_empty()); - assert!(inner.call_index.is_empty()); - assert_eq!(inner.cached_bytes, 0); - } - - #[tokio::test] - async fn native_history_materializes_across_provider_boundaries_and_strips_previous_id() { - let history = CodexHistoryStore::default(); - let scope = "session-native"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"message","role":"user","content":"first"}] - }), - &json!({ - "id":"resp_native_a","status":"completed", - "output":[{ - "type":"message","id":"msg_native_a","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_native_a", - "input":[{"type":"message","role":"user","content":"second"}] - }); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_found); - assert!(resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert!(next.get("previous_response_id").is_none()); - let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); - assert_eq!( - decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(), - vec!["first", "answer", "second"] - ); - } - - #[tokio::test] - async fn owner_only_native_history_is_reported_but_never_materialized() { - let history = CodexHistoryStore::default(); - let scope = "session-owner-only"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - false, - &json!({ - "previous_response_id":"unknown-before-restart", - "input":[{"type":"message","role":"user","content":"delta"}] - }), - &json!({ - "id":"resp_owner_only","status":"completed", - "output":[{ - "type":"message","id":"msg_owner_only","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_owner_only", - "input":[{"type":"message","role":"user","content":"next"}] - }); - let before = next.clone(); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_found); - assert!(!resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert_eq!(next, before); - } - - #[tokio::test] - async fn incomplete_response_remains_resumable_history() { - let history = CodexHistoryStore::default(); - let scope = "session-incomplete"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &json!({ - "input":[{"type":"message","role":"user","content":"write a lot"}] - }), - &json!({ - "id":"resp_incomplete","status":"incomplete", - "incomplete_details":{"reason":"max_output_tokens"}, - "output":[{ - "type":"message","id":"msg_partial","role":"assistant", - "content":[{"type":"output_text","text":"partial"}] - }] - }), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_incomplete", - "input":[{"type":"message","role":"user","content":"continue"}] - }); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.previous_materialized); - assert!(next.get("previous_response_id").is_none()); - let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); - assert_eq!( - decoded - .messages - .iter() - .map(|message| message.content_as_text()) - .collect::>(), - vec!["write a lot", "partial", "continue"] - ); - } - - #[tokio::test] - async fn metadata_recording_rejects_non_resumable_terminals() { - let history = CodexHistoryStore::default(); - let request = json!({ - "input":[{"type":"message","role":"user","content":"hello"}] - }); - for response in [ - json!({"id":"resp_failed","status":"failed","output":[]}), - json!({"id":"resp_partial","output":[]}), - json!({ - "id":"resp_compaction","object":"response.compaction","status":"completed", - "output":[{"type":"compaction","encrypted_content":"opaque"}] - }), - ] { - assert_eq!( - history - .record_response_scoped_with_metadata( - "session-terminal", - ResponseOrigin::Native("provider-a".to_string()), - true, - &request, - &response, - ) - .await, - 0 - ); - assert!(history - .response_metadata("session-terminal", response["id"].as_str().unwrap()) - .await - .is_none()); - } - } - - #[tokio::test] - async fn unsupported_output_and_owner_only_calls_never_become_portable_fallback() { - let history = CodexHistoryStore::default(); - let scope = "session-partial"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({"input":[{"type":"message","role":"user","content":"look"}]}), - &json!({ - "id":"resp_unsupported","object":"response","status":"completed", - "output":[{"type":"computer_call","id":"computer_1"}] - }), - ) - .await; - assert_eq!( - history - .response_metadata(scope, "resp_unsupported") - .await - .unwrap(), - ResponseMetadata { - origin: ResponseOrigin::Native("provider-a".to_string()), - materializable: false, - } - ); - - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - false, - &json!({ - "previous_response_id":"missing-prefix", - "input":[{"type":"message","role":"user","content":"run"}] - }), - &json!({ - "id":"resp_owner_call","status":"completed", - "output":[{ - "type":"function_call","call_id":"call_owner_only", - "name":"shell","arguments":"{}" - }] - }), - ) - .await; - let mut fallback = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_owner_only","output":"ok" - }] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut fallback) - .await, - 0 - ); - assert_eq!(fallback["input"].as_array().unwrap().len(), 1); - - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"compaction","encrypted_content":"opaque-prefix"}] - }), - &json!({ - "id":"resp_compacted_input","status":"completed", - "output":[{ - "type":"message","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - assert!( - !history - .response_metadata(scope, "resp_compacted_input") - .await - .unwrap() - .materializable - ); - } - - #[tokio::test] - async fn empty_output_does_not_collapse_an_identical_follow_up_input() { - let history = CodexHistoryStore::default(); - history - .record_response_scoped_with_metadata( - "session-empty-output", - ResponseOrigin::Local, - true, - &json!({"input":"ping"}), - &json!({"id":"resp_empty","status":"completed","output":[]}), - ) - .await; - let mut next = json!({ - "previous_response_id":"resp_empty", - "input":"ping" - }); - - let resolution = history - .materialize_request_scoped("session-empty-output", true, &mut next) - .await; - assert_eq!(resolution.changed, 1); - assert!(next.get("previous_response_id").is_none()); - let input = next["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["content"], "ping"); - assert_eq!(input[1]["content"], "ping"); - } - - #[tokio::test] - async fn call_fallback_uses_one_complete_branch_and_never_grafts_onto_previous() { - let history = CodexHistoryStore::default(); - let scope = "session-fallback-branch"; - for (response_id, call_id, prompt) in [ - ("resp_a", "call_a", "branch a"), - ("resp_b", "call_b", "branch b"), - ] { - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &json!({ - "input":[{"type":"message","role":"user","content":prompt}] - }), - &json!({ - "id":response_id,"status":"completed", - "output":[{ - "type":"function_call","call_id":call_id, - "name":"lookup","arguments":"{}" - }] - }), - ) - .await; - } - - let mut one_branch = json!({ - "input":[{ - "type":"function_call_output","call_id":"call_a","output":"a" - }] - }); - let resolution = history - .materialize_request_scoped(scope, true, &mut one_branch) - .await; - assert!(!resolution.had_previous_response_id); - assert_eq!(resolution.changed, 2); - assert_eq!(one_branch["input"][0]["content"], "branch a"); - assert_eq!(one_branch["input"][1]["call_id"], "call_a"); - assert_eq!(one_branch["input"][2]["call_id"], "call_a"); - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Local, - true, - &one_branch, - &json!({ - "id":"resp_after_fallback","status":"completed", - "output":[{ - "type":"message","role":"assistant", - "content":[{"type":"output_text","text":"done"}] - }] - }), - ) - .await; - let mut switched_provider = json!({ - "previous_response_id":"resp_after_fallback", - "input":"next" - }); - let switched = history - .materialize_request_scoped(scope, true, &mut switched_provider) - .await; - assert!(switched.previous_materialized); - assert!(switched_provider.get("previous_response_id").is_none()); - assert_eq!(switched_provider["input"][0]["content"], "branch a"); - - let mut mixed_branches = json!({ - "input":[ - {"type":"function_call_output","call_id":"call_a","output":"a"}, - {"type":"function_call_output","call_id":"call_b","output":"b"} - ] - }); - assert_eq!( - history - .enrich_request_scoped(scope, true, &mut mixed_branches) - .await, - 0 - ); - assert_eq!(mixed_branches["input"].as_array().unwrap().len(), 2); - - let mut unrelated_to_previous = json!({ - "previous_response_id":"resp_a", - "input":[{ - "type":"function_call_output","call_id":"call_b","output":"b" - }] - }); - history - .materialize_request_scoped(scope, true, &mut unrelated_to_previous) - .await; - let input = unrelated_to_previous["input"].as_array().unwrap(); - assert!(input.iter().any(|item| item["call_id"] == "call_a")); - assert!(!input - .iter() - .any(|item| { item["type"] == "function_call" && item["call_id"] == "call_b" })); - assert!(crate::protocol::openai_responses::decode_request(&unrelated_to_previous).is_err()); - } - - #[tokio::test] - async fn previous_response_is_resolved_and_materialized_without_new_input() { - let history = CodexHistoryStore::default(); - let scope = "session-no-input"; - history - .record_response_scoped_with_metadata( - scope, - ResponseOrigin::Native("provider-a".to_string()), - true, - &json!({ - "input":[{"type":"message","role":"user","content":"first"}] - }), - &json!({ - "id":"resp_no_input","status":"completed", - "output":[{ - "type":"message","id":"msg_no_input","role":"assistant", - "content":[{"type":"output_text","text":"answer"}] - }] - }), - ) - .await; - let mut next = json!({"previous_response_id":"resp_no_input"}); - - let resolution = history - .materialize_request_scoped(scope, true, &mut next) - .await; - assert!(resolution.had_previous_response_id); - assert!(resolution.previous_found); - assert!(resolution.previous_materialized); - assert_eq!( - resolution.previous_origin, - Some(ResponseOrigin::Native("provider-a".to_string())) - ); - assert!(next.get("previous_response_id").is_none()); - let input = next["input"].as_array().unwrap(); - assert_eq!(input.len(), 2); - assert_eq!(input[0]["content"], "first"); - assert_eq!(input[1]["content"][0]["text"], "answer"); - } -} diff --git a/src-tauri/src/protocol/codex_history/cache_items.rs b/src-tauri/src/protocol/codex_history/cache_items.rs new file mode 100644 index 0000000..8fec1ad --- /dev/null +++ b/src-tauri/src/protocol/codex_history/cache_items.rs @@ -0,0 +1,91 @@ +// Reading call / call-output items out of a cached response, and grafting cached metadata back +// onto a client-supplied call item. + +use serde_json::Value; + +pub(super) fn cached_call_item(item: &Value) -> Option<(String, Value)> { + if !item + .get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + { + return None; + } + let call_id = response_item_call_id(item)?; + Some((call_id, item.clone())) +} + +pub(super) fn cached_output_item(item: &Value) -> Option { + match item.get("type").and_then(Value::as_str) { + Some("reasoning") => Some(item.clone()), + Some("message") + if item + .get("role") + .and_then(Value::as_str) + .map_or(true, |role| role == "assistant") => + { + Some(item.clone()) + } + Some(item_type) if is_call_item_type(item_type) => Some(item.clone()), + _ => None, + } +} + +pub(super) fn response_item_call_id(item: &Value) -> Option { + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(super) fn is_call_item_type(item_type: &str) -> bool { + matches!( + item_type, + "function_call" | "custom_tool_call" | "tool_search_call" + ) +} + +pub(super) fn is_call_output_item_type(item_type: &str) -> bool { + matches!( + item_type, + "function_call_output" | "custom_tool_call_output" | "tool_search_output" + ) +} + +pub(super) fn is_empty_value(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(value) => value.trim().is_empty(), + Value::Array(value) => value.is_empty(), + Value::Object(value) => value.is_empty(), + _ => false, + } +} + +pub(super) fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool { + let mut changed = false; + for key in [ + "name", + "namespace", + "arguments", + "input", + "status", + "execution", + "reasoning_content", + "reasoning", + ] { + if item.get(key).is_some_and(|value| !is_empty_value(value)) { + continue; + } + let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else { + continue; + }; + if let Some(object) = item.as_object_mut() { + object.insert(key.to_string(), value.clone()); + changed = true; + } + } + changed +} diff --git a/src-tauri/src/protocol/codex_history/inner_index.rs b/src-tauri/src/protocol/codex_history/inner_index.rs new file mode 100644 index 0000000..6ba70b7 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/inner_index.rs @@ -0,0 +1,110 @@ +// Eviction, the call-id reverse index, and the unique-call fallback lookups. + +use super::types::{CachedResponse, HistoryInner, ScopedResponseId}; +use serde_json::Value; +use std::collections::HashSet; + +impl HistoryInner { + pub(super) fn prune_to_limits(&mut self, max_responses: usize, max_bytes: usize) { + while self.response_order.len() > max_responses || self.cached_bytes > max_bytes { + let Some(response_id) = self.response_order.pop_front() else { + break; + }; + self.remove_response(&response_id); + } + } + + pub(super) fn remove_response(&mut self, response_id: &ScopedResponseId) -> bool { + self.remove_response_from_call_index(response_id); + let Some(response) = self.responses.remove(response_id) else { + return false; + }; + self.cached_bytes = self.cached_bytes.saturating_sub(response.serialized_bytes); + true + } + + pub(super) fn index_call(&mut self, scope: &str, call_id: &str, response_id: &ScopedResponseId) { + let response_ids = self + .call_index + .entry((scope.to_string(), call_id.to_string())) + .or_default(); + if !response_ids + .iter() + .any(|cached_id| cached_id == response_id) + { + response_ids.push_back(response_id.clone()); + } + } + + pub(super) fn remove_response_from_call_index(&mut self, response_id: &ScopedResponseId) { + for response_ids in self.call_index.values_mut() { + response_ids.retain(|cached_id| cached_id != response_id); + } + self.call_index + .retain(|_, response_ids| !response_ids.is_empty()); + } + + pub(super) fn unique_fallback_response( + &self, + scope: &str, + requested_call_ids: &HashSet, + previous: Option<&CachedResponse>, + ) -> CachedResponse { + // A resolved previous_response_id is authoritative. Grafting calls from another cached + // branch onto it would create history that no provider ever observed. + if previous.is_some() || requested_call_ids.is_empty() { + return CachedResponse::default(); + } + + let mut source_response_id: Option = None; + for call_id in requested_call_ids { + let Some(response_id) = self.unique_response_for_call(scope, call_id) else { + return CachedResponse::default(); + }; + if source_response_id + .as_ref() + .is_some_and(|source| source != &response_id) + { + return CachedResponse::default(); + } + source_response_id = Some(response_id); + } + + source_response_id + .and_then(|response_id| self.responses.get(&response_id).cloned()) + .unwrap_or_default() + } + + pub(super) fn unique_response_for_call(&self, scope: &str, call_id: &str) -> Option { + let response_ids = self + .call_index + .get(&(scope.to_string(), call_id.to_string()))?; + let mut found: Option<&ScopedResponseId> = None; + for response_id in response_ids { + let Some(response) = self.responses.get(response_id) else { + continue; + }; + // An owner-only native response may contain just a delta after an unavailable prefix. + // Its calls are useful to that provider via previous_response_id, but are never a safe + // source for session fallback because doing so would manufacture a truncated chain. + if !response.materializable { + continue; + } + if !response.calls_by_id.contains_key(call_id) { + continue; + } + if found.is_some() { + return None; + } + found = Some(response_id); + } + found.cloned() + } + + pub(super) fn unique_call(&self, scope: &str, call_id: &str) -> Option<&Value> { + let response_id = self.unique_response_for_call(scope, call_id)?; + self.responses + .get(&response_id) + .and_then(|response| response.calls_by_id.get(call_id)) + } +} diff --git a/src-tauri/src/protocol/codex_history/inner_insert.rs b/src-tauri/src/protocol/codex_history/inner_insert.rs new file mode 100644 index 0000000..8b862d0 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/inner_insert.rs @@ -0,0 +1,138 @@ +// Inserting a recorded response into the bounded cache, with the byte/count accounting that keeps +// a long conversation from growing the cache quadratically. + +use super::cache_items::cached_call_item; +use super::sizing::cached_response_size; +use super::types::{ + CachedResponse, HistoryInner, ResponseOrigin, MAX_CACHED_HISTORY_BYTES, MAX_CACHED_RESPONSES, +}; +use serde_json::Value; + +impl HistoryInner { + pub(super) fn insert_response( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + ) -> usize { + self.insert_response_with_metadata( + scope, + response_id, + request_input, + output, + ResponseOrigin::Local, + true, + ) + } + + pub(super) fn insert_response_with_metadata( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + origin: ResponseOrigin, + materializable: bool, + ) -> usize { + self.insert_response_with_metadata_and_limits( + scope, + response_id, + request_input, + output, + origin, + materializable, + MAX_CACHED_RESPONSES, + MAX_CACHED_HISTORY_BYTES, + ) + } + + pub(super) fn insert_response_with_limits( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + max_responses: usize, + max_bytes: usize, + ) -> usize { + self.insert_response_with_metadata_and_limits( + scope, + response_id, + request_input, + output, + ResponseOrigin::Local, + true, + max_responses, + max_bytes, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn insert_response_with_metadata_and_limits( + &mut self, + scope: &str, + response_id: &str, + request_input: Vec, + output: Vec, + origin: ResponseOrigin, + materializable: bool, + max_responses: usize, + max_bytes: usize, + ) -> usize { + let mut cached_response = CachedResponse { + request_input, + output, + origin, + materializable, + ..CachedResponse::default() + }; + for item in &cached_response.output { + if let Some((call_id, item)) = cached_call_item(item) { + if !cached_response.calls_by_id.contains_key(&call_id) { + cached_response.call_order.push(call_id.clone()); + } + cached_response.calls_by_id.insert(call_id, item); + } + } + cached_response.serialized_bytes = + cached_response_size(scope, response_id, &cached_response); + let cached_count = cached_response.output.len(); + let response_key = (scope.to_string(), response_id.to_string()); + + // Never let one pathological request flush every useful older entry before being evicted + // itself. A same-id response is authoritative, though, so an oversized replacement drops + // only its stale predecessor rather than leaving old history addressable under that id. + if cached_response.serialized_bytes > max_bytes { + if self.remove_response(&response_key) { + self.response_order + .retain(|cached_id| cached_id != &response_key); + } + return 0; + } + + let replacing = self.responses.contains_key(&response_key); + if !replacing { + self.response_order.push_back(response_key.clone()); + } + + // A completed response is authoritative. Replacing an already-seen id keeps + // retry/replay recording idempotent and prevents stale call-index entries. + self.remove_response(&response_key); + + for call_id in &cached_response.call_order { + self.index_call(scope, &call_id, &response_key); + } + self.cached_bytes = self + .cached_bytes + .checked_add(cached_response.serialized_bytes) + .unwrap_or(usize::MAX); + self.responses.insert(response_key.clone(), cached_response); + self.prune_to_limits(max_responses, max_bytes); + if self.responses.contains_key(&response_key) { + cached_count + } else { + 0 + } + } +} diff --git a/src-tauri/src/protocol/codex_history/materialize.rs b/src-tauri/src/protocol/codex_history/materialize.rs new file mode 100644 index 0000000..10dffaa --- /dev/null +++ b/src-tauri/src/protocol/codex_history/materialize.rs @@ -0,0 +1,119 @@ +// Deciding whether a request's input (or a single history item) can be materialized verbatim for +// a different provider. + +use super::cache_items::{ + is_call_item_type, is_call_output_item_type, is_empty_value, response_item_call_id, +}; +use serde_json::Value; + +pub(super) fn request_input_items(request: &Value) -> Vec { + match request.get("input") { + Some(Value::Array(items)) => items.clone(), + Some(Value::Object(object)) => vec![Value::Object(object.clone())], + Some(Value::String(value)) => vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": value, + })], + _ => Vec::new(), + } +} + +pub(super) fn request_input_is_materializable(request: &Value) -> bool { + match request.get("input") { + None | Some(Value::String(_)) => true, + Some(Value::Array(items)) => items.iter().all(history_item_is_materializable), + Some(item @ Value::Object(_)) => history_item_is_materializable(item), + _ => false, + } +} + +pub(super) fn history_item_is_materializable(item: &Value) -> bool { + let Some(object) = item.as_object() else { + return false; + }; + let item_type = object + .get("type") + .and_then(Value::as_str) + .unwrap_or_else(|| { + if object.get("role").is_some() { + "message" + } else { + "" + } + }); + match item_type { + // Responses Lite carries request-scoped tool declarations as a developer input item. + // They are ordinary JSON tool definitions and can be replayed when CC Buddy has to + // materialize a previous response across providers. + "additional_tools" => object.get("tools").is_some_and(Value::is_array), + "message" => object + .get("content") + .map_or(true, history_content_is_materializable), + "reasoning" => { + let has_opaque_reasoning = object + .get("encrypted_content") + .is_some_and(|value| !is_empty_value(value)); + !has_opaque_reasoning + && object + .get("summary") + .map_or(true, history_content_is_materializable) + && object + .get("content") + .map_or(true, history_content_is_materializable) + } + item_type if is_call_item_type(item_type) || is_call_output_item_type(item_type) => true, + _ => false, + } +} + +pub(super) fn history_content_is_materializable(content: &Value) -> bool { + match content { + Value::Null | Value::String(_) => true, + Value::Array(parts) => parts.iter().all(|part| { + let Some(part_type) = part.get("type").and_then(Value::as_str) else { + return false; + }; + match part_type { + "input_text" | "output_text" | "text" | "summary_text" => { + part.get("text").is_some_and(Value::is_string) + } + "input_image" => part + .get("image_url") + .and_then(|value| { + value + .as_str() + .or_else(|| value.get("url").and_then(Value::as_str)) + }) + .is_some(), + _ => false, + } + }), + _ => false, + } +} + +pub(super) fn cached_item_matches_input(cached: &Value, input: &Value) -> bool { + let cached_type = cached.get("type").and_then(Value::as_str); + let input_type = input.get("type").and_then(Value::as_str); + if cached_type.is_some_and(is_call_item_type) { + return input_type.is_some_and(is_call_item_type) + && response_item_call_id(cached).is_some_and(|call_id| { + response_item_call_id(input).as_deref() == Some(call_id.as_str()) + }); + } + + if let Some(id) = cached + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + return cached_type == input_type + && input + .get("id") + .and_then(Value::as_str) + .is_some_and(|input_id| input_id.trim() == id); + } + cached == input +} diff --git a/src-tauri/src/protocol/codex_history/merge.rs b/src-tauri/src/protocol/codex_history/merge.rs new file mode 100644 index 0000000..a82c217 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/merge.rs @@ -0,0 +1,116 @@ +// Merging a referenced response's model-visible context into a continuation request's input. + +use super::materialize::cached_item_matches_input; +use super::types::CachedResponse; +use serde_json::Value; +use std::collections::HashMap; + +/// Merge the directly referenced response's complete model-visible context into the new input. +/// +/// The cached request prefix is always restored before the previous response output. A client that +/// already sent the full prefix is detected by a matching request prefix plus at least one prior +/// output anchor, while a coincidentally repeated new user message is still treated as a delta. +/// Every supported previous output item is restored. Filtering unmatched calls would no longer be +/// equivalent to provider-side `previous_response_id` continuation and could silently turn an +/// invalid continuation into a different, truncated conversation. +pub(super) fn merge_previous_context( + items: Vec, + previous: Option<&CachedResponse>, +) -> (Vec, usize) { + let Some(previous) = previous else { + return (items, 0); + }; + + let eligible_output = previous.output.clone(); + let request_prefix_len = previous.request_input.len(); + let has_request_prefix = request_prefix_len <= items.len() + && previous + .request_input + .iter() + .zip(&items) + .all(|(cached, input)| cached_item_matches_input(cached, input)); + let has_output_anchor = has_request_prefix + && !previous.output.is_empty() + && previous.output.iter().any(|cached| { + items[request_prefix_len..] + .iter() + .any(|input| cached_item_matches_input(cached, input)) + }); + + let (prefix, tail, restored_input) = if has_request_prefix && has_output_anchor { + ( + items[..request_prefix_len].to_vec(), + items[request_prefix_len..].to_vec(), + 0, + ) + } else { + ( + previous.request_input.clone(), + items, + previous.request_input.len(), + ) + }; + let (tail, restored_output) = merge_cached_output(tail, &eligible_output); + let mut merged = Vec::with_capacity(prefix.len() + tail.len()); + merged.extend(prefix); + merged.extend(tail); + (merged, restored_input + restored_output) +} + +pub(super) fn merge_cached_output(items: Vec, eligible: &[Value]) -> (Vec, usize) { + if eligible.is_empty() { + return (items, 0); + } + + // Match explicit prior-output items monotonically. Legal explicit history keeps + // response order, and monotonic matching avoids treating a coincidentally reused + // text value later in the request as the prior item. + let mut matches = HashMap::::new(); + let mut next_input = 0usize; + for (cached_index, cached) in eligible.iter().enumerate() { + let Some(relative_index) = items[next_input..] + .iter() + .position(|item| cached_item_matches_input(cached, item)) + else { + continue; + }; + let input_index = next_input + relative_index; + matches.insert(input_index, cached_index); + next_input = input_index + 1; + } + + if matches.is_empty() { + let restored = eligible.len(); + let mut merged = Vec::with_capacity(restored + items.len()); + merged.extend(eligible.iter().cloned()); + merged.extend(items); + return (merged, restored); + } + + let last_match = matches.keys().copied().max().unwrap_or(0); + let mut merged = Vec::with_capacity(eligible.len() + items.len()); + let mut cached_cursor = 0usize; + let mut restored = 0usize; + for (input_index, item) in items.into_iter().enumerate() { + if let Some(&cached_index) = matches.get(&input_index) { + while cached_cursor < cached_index { + merged.push(eligible[cached_cursor].clone()); + cached_cursor += 1; + restored += 1; + } + // The explicit item wins (and may intentionally contain richer content). + merged.push(item); + cached_cursor = cached_index + 1; + if input_index == last_match { + while cached_cursor < eligible.len() { + merged.push(eligible[cached_cursor].clone()); + cached_cursor += 1; + restored += 1; + } + } + } else { + merged.push(item); + } + } + (merged, restored) +} diff --git a/src-tauri/src/protocol/codex_history/mod.rs b/src-tauri/src/protocol/codex_history/mod.rs new file mode 100644 index 0000000..1521ead --- /dev/null +++ b/src-tauri/src/protocol/codex_history/mod.rs @@ -0,0 +1,38 @@ +//! Cross-request history for bridging Codex Responses requests to chat-style upstreams. +//! +//! Responses clients may continue a tool turn with only +//! `previous_response_id + new input`. Chat-style protocols do not implement that +//! server-side continuation, so they need the previous request input and assistant +//! output restored recursively into the next request. Tool outputs additionally need +//! the original assistant call, including its name, arguments, and reasoning metadata. +//! This store records that model-visible context and restores it before conversion. + +mod cache_items; +mod inner_index; +mod inner_insert; +mod materialize; +mod merge; +mod resolve; +mod sizing; +mod store; +mod types; +#[cfg(test)] +mod tests_call_fallback; +#[cfg(test)] +mod tests_continuation; +#[cfg(test)] +mod tests_eviction; +#[cfg(test)] +mod tests_hops; +#[cfg(test)] +mod tests_limits; +#[cfg(test)] +mod tests_materialize; +#[cfg(test)] +mod tests_native; + +pub use types::{CodexHistoryStore, HistoryResolution, ResponseOrigin}; +// ResponseMetadata is this module's public return type for `response_metadata`; keep it resolving +// at crate::protocol::codex_history::ResponseMetadata even though callers destructure it today. +#[allow(unused_imports)] +pub use types::ResponseMetadata; diff --git a/src-tauri/src/protocol/codex_history/resolve.rs b/src-tauri/src/protocol/codex_history/resolve.rs new file mode 100644 index 0000000..b28b32c --- /dev/null +++ b/src-tauri/src/protocol/codex_history/resolve.rs @@ -0,0 +1,203 @@ +// Resolving a Responses request against the cache: restoring the previous turn's context, then +// enriching or materializing the request input. + +use super::cache_items::{ + enrich_call_item_from_cache, is_call_item_type, is_call_output_item_type, + response_item_call_id, +}; +use super::merge::merge_previous_context; +use super::types::{CachedLookup, CachedResponse, CodexHistoryStore, HistoryResolution}; +use serde_json::Value; +use std::collections::HashSet; + +impl CodexHistoryStore { + pub(super) async fn resolve_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + strip_materialized_previous_id: bool, + body: &mut Value, + ) -> HistoryResolution { + let previous_response_id = body + .get("previous_response_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let had_previous_response_id = previous_response_id.is_some(); + + let input_was_missing = body.get("input").is_none(); + let original_input = body.get_mut("input").map(std::mem::take); + let original_was_object = original_input.as_ref().is_some_and(Value::is_object); + let mut original_string = None; + let mut unsupported_input = None; + let items = match original_input { + Some(Value::Array(items)) => items, + Some(Value::Object(object)) => vec![Value::Object(object)], + Some(Value::String(value)) => { + original_string = Some(value.clone()); + vec![serde_json::json!({ + "type": "message", + "role": "user", + "content": value, + })] + } + Some(other) => { + unsupported_input = Some(other); + Vec::new() + } + None => Vec::new(), + }; + + let output_call_ids = items + .iter() + .filter(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(is_call_output_item_type) + }) + .filter_map(response_item_call_id) + .collect::>(); + let existing_call_ids = items + .iter() + .filter(|item| { + item.get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + }) + .filter_map(response_item_call_id) + .collect::>(); + let requested_call_ids = output_call_ids + .union(&existing_call_ids) + .cloned() + .collect::>(); + + let lookup = self + .lookup( + scope, + previous_response_id.as_deref(), + &requested_call_ids, + allow_call_id_fallback, + ) + .await; + let previous_found = lookup.previous.is_some(); + let previous_origin = lookup + .previous + .as_ref() + .map(|response| response.origin.clone()); + let previous_materialized = lookup + .previous + .as_ref() + .is_some_and(|response| response.materializable); + + if let Some(original_input) = unsupported_input { + if let Some(object) = body.as_object_mut() { + object.insert("input".to_string(), original_input); + } + return HistoryResolution { + changed: 0, + had_previous_response_id, + previous_found, + previous_materialized: false, + previous_origin, + }; + } + + // A native provider may still own a continuation that the gateway observed only after a + // restart. Keep that request byte-for-byte intact for same-provider passthrough; callers + // must reject it before cross-wire/provider-switch forwarding because its prefix is absent. + if previous_found && !previous_materialized { + if !input_was_missing { + let restored_input = if original_string.is_some() && items.len() == 1 { + Value::String(original_string.unwrap_or_default()) + } else if original_was_object && items.len() == 1 { + items.into_iter().next().unwrap_or(Value::Null) + } else { + Value::Array(items) + }; + if let Some(object) = body.as_object_mut() { + object.insert("input".to_string(), restored_input); + } + } + return HistoryResolution { + changed: 0, + had_previous_response_id, + previous_found, + previous_materialized, + previous_origin, + }; + } + let replay_context = lookup + .previous + .as_ref() + .or_else(|| lookup.fallback.materializable.then_some(&lookup.fallback)); + let (items, restored) = merge_previous_context(items, replay_context); + let mut enriched = 0usize; + let mut new_items = Vec::with_capacity(items.len()); + + for mut item in items { + if item + .get("type") + .and_then(Value::as_str) + .is_some_and(is_call_item_type) + { + if let Some(call_id) = response_item_call_id(&item) { + if let Some(cached) = lookup.call(&call_id) { + if enrich_call_item_from_cache(&mut item, cached) { + enriched += 1; + } + } + } + } + new_items.push(item); + } + + let changed = restored + enriched; + let resolved_input = if changed == 0 && original_string.is_some() && new_items.len() == 1 { + Some(Value::String(original_string.unwrap_or_default())) + } else if changed == 0 && original_was_object && new_items.len() == 1 { + Some(new_items.into_iter().next().unwrap_or(Value::Null)) + } else if input_was_missing && changed == 0 { + None + } else { + Some(Value::Array(new_items)) + }; + if let (Some(object), Some(resolved_input)) = (body.as_object_mut(), resolved_input) { + object.insert("input".to_string(), resolved_input); + } + if strip_materialized_previous_id && previous_materialized { + if let Some(object) = body.as_object_mut() { + object.remove("previous_response_id"); + } + } + HistoryResolution { + changed, + had_previous_response_id, + previous_found, + previous_materialized, + previous_origin, + } + } + + pub(super) async fn lookup( + &self, + scope: &str, + previous_response_id: Option<&str>, + requested_call_ids: &HashSet, + allow_call_id_fallback: bool, + ) -> CachedLookup { + let inner = self.inner.read().await; + let previous = previous_response_id.and_then(|id| { + inner + .responses + .get(&(scope.to_string(), id.to_string())) + .cloned() + }); + let fallback = if allow_call_id_fallback { + inner.unique_fallback_response(scope, requested_call_ids, previous.as_ref()) + } else { + CachedResponse::default() + }; + CachedLookup { previous, fallback } + } +} diff --git a/src-tauri/src/protocol/codex_history/sizing.rs b/src-tauri/src/protocol/codex_history/sizing.rs new file mode 100644 index 0000000..2375224 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/sizing.rs @@ -0,0 +1,44 @@ +// Serialized-size accounting for the cache's byte budget. + +use super::types::{CachedResponse, ResponseOrigin}; +use std::io::{self, Write}; + +#[derive(Default)] +struct ByteCounter { + bytes: usize, +} + +impl Write for ByteCounter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes = self.bytes.saturating_add(buf.len()); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(super) fn serialized_size(value: &T) -> usize { + let mut counter = ByteCounter::default(); + if serde_json::to_writer(&mut counter, value).is_err() { + usize::MAX + } else { + counter.bytes + } +} + +pub(super) fn cached_response_size(scope: &str, response_id: &str, response: &CachedResponse) -> usize { + let origin_bytes = match &response.origin { + ResponseOrigin::Local => 1, + ResponseOrigin::Native(provider_id) => 1usize.saturating_add(serialized_size(provider_id)), + }; + serialized_size(scope) + .saturating_add(serialized_size(response_id)) + .saturating_add(serialized_size(&response.request_input)) + .saturating_add(serialized_size(&response.output)) + .saturating_add(serialized_size(&response.calls_by_id)) + .saturating_add(serialized_size(&response.call_order)) + .saturating_add(origin_bytes) + .saturating_add(1) +} diff --git a/src-tauri/src/protocol/codex_history/store.rs b/src-tauri/src/protocol/codex_history/store.rs new file mode 100644 index 0000000..86ca2f9 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/store.rs @@ -0,0 +1,164 @@ +// Recording a translated Responses turn and the public request-enrichment entry points. + +use super::cache_items::cached_output_item; +use super::materialize::{ + history_item_is_materializable, request_input_is_materializable, request_input_items, +}; +use super::types::{CodexHistoryStore, HistoryResolution, ResponseMetadata, ResponseOrigin}; +use serde_json::Value; + +impl CodexHistoryStore { + /// Record the full translated request input plus supported assistant-output items from a + /// resumable terminal Responses response (`completed` or `incomplete`). + /// + /// Returns the number of cached output items. Responses without an id are ignored; an otherwise + /// empty response is still retained so provider ownership remains known. + pub async fn record_response(&self, request: &Value, response: &Value) -> usize { + self.record_response_scoped("", request, response).await + } + + /// Scoped variant used by the gateway so response/call ids from different client sessions can + /// never satisfy one another while the same conversation can survive a provider switch. + pub async fn record_response_scoped( + &self, + scope: &str, + request: &Value, + response: &Value, + ) -> usize { + // Preserve the original store API for internal callers/tests that predate Responses + // terminal statuses. Gateway-owned/native recording uses the metadata variant below, + // which requires an explicit resumable terminal status. + let mut legacy_terminal; + let response = if response.get("status").is_none() { + legacy_terminal = response.clone(); + legacy_terminal["status"] = Value::String("completed".to_string()); + &legacy_terminal + } else { + response + }; + self.record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + request, + response, + ) + .await + } + + pub async fn record_response_scoped_with_metadata( + &self, + scope: &str, + origin: ResponseOrigin, + materializable: bool, + request: &Value, + response: &Value, + ) -> usize { + if response + .get("object") + .and_then(Value::as_str) + .is_some_and(|object| object != "response") + { + return 0; + } + if !matches!( + response.get("status").and_then(Value::as_str), + Some("completed" | "incomplete") + ) { + return 0; + } + + let Some(response_id) = response + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return 0; + }; + + let request_input = request_input_items(request); + let request_input_is_complete = request_input_is_materializable(request); + let (output, output_is_complete) = match response.get("output").and_then(Value::as_array) { + Some(items) => { + let output = items + .iter() + .filter_map(cached_output_item) + .collect::>(); + let output_is_complete = + output.len() == items.len() && items.iter().all(history_item_is_materializable); + (output, output_is_complete) + } + None => (Vec::new(), false), + }; + // Preserve ownership for a response containing an item this bridge cannot replay, but + // never advertise that partial transcript as safe to move to another provider. + let materializable = materializable && request_input_is_complete && output_is_complete; + + self.inner.write().await.insert_response_with_metadata( + scope, + response_id, + request_input, + output, + origin, + materializable, + ) + } + + pub async fn response_metadata( + &self, + scope: &str, + response_id: &str, + ) -> Option { + let response_id = response_id.trim(); + if response_id.is_empty() { + return None; + } + self.inner + .read() + .await + .responses + .get(&(scope.to_string(), response_id.to_string())) + .map(|response| ResponseMetadata { + origin: response.origin.clone(), + materializable: response.materializable, + }) + } + + /// Restore or enrich call items required by a follow-up Responses request. + /// + /// Missing calls are inserted immediately before the first matching output. + /// Parallel calls from the same response are restored as one ordered group. + /// Existing call items are enriched when fields such as `name`, `arguments`, + /// or `reasoning_content` are missing. + /// + /// The primary lookup uses `previous_response_id`. If that id is absent or + /// stale, a call-id fallback is used only when the caller supplied a safe scope and the call id + /// is unique inside that client session. Returns the number of restored or enriched items. + pub async fn enrich_request(&self, body: &mut Value) -> usize { + self.enrich_request_scoped("", false, body).await + } + + /// Scoped variant. Missing/stale-`previous_response_id` call-id recovery is allowed only when + /// the caller can provide a client-session scope; otherwise orphan validation must fail. + pub async fn enrich_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + body: &mut Value, + ) -> usize { + self.resolve_request_scoped(scope, allow_call_id_fallback, false, body) + .await + .changed + } + + pub async fn materialize_request_scoped( + &self, + scope: &str, + allow_call_id_fallback: bool, + body: &mut Value, + ) -> HistoryResolution { + self.resolve_request_scoped(scope, allow_call_id_fallback, true, body) + .await + } +} diff --git a/src-tauri/src/protocol/codex_history/tests_call_fallback.rs b/src-tauri/src/protocol/codex_history/tests_call_fallback.rs new file mode 100644 index 0000000..ea441d0 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_call_fallback.rs @@ -0,0 +1,175 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn call_id_fallback_never_crosses_client_session_scope() { + let history = CodexHistoryStore::default(); + history + .record_response_scoped( + "session-a", + &json!({"input":[]}), + &json!({ + "id":"resp_a", + "output":[{ + "type":"function_call","call_id":"call_a", + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + let mut request = json!({ + "previous_response_id":"stale", + "input":[{ + "type":"function_call_output","call_id":"call_a","output":"ok" + }] + }); + + assert_eq!( + history + .enrich_request_scoped("session-b", true, &mut request) + .await, + 0 + ); + assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); +} + +#[tokio::test] +async fn ambiguous_call_id_does_not_use_fallback() { + let history = CodexHistoryStore::default(); + let scope = "session-1"; + for response_id in ["resp_1", "resp_2"] { + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": response_id, + "output": [{ + "type":"function_call", + "call_id":"shared_call", + "name":"lookup", + "arguments":"{}" + }] + }), + ) + .await; + } + + let mut request = json!({ + "input": [{ + "type":"function_call_output", + "call_id":"shared_call", + "output":"ok" + }] + }); + + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 0 + ); + assert_eq!(request["input"].as_array().unwrap().len(), 1); + assert_eq!(request["input"][0]["type"], "function_call_output"); + let error = crate::protocol::openai_responses::decode_request(&request).unwrap_err(); + assert!(error.contains("shared_call")); +} + +#[tokio::test] +async fn enriches_existing_call_without_duplicating_it() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type":"function_call", + "call_id":"call_1", + "name":"read_file", + "arguments":"{\"path\":\"README.md\"}", + "reasoning_content":"Need the file." + }] + }), + ) + .await; + + let mut request = json!({ + "previous_response_id":"resp_1", + "input":[ + {"type":"function_call","call_id":"call_1"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + let input = request["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["name"], "read_file"); + assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}"); + assert_eq!(input[0]["reasoning_content"], "Need the file."); +} + +#[tokio::test] +async fn restores_custom_and_tool_search_calls() { + let history = CodexHistoryStore::default(); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_tools", + "output":[ + { + "type":"custom_tool_call", + "call_id":"call_patch", + "name":"apply_patch", + "input":"*** Begin Patch\n*** End Patch" + }, + { + "type":"tool_search_call", + "call_id":"call_search", + "status":"completed", + "execution":"client", + "arguments":{"query":"mail tools"} + } + ] + }) + ) + .await, + 2 + ); + + let mut request = json!({ + "previous_response_id":"resp_tools", + "input":[ + {"type":"custom_tool_call_output","call_id":"call_patch","output":"patched"}, + {"type":"tool_search_output","call_id":"call_search","tools":[]} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 2); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["type"], "custom_tool_call"); + assert_eq!(input[0]["input"], "*** Begin Patch\n*** End Patch"); + assert_eq!(input[1]["type"], "tool_search_call"); + assert_eq!(input[2]["type"], "custom_tool_call_output"); + assert_eq!(input[3]["type"], "tool_search_output"); +} + +#[tokio::test] +async fn preserves_scalar_and_single_object_input_when_no_change_is_needed() { + let history = CodexHistoryStore::default(); + let mut scalar_request = json!({"input":"hello"}); + assert_eq!(history.enrich_request(&mut scalar_request).await, 0); + assert_eq!(scalar_request["input"], "hello"); + + let mut request = json!({ + "input": {"type":"message","role":"user","content":"hello"} + }); + + assert_eq!(history.enrich_request(&mut request).await, 0); + assert!(request["input"].is_object()); + assert_eq!(request["input"]["content"], "hello"); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_continuation.rs b/src-tauri/src/protocol/codex_history/tests_continuation.rs new file mode 100644 index 0000000..be6d733 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_continuation.rs @@ -0,0 +1,135 @@ +use super::*; +use super::materialize::request_input_is_materializable; +use serde_json::json; + +#[test] +fn responses_lite_additional_tools_are_materializable() { + let request = json!({ + "input": [{ + "type": "additional_tools", + "role": "developer", + "tools": [ + { "type": "custom", "name": "exec" }, + { + "type": "namespace", + "name": "collaboration", + "tools": [{ "type": "function", "name": "spawn_agent" }] + } + ] + }] + }); + assert!(request_input_is_materializable(&request)); +} + +#[tokio::test] +async fn restores_call_before_output_from_previous_response() { + let history = CodexHistoryStore::default(); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type": "function_call", + "call_id": "call_1", + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}", + "reasoning_content": "Need to inspect the file." + }] + }) + ) + .await, + 1 + ); + + let mut request = json!({ + "previous_response_id": "resp_1", + "input": [{ + "type": "function_call_output", + "call_id": "call_1", + "output": "ok" + }] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["name"], "read_file"); + assert_eq!(input[0]["reasoning_content"], "Need to inspect the file."); + assert_eq!(input[1]["type"], "function_call_output"); + + // The restored item-level reasoning must survive the JSON → chat IR half, + // not merely remain present in the enriched request body. + let decoded = crate::protocol::openai_responses::decode_request(&request).unwrap(); + assert_eq!( + decoded.messages[0].reasoning_content.as_deref(), + Some("Need to inspect the file.") + ); + assert_eq!( + decoded.messages[0].tool_calls.as_ref().unwrap()[0].id, + "call_1" + ); +} + +#[tokio::test] +async fn restores_text_continuation_and_deduplicates_explicit_prior_output() { + let history = CodexHistoryStore::default(); + let reasoning = json!({ + "type":"reasoning", + "id":"rs_text", + "summary":[{"type":"summary_text","text":"continue the thought"}] + }); + let assistant = json!({ + "type":"message", + "id":"msg_text", + "role":"assistant", + "content":[{"type":"output_text","text":"First answer."}] + }); + assert_eq!( + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_text", + "output":[reasoning.clone(), assistant.clone()] + }) + ) + .await, + 2 + ); + + let mut continuation = json!({ + "previous_response_id":"resp_text", + "input":"Continue." + }); + assert_eq!(history.enrich_request(&mut continuation).await, 2); + let input = continuation["input"].as_array().unwrap(); + assert_eq!(input.len(), 3); + assert_eq!(input[0]["id"], "rs_text"); + assert_eq!(input[1]["id"], "msg_text"); + assert_eq!(input[2]["role"], "user"); + + let decoded = crate::protocol::openai_responses::decode_request(&continuation).unwrap(); + assert_eq!(decoded.messages.len(), 2); + assert_eq!(decoded.messages[0].content_as_text(), "First answer."); + assert_eq!( + decoded.messages[0].reasoning_content.as_deref(), + Some("continue the thought") + ); + assert_eq!(decoded.messages[1].content_as_text(), "Continue."); + + // A client may send explicit history even while retaining previous_response_id. + // Stable item ids anchor that history, so the cache must not duplicate it. + let mut explicit = json!({ + "previous_response_id":"resp_text", + "input":[ + reasoning, + assistant, + {"type":"message","role":"user","content":"Continue."} + ] + }); + assert_eq!(history.enrich_request(&mut explicit).await, 0); + assert_eq!(explicit["input"].as_array().unwrap().len(), 3); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_eviction.rs b/src-tauri/src/protocol/codex_history/tests_eviction.rs new file mode 100644 index 0000000..a284371 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_eviction.rs @@ -0,0 +1,163 @@ +use super::*; +use super::types::{HistoryInner, MAX_CACHED_RESPONSES}; +use serde_json::json; + +#[test] +fn oversized_insert_preserves_unrelated_entries_and_drops_stale_replacement() { + let keep_request = vec![json!({ + "type":"message","role":"user","content":"keep" + })]; + let keep_output = vec![json!({ + "type":"function_call","call_id":"call_keep", + "name":"keep_tool","arguments":"{}" + })]; + let oversized_request = vec![json!({ + "type":"message","role":"user","content":"x".repeat(2048) + })]; + let oversized_output = vec![json!({ + "type":"function_call","call_id":"call_huge", + "name":"huge_tool","arguments":"y".repeat(2048) + })]; + + let scope = "session-1"; + let keep_key = (scope.to_string(), "resp_keep".to_string()); + let mut probe = HistoryInner::default(); + probe.insert_response( + scope, + "resp_keep", + keep_request.clone(), + keep_output.clone(), + ); + let budget = probe.responses[&keep_key].serialized_bytes; + + let mut inner = HistoryInner::default(); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_keep", + keep_request, + keep_output, + MAX_CACHED_RESPONSES, + budget, + ), + 1 + ); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_huge", + oversized_request.clone(), + oversized_output.clone(), + MAX_CACHED_RESPONSES, + budget, + ), + 0 + ); + assert!(inner.responses.contains_key(&keep_key)); + assert_eq!(inner.cached_bytes, budget); + assert!(inner.unique_call(scope, "call_huge").is_none()); + + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_keep", + oversized_request, + oversized_output, + MAX_CACHED_RESPONSES, + budget, + ), + 0 + ); + assert!(inner.responses.is_empty()); + assert!(inner.response_order.is_empty()); + assert!(inner.call_index.is_empty()); + assert_eq!(inner.cached_bytes, 0); +} + +#[tokio::test] +async fn native_history_materializes_across_provider_boundaries_and_strips_previous_id() { + let history = CodexHistoryStore::default(); + let scope = "session-native"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"message","role":"user","content":"first"}] + }), + &json!({ + "id":"resp_native_a","status":"completed", + "output":[{ + "type":"message","id":"msg_native_a","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_native_a", + "input":[{"type":"message","role":"user","content":"second"}] + }); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_found); + assert!(resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert!(next.get("previous_response_id").is_none()); + let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); + assert_eq!( + decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(), + vec!["first", "answer", "second"] + ); +} + +#[tokio::test] +async fn owner_only_native_history_is_reported_but_never_materialized() { + let history = CodexHistoryStore::default(); + let scope = "session-owner-only"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + false, + &json!({ + "previous_response_id":"unknown-before-restart", + "input":[{"type":"message","role":"user","content":"delta"}] + }), + &json!({ + "id":"resp_owner_only","status":"completed", + "output":[{ + "type":"message","id":"msg_owner_only","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_owner_only", + "input":[{"type":"message","role":"user","content":"next"}] + }); + let before = next.clone(); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_found); + assert!(!resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert_eq!(next, before); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_hops.rs b/src-tauri/src/protocol/codex_history/tests_hops.rs new file mode 100644 index 0000000..ceed8ad --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_hops.rs @@ -0,0 +1,174 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn restores_request_and_response_context_across_multiple_hops() { + let history = CodexHistoryStore::default(); + let first_request = json!({ + "input":[{"type":"message","role":"user","content":"First question."}] + }); + let first_response = json!({ + "id":"resp_first", + "output":[{ + "type":"message","id":"msg_first","role":"assistant", + "content":[{"type":"output_text","text":"First answer."}] + }] + }); + assert_eq!( + history + .record_response(&first_request, &first_response) + .await, + 1 + ); + + let mut second_request = json!({ + "previous_response_id":"resp_first", + "input":[{"type":"message","role":"user","content":"Second question."}] + }); + assert_eq!(history.enrich_request(&mut second_request).await, 2); + let second_response = json!({ + "id":"resp_second", + "output":[{ + "type":"message","id":"msg_second","role":"assistant", + "content":[{"type":"output_text","text":"Second answer."}] + }] + }); + assert_eq!( + history + .record_response(&second_request, &second_response) + .await, + 1 + ); + + let mut third_request = json!({ + "previous_response_id":"resp_second", + "input":[{"type":"message","role":"user","content":"Third question."}] + }); + assert_eq!(history.enrich_request(&mut third_request).await, 4); + let decoded = crate::protocol::openai_responses::decode_request(&third_request).unwrap(); + let transcript = decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(); + assert_eq!( + transcript, + vec![ + "First question.", + "First answer.", + "Second question.", + "Second answer.", + "Third question.", + ] + ); + + // Full explicit history plus previous_response_id must remain idempotent. + let before = third_request.clone(); + assert_eq!(history.enrich_request(&mut third_request).await, 0); + assert_eq!(third_request, before); +} + +#[tokio::test] +async fn restores_parallel_calls_as_one_ordered_group() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id": "resp_parallel", + "output": [ + {"type":"function_call","call_id":"call_a","name":"first","arguments":"{}"}, + {"type":"function_call","call_id":"call_b","name":"second","arguments":"{}"} + ] + }), + ) + .await; + + // Outputs may arrive in a different order. The assistant call group must + // retain the order in which the response originally emitted the calls. + let mut request = json!({ + "previous_response_id": "resp_parallel", + "input": [ + {"type":"function_call_output","call_id":"call_b","output":"two"}, + {"type":"function_call_output","call_id":"call_a","output":"one"} + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 2); + let input = request["input"].as_array().unwrap(); + assert_eq!(input[0]["call_id"], "call_a"); + assert_eq!(input[1]["call_id"], "call_b"); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[3]["type"], "function_call_output"); +} + +#[tokio::test] +async fn same_client_session_recovers_across_provider_switches() { + let history = CodexHistoryStore::default(); + // The scope deliberately contains no provider identity: switching the active provider + // must not sever the client's previous_response_id chain. + let scope = "session-1"; + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": "resp_1", + "output": [{ + "type":"function_call", + "call_id":"unique_call", + "name":"lookup", + "arguments":"{}" + }] + }), + ) + .await; + + for previous in [None, Some("stale_response"), Some("resp_1")] { + let mut request = json!({ + "input": [{ + "type":"function_call_output", + "call_id":"unique_call", + "output":"ok" + }] + }); + if let Some(previous) = previous { + request["previous_response_id"] = json!(previous); + } + + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 1 + ); + assert_eq!(request["input"][0]["type"], "function_call"); + assert_eq!(request["input"][0]["name"], "lookup"); + } +} + +#[tokio::test] +async fn missing_previous_response_fallback_requires_a_safe_scope() { + let history = CodexHistoryStore::default(); + history + .record_response( + &json!({ "input": [] }), + &json!({ + "id":"resp_1", + "output":[{ + "type":"function_call","call_id":"call_1", + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + let mut request = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_1","output":"ok" + }] + }); + + assert_eq!(history.enrich_request(&mut request).await, 0); + assert!(crate::protocol::openai_responses::decode_request(&request).is_err()); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_limits.rs b/src-tauri/src/protocol/codex_history/tests_limits.rs new file mode 100644 index 0000000..8244f8c --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_limits.rs @@ -0,0 +1,172 @@ +use super::*; +use super::types::{HistoryInner, MAX_CACHED_RESPONSES}; +use std::sync::Arc; +use serde_json::{json, Value}; + +#[tokio::test] +async fn concurrent_recording_is_safe_and_searchable() { + let history = Arc::new(CodexHistoryStore::default()); + let scope = "session-1"; + let mut tasks = Vec::new(); + for index in 0..16 { + let history = history.clone(); + tasks.push(tokio::spawn(async move { + history + .record_response_scoped( + scope, + &json!({ "input": [] }), + &json!({ + "id": format!("resp_{index}"), + "output": [{ + "type":"function_call", + "call_id":format!("call_{index}"), + "name":"work", + "arguments":"{}" + }] + }), + ) + .await + })); + } + for task in tasks { + assert_eq!(task.await.unwrap(), 1); + } + + let mut request = json!({ + "input":[{ + "type":"function_call_output", + "call_id":"call_9", + "output":"done" + }] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut request) + .await, + 1 + ); + assert_eq!(request["input"][0]["call_id"], "call_9"); + assert_eq!(request["input"][0]["name"], "work"); +} + +#[test] +fn byte_budget_evicts_oldest_responses_and_cleans_call_index() { + let old_request = vec![json!({ + "type":"message","role":"user","content":"old request" + })]; + let old_output = vec![json!({ + "type":"function_call", + "call_id":"shared_call", + "name":"old_tool", + "arguments":"{\"value\":\"old\"}" + })]; + let new_request = vec![json!({ + "type":"message","role":"user","content":"new request" + })]; + let new_output = vec![json!({ + "type":"function_call", + "call_id":"shared_call", + "name":"new_tool", + "arguments":"{\"value\":\"new\"}" + })]; + let mut probe = HistoryInner::default(); + let scope = "session-1"; + let new_key = (scope.to_string(), "resp_new".to_string()); + let old_key = (scope.to_string(), "resp_old".to_string()); + probe.insert_response(scope, "resp_new", new_request.clone(), new_output.clone()); + let newest_size = probe.responses[&new_key].serialized_bytes; + + let mut inner = HistoryInner::default(); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_old", + old_request, + old_output, + MAX_CACHED_RESPONSES, + usize::MAX, + ), + 1 + ); + assert_eq!( + inner.insert_response_with_limits( + scope, + "resp_new", + new_request, + new_output, + MAX_CACHED_RESPONSES, + newest_size, + ), + 1 + ); + + assert_eq!(inner.cached_bytes, newest_size); + assert!(!inner.responses.contains_key(&old_key)); + assert!(inner.responses.contains_key(&new_key)); + assert_eq!( + inner + .unique_call(scope, "shared_call") + .and_then(|item| item.get("name")) + .and_then(Value::as_str), + Some("new_tool") + ); +} + +#[test] +fn same_id_replacement_keeps_exact_accounting_and_no_stale_call_index() { + let mut inner = HistoryInner::default(); + let scope = "session-1"; + let response_key = (scope.to_string(), "resp_same".to_string()); + let call_key = (scope.to_string(), "call_new".to_string()); + assert_eq!( + inner.insert_response( + scope, + "resp_same", + vec![json!({"type":"message","role":"user","content":"short"})], + vec![json!({ + "type":"function_call","call_id":"call_old", + "name":"old_tool","arguments":"{}" + })], + ), + 1 + ); + assert_eq!( + inner.insert_response( + scope, + "resp_same", + vec![json!({ + "type":"message","role":"user", + "content":"a longer authoritative replacement" + })], + vec![json!({ + "type":"function_call","call_id":"call_new", + "name":"new_tool","arguments":"{\"ok\":true}" + })], + ), + 1 + ); + + let replacement_bytes = inner.responses[&response_key].serialized_bytes; + assert_eq!(inner.response_order.len(), 1); + assert_eq!(inner.cached_bytes, replacement_bytes); + assert!(inner.unique_call(scope, "call_old").is_none()); + assert_eq!( + inner + .unique_call(scope, "call_new") + .and_then(|item| item.get("name")) + .and_then(Value::as_str), + Some("new_tool") + ); + + // Replaying the same completed response must not duplicate order/index entries or bytes. + let request = inner.responses[&response_key].request_input.clone(); + let output = inner.responses[&response_key].output.clone(); + assert_eq!( + inner.insert_response(scope, "resp_same", request, output), + 1 + ); + assert_eq!(inner.response_order.len(), 1); + assert_eq!(inner.cached_bytes, replacement_bytes); + assert_eq!(inner.call_index[&call_key].len(), 1); +} + diff --git a/src-tauri/src/protocol/codex_history/tests_materialize.rs b/src-tauri/src/protocol/codex_history/tests_materialize.rs new file mode 100644 index 0000000..0b87ba5 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_materialize.rs @@ -0,0 +1,167 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn empty_output_does_not_collapse_an_identical_follow_up_input() { + let history = CodexHistoryStore::default(); + history + .record_response_scoped_with_metadata( + "session-empty-output", + ResponseOrigin::Local, + true, + &json!({"input":"ping"}), + &json!({"id":"resp_empty","status":"completed","output":[]}), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_empty", + "input":"ping" + }); + + let resolution = history + .materialize_request_scoped("session-empty-output", true, &mut next) + .await; + assert_eq!(resolution.changed, 1); + assert!(next.get("previous_response_id").is_none()); + let input = next["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["content"], "ping"); + assert_eq!(input[1]["content"], "ping"); +} + +#[tokio::test] +async fn call_fallback_uses_one_complete_branch_and_never_grafts_onto_previous() { + let history = CodexHistoryStore::default(); + let scope = "session-fallback-branch"; + for (response_id, call_id, prompt) in [ + ("resp_a", "call_a", "branch a"), + ("resp_b", "call_b", "branch b"), + ] { + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &json!({ + "input":[{"type":"message","role":"user","content":prompt}] + }), + &json!({ + "id":response_id,"status":"completed", + "output":[{ + "type":"function_call","call_id":call_id, + "name":"lookup","arguments":"{}" + }] + }), + ) + .await; + } + + let mut one_branch = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_a","output":"a" + }] + }); + let resolution = history + .materialize_request_scoped(scope, true, &mut one_branch) + .await; + assert!(!resolution.had_previous_response_id); + assert_eq!(resolution.changed, 2); + assert_eq!(one_branch["input"][0]["content"], "branch a"); + assert_eq!(one_branch["input"][1]["call_id"], "call_a"); + assert_eq!(one_branch["input"][2]["call_id"], "call_a"); + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &one_branch, + &json!({ + "id":"resp_after_fallback","status":"completed", + "output":[{ + "type":"message","role":"assistant", + "content":[{"type":"output_text","text":"done"}] + }] + }), + ) + .await; + let mut switched_provider = json!({ + "previous_response_id":"resp_after_fallback", + "input":"next" + }); + let switched = history + .materialize_request_scoped(scope, true, &mut switched_provider) + .await; + assert!(switched.previous_materialized); + assert!(switched_provider.get("previous_response_id").is_none()); + assert_eq!(switched_provider["input"][0]["content"], "branch a"); + + let mut mixed_branches = json!({ + "input":[ + {"type":"function_call_output","call_id":"call_a","output":"a"}, + {"type":"function_call_output","call_id":"call_b","output":"b"} + ] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut mixed_branches) + .await, + 0 + ); + assert_eq!(mixed_branches["input"].as_array().unwrap().len(), 2); + + let mut unrelated_to_previous = json!({ + "previous_response_id":"resp_a", + "input":[{ + "type":"function_call_output","call_id":"call_b","output":"b" + }] + }); + history + .materialize_request_scoped(scope, true, &mut unrelated_to_previous) + .await; + let input = unrelated_to_previous["input"].as_array().unwrap(); + assert!(input.iter().any(|item| item["call_id"] == "call_a")); + assert!(!input + .iter() + .any(|item| { item["type"] == "function_call" && item["call_id"] == "call_b" })); + assert!(crate::protocol::openai_responses::decode_request(&unrelated_to_previous).is_err()); +} + +#[tokio::test] +async fn previous_response_is_resolved_and_materialized_without_new_input() { + let history = CodexHistoryStore::default(); + let scope = "session-no-input"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"message","role":"user","content":"first"}] + }), + &json!({ + "id":"resp_no_input","status":"completed", + "output":[{ + "type":"message","id":"msg_no_input","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + let mut next = json!({"previous_response_id":"resp_no_input"}); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.had_previous_response_id); + assert!(resolution.previous_found); + assert!(resolution.previous_materialized); + assert_eq!( + resolution.previous_origin, + Some(ResponseOrigin::Native("provider-a".to_string())) + ); + assert!(next.get("previous_response_id").is_none()); + let input = next["input"].as_array().unwrap(); + assert_eq!(input.len(), 2); + assert_eq!(input[0]["content"], "first"); + assert_eq!(input[1]["content"][0]["text"], "answer"); +} diff --git a/src-tauri/src/protocol/codex_history/tests_native.rs b/src-tauri/src/protocol/codex_history/tests_native.rs new file mode 100644 index 0000000..6caa3c8 --- /dev/null +++ b/src-tauri/src/protocol/codex_history/tests_native.rs @@ -0,0 +1,163 @@ +use super::*; +use serde_json::json; + +#[tokio::test] +async fn incomplete_response_remains_resumable_history() { + let history = CodexHistoryStore::default(); + let scope = "session-incomplete"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Local, + true, + &json!({ + "input":[{"type":"message","role":"user","content":"write a lot"}] + }), + &json!({ + "id":"resp_incomplete","status":"incomplete", + "incomplete_details":{"reason":"max_output_tokens"}, + "output":[{ + "type":"message","id":"msg_partial","role":"assistant", + "content":[{"type":"output_text","text":"partial"}] + }] + }), + ) + .await; + let mut next = json!({ + "previous_response_id":"resp_incomplete", + "input":[{"type":"message","role":"user","content":"continue"}] + }); + + let resolution = history + .materialize_request_scoped(scope, true, &mut next) + .await; + assert!(resolution.previous_materialized); + assert!(next.get("previous_response_id").is_none()); + let decoded = crate::protocol::openai_responses::decode_request(&next).unwrap(); + assert_eq!( + decoded + .messages + .iter() + .map(|message| message.content_as_text()) + .collect::>(), + vec!["write a lot", "partial", "continue"] + ); +} + +#[tokio::test] +async fn metadata_recording_rejects_non_resumable_terminals() { + let history = CodexHistoryStore::default(); + let request = json!({ + "input":[{"type":"message","role":"user","content":"hello"}] + }); + for response in [ + json!({"id":"resp_failed","status":"failed","output":[]}), + json!({"id":"resp_partial","output":[]}), + json!({ + "id":"resp_compaction","object":"response.compaction","status":"completed", + "output":[{"type":"compaction","encrypted_content":"opaque"}] + }), + ] { + assert_eq!( + history + .record_response_scoped_with_metadata( + "session-terminal", + ResponseOrigin::Native("provider-a".to_string()), + true, + &request, + &response, + ) + .await, + 0 + ); + assert!(history + .response_metadata("session-terminal", response["id"].as_str().unwrap()) + .await + .is_none()); + } +} + +#[tokio::test] +async fn unsupported_output_and_owner_only_calls_never_become_portable_fallback() { + let history = CodexHistoryStore::default(); + let scope = "session-partial"; + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({"input":[{"type":"message","role":"user","content":"look"}]}), + &json!({ + "id":"resp_unsupported","object":"response","status":"completed", + "output":[{"type":"computer_call","id":"computer_1"}] + }), + ) + .await; + assert_eq!( + history + .response_metadata(scope, "resp_unsupported") + .await + .unwrap(), + ResponseMetadata { + origin: ResponseOrigin::Native("provider-a".to_string()), + materializable: false, + } + ); + + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + false, + &json!({ + "previous_response_id":"missing-prefix", + "input":[{"type":"message","role":"user","content":"run"}] + }), + &json!({ + "id":"resp_owner_call","status":"completed", + "output":[{ + "type":"function_call","call_id":"call_owner_only", + "name":"shell","arguments":"{}" + }] + }), + ) + .await; + let mut fallback = json!({ + "input":[{ + "type":"function_call_output","call_id":"call_owner_only","output":"ok" + }] + }); + assert_eq!( + history + .enrich_request_scoped(scope, true, &mut fallback) + .await, + 0 + ); + assert_eq!(fallback["input"].as_array().unwrap().len(), 1); + + history + .record_response_scoped_with_metadata( + scope, + ResponseOrigin::Native("provider-a".to_string()), + true, + &json!({ + "input":[{"type":"compaction","encrypted_content":"opaque-prefix"}] + }), + &json!({ + "id":"resp_compacted_input","status":"completed", + "output":[{ + "type":"message","role":"assistant", + "content":[{"type":"output_text","text":"answer"}] + }] + }), + ) + .await; + assert!( + !history + .response_metadata(scope, "resp_compacted_input") + .await + .unwrap() + .materializable + ); +} + diff --git a/src-tauri/src/protocol/codex_history/types.rs b/src-tauri/src/protocol/codex_history/types.rs new file mode 100644 index 0000000..44c4aac --- /dev/null +++ b/src-tauri/src/protocol/codex_history/types.rs @@ -0,0 +1,82 @@ +// Cache record types and the bounded store handle: what a recorded Responses turn keeps, plus the +// reverse indexes `resolve` consults when `previous_response_id` is absent or stale. + +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use tokio::sync::RwLock; + +pub(super) const MAX_CACHED_RESPONSES: usize = 512; +// Count-bounding alone is not enough once every entry carries the cumulative transcript: a long +// conversation would otherwise make the cache grow quadratically. This is a logical serialized +// size ceiling (including the duplicated call lookup values), which keeps resident memory in the +// same order of magnitude while still leaving ample room for large model contexts. +pub(super) const MAX_CACHED_HISTORY_BYTES: usize = 32 * 1024 * 1024; + +pub(super) type ScopedResponseId = (String, String); +pub(super) type ScopedCallId = (String, String); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ResponseOrigin { + #[default] + Local, + Native(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResponseMetadata { + pub origin: ResponseOrigin, + pub materializable: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryResolution { + pub changed: usize, + pub had_previous_response_id: bool, + pub previous_found: bool, + pub previous_materialized: bool, + pub previous_origin: Option, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct CachedResponse { + /// Full model-visible input used to create this response. For an incremental + /// `previous_response_id` request this already includes every earlier request and response. + pub(super) request_input: Vec, + pub(super) output: Vec, + pub(super) calls_by_id: HashMap, + pub(super) call_order: Vec, + pub(super) serialized_bytes: usize, + pub(super) origin: ResponseOrigin, + pub(super) materializable: bool, +} + +#[derive(Debug, Default)] +pub(super) struct HistoryInner { + pub(super) responses: HashMap, + pub(super) response_order: VecDeque, + /// Reverse index used only when `previous_response_id` is absent or stale. + /// A fallback is safe only when a call id resolves to exactly one response. + pub(super) call_index: HashMap>, + pub(super) cached_bytes: usize, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct CachedLookup { + pub(super) previous: Option, + pub(super) fallback: CachedResponse, +} + +/// Thread-safe, bounded Responses conversation-history store. +#[derive(Debug, Default)] +pub struct CodexHistoryStore { + pub(super) inner: RwLock, +} + +impl CachedLookup { + pub(super) fn call(&self, call_id: &str) -> Option<&Value> { + self.previous + .as_ref() + .and_then(|previous| previous.calls_by_id.get(call_id)) + .or_else(|| self.fallback.calls_by_id.get(call_id)) + } +} diff --git a/src-tauri/src/protocol/mod.rs b/src-tauri/src/protocol/mod.rs index 11f14d1..27d8a99 100644 --- a/src-tauri/src/protocol/mod.rs +++ b/src-tauri/src/protocol/mod.rs @@ -16,523 +16,20 @@ #![allow(dead_code)] pub mod anthropic; +mod codec; pub mod codex_history; pub mod openai_chat_client; pub mod openai_responses; +mod signatures; pub mod stream; - -use axum::http::Uri; - -/// A wire protocol a request or provider speaks. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Wire { - Anthropic, - OpenAiChat, - OpenAiResponses, -} - -impl Wire { - /// The provider's declared protocol (config `protocol` field). Unknown / absent → Anthropic, - /// which is today's passthrough default. - pub fn from_provider(s: Option<&str>) -> Wire { - match s { - Some("openai-chat") => Wire::OpenAiChat, - Some("openai-responses") => Wire::OpenAiResponses, - _ => Wire::Anthropic, - } - } - - /// The client's protocol, inferred from the inbound request path. Claude Code hits - /// `/v1/messages`; an OpenAI/Codex client hits `/v1/chat/completions` or `/v1/responses`. - pub fn from_request_path(uri: &Uri) -> Wire { - let p = uri.path().trim_end_matches('/'); - if p.ends_with("/responses") || p.ends_with("/responses/compact") { - Wire::OpenAiResponses - } else if p.contains("/chat/completions") { - Wire::OpenAiChat - } else { - Wire::Anthropic - } - } - - /// Short human label for exchange records / monitor UI. - pub fn label(self) -> &'static str { - match self { - Wire::Anthropic => "anthropic", - Wire::OpenAiChat => "openai-chat", - Wire::OpenAiResponses => "openai-responses", - } - } - - /// The bare endpoint appended to the provider's configured baseUrl. - pub fn endpoint_path(self) -> &'static str { - match self { - Wire::Anthropic => "/messages", - Wire::OpenAiChat => "/chat/completions", - Wire::OpenAiResponses => "/responses", - } - } - - /// Full upstream URL, treating the configured baseUrl as authoritative. - pub fn upstream_url(self, base_url: &str) -> String { - let base = base_url.trim_end_matches('/'); - format!("{}{}", base, self.endpoint_path()) - } - - /// Compatibility URL for configurations created when ccbud implicitly inserted `/v1`. - /// A versioned baseUrl (`v1`, `v4`, `v1beta`, …), or Google's `/openai` compatibility root, - /// must never receive another version segment. - pub fn v1_fallback_url(self, base_url: &str) -> Option { - let base = base_url.trim_end_matches('/'); - if base_url_has_version_suffix(base_url) - || (self == Wire::OpenAiChat && base.ends_with("/openai")) - { - return None; - } - Some(format!("{}/v1{}", base, self.endpoint_path())) - } - - /// Match only the three request endpoints that may be safely rebased onto a provider URL. - /// Models, count_tokens, HEAD, and unknown routes keep the generic passthrough path. - pub fn from_request_endpoint(path: &str) -> Option { - match path.trim_end_matches('/') { - "/messages" | "/v1/messages" => Some(Wire::Anthropic), - "/chat/completions" | "/v1/chat/completions" => Some(Wire::OpenAiChat), - "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" => { - Some(Wire::OpenAiResponses) - } - _ => None, - } - } - - pub fn request_endpoint_path(self, inbound_path: &str) -> &'static str { - if self == Wire::OpenAiResponses - && inbound_path.trim_end_matches('/').ends_with("/responses/compact") - { - "/responses/compact" - } else { - self.endpoint_path() - } - } - - pub fn upstream_url_for_request(self, base_url: &str, inbound_path: &str) -> String { - let base = base_url.trim_end_matches('/'); - format!("{}{}", base, self.request_endpoint_path(inbound_path)) - } - - pub fn v1_fallback_url_for_request( - self, - base_url: &str, - inbound_path: &str, - ) -> Option { - let base = base_url.trim_end_matches('/'); - if base_url_has_version_suffix(base_url) - || (self == Wire::OpenAiChat && base.ends_with("/openai")) - { - return None; - } - Some(format!("{}/v1{}", base, self.request_endpoint_path(inbound_path))) - } -} - -fn base_url_has_version_suffix(base_url: &str) -> bool { - let clean = base_url - .split(['?', '#']) - .next() - .unwrap_or(base_url) - .trim_end_matches('/'); - let after_authority = clean - .split_once("://") - .map(|(_, rest)| rest) - .unwrap_or(clean); - let Some((_, path)) = after_authority.split_once('/') else { - return false; - }; - let Some(segment) = path.rsplit('/').find(|segment| !segment.is_empty()) else { - return false; - }; - let mut chars = segment.chars(); - matches!(chars.next(), Some('v' | 'V')) - && matches!(chars.next(), Some(c) if c.is_ascii_digit()) -} - -/// Statuses commonly used by upstreams for an unrecognized or unsupported endpoint path. -/// Authentication, validation, payload-size, and rate-limit errors intentionally do not qualify. -pub fn should_try_v1_fallback(status: u16) -> bool { - matches!(status, 400 | 404 | 405) -} - -use llm_connector::core::Protocol; -use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; -use llm_connector::protocols::adapters::openai::OpenAIProtocol; -use llm_connector::types::{ChatRequest, ChatResponse, ToolCall}; -use serde_json::{json, Value}; - -/// Unique id for a synthesized response ("msg_ccbud__"). Clients persist these ids into -/// their history, and usage analytics de-dupes assistant messages BY id — a constant fallback id -/// would collapse every translated turn into a single counted request. -pub fn uid(prefix: &str) -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static N: AtomicU64 = AtomicU64::new(0); - let ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - format!("{}_{}_{}", prefix, ms, N.fetch_add(1, Ordering::Relaxed)) -} - -/// Extract Gemini's opaque thought signature from its OpenAI-compatible wire location, or from -/// an internal/native spelling encountered while translating. The canonical OpenAI compatibility -/// shape is `extra_content.google.thought_signature`. -pub(crate) fn json_thought_signature(value: &Value) -> Option { - value - .pointer("/extra_content/google/thought_signature") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .or_else(|| value.get("thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) - .or_else(|| value.pointer("/function/thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) - .map(str::to_string) -} - -/// Read the signature from the llm-connector IR. The crate supports both placements for native -/// Gemini, so accept either while keeping a single canonical wire representation at the edge. -pub(crate) fn tool_call_thought_signature(call: &ToolCall) -> Option { - call.thought_signature - .as_deref() - .filter(|s| !s.is_empty()) - .or_else(|| call.function.thought_signature.as_deref().filter(|s| !s.is_empty())) - .map(str::to_string) -} - -fn strip_internal_thought_signature(call: &mut Value) { - let Some(call_obj) = call.as_object_mut() else { return }; - call_obj.remove("thought_signature"); - if let Some(function) = call_obj.get_mut("function").and_then(Value::as_object_mut) { - function.remove("thought_signature"); - } -} - -fn set_google_thought_signature(call: &mut Value, signature: &str) { - strip_internal_thought_signature(call); - call["extra_content"]["google"]["thought_signature"] = json!(signature); -} - -/// llm-connector serializes its internal signature fields literally. Rewrite them into Gemini's -/// OpenAI-compatible `extra_content.google.thought_signature` before forwarding. -fn normalize_openai_request_thought_signatures(body: &mut Value) { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; - for message in messages { - let Some(calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else { continue }; - for call in calls { - if let Some(signature) = json_thought_signature(call) { - set_google_thought_signature(call, &signature); - } - } - } -} - -/// Thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) require every assistant message that -/// carries `tool_calls` to also carry a non-empty `reasoning_content`, and answer -/// "reasoning_content is missing in assistant tool call message" otherwise. Real reasoning is -/// bridged from the client history where available (thinking blocks, Responses reasoning items); -/// this is the last-resort placeholder for turns whose reasoning didn't survive the wire. -/// Providers without the requirement ignore the extra field. -fn ensure_chat_tool_call_reasoning_content(body: &mut Value) { - let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; - for message in messages { - let has_tool_calls = message.get("role").and_then(Value::as_str) == Some("assistant") - && message.get("tool_calls").and_then(Value::as_array).is_some_and(|c| !c.is_empty()); - if !has_tool_calls { - continue; - } - let missing = message - .get("reasoning_content") - .and_then(Value::as_str) - .map_or(true, |s| s.trim().is_empty()); - if missing { - message["reasoning_content"] = json!("tool call"); - } - } -} - -/// Gemini/OpenRouter/Cloudflare return provider metadata in `extra_content`, which serde ignores -/// when llm-connector parses a standard OpenAI ToolCall. Copy the opaque signature into the -/// crate's internal field before parsing; the original response remains otherwise unchanged. -fn normalize_openai_response_thought_signatures(body: &mut Value) { - let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) else { return }; - for choice in choices { - let Some(calls) = choice - .get_mut("message") - .and_then(|message| message.get_mut("tool_calls")) - .and_then(Value::as_array_mut) - else { continue }; - for call in calls { - if let Some(signature) = json_thought_signature(call) { - call["thought_signature"] = json!(signature); - } - } - } -} - -/// Decode an inbound client request (in its wire format) into the unified IR. -pub fn decode_client_request(client: Wire, body: &Value) -> Result { - match client { - Wire::Anthropic => anthropic::decode_request(body), - Wire::OpenAiChat => openai_chat_client::decode_request(body), - // Hand-rolled (not the crate's responses_request_to_chat_request, which drops - // function_call / function_call_output / assistant items and rejects flattened tools — - // fatal for Codex). - Wire::OpenAiResponses => openai_responses::decode_request(body), - } -} - -/// Encode the IR into the upstream provider's request BODY. `outgoing_model` is the provider's real -/// model (gateway already resolved it); `stream` requests SSE from the upstream. For the first cut -/// we translate cross-protocol responses buffered, so callers pass stream=false here and synthesize -/// the client SSE from the full response (true incremental transcoding is P2). -pub fn encode_upstream_request( - provider: Wire, - ir: &ChatRequest, - outgoing_model: &str, - stream: bool, -) -> Result { - let mut ir = ir.clone(); - ir.model = outgoing_model.to_string(); - ir.stream = Some(stream); - match provider { - Wire::OpenAiChat => { - let mut body = OpenAIProtocol::new("") - .build_chat_request_body(&ir) - .map_err(|e| e.to_string())?; - let lower_model = outgoing_model.to_ascii_lowercase(); - if lower_model.contains("gemini") { - normalize_openai_request_thought_signatures(&mut body); - } - // GLM's OpenAI-compatible coding endpoint uses its native `thinking` switch rather - // than the OpenAI `reasoning_effort` field emitted by the generic connector. - if lower_model.contains("glm") || lower_model.contains("zhipu") || lower_model.contains("z-ai") { - if let Some(object) = body.as_object_mut() { - object.remove("reasoning_effort"); - } - if ir.enable_thinking == Some(true) { - body["thinking"] = json!({ "type": "enabled" }); - } - } - ensure_chat_tool_call_reasoning_content(&mut body); - Ok(body) - } - Wire::OpenAiResponses => Ok(openai_responses::encode_request(&ir, outgoing_model, stream)), - // Reverse direction: an OpenAI/Codex client → an Anthropic upstream. The crate encodes the - // IR into an Anthropic Messages request (tool_calls→tool_use blocks, etc.). Anthropic - // requires max_tokens; OpenAI-family clients (Codex) usually omit it and the crate's - // fallback (1024) truncates agent turns — default to a workable ceiling instead. - Wire::Anthropic => { - if ir.max_tokens.is_none() { - ir.max_tokens = Some(8192); - } - AnthropicProtocol::new("") - .build_chat_request_body(&ir) - .map_err(|e| e.to_string()) - } - } -} - -/// Decode an upstream provider RESPONSE (its wire format, buffered) into the IR. -pub fn decode_upstream_response(provider: Wire, text: &str) -> Result { - match provider { - Wire::OpenAiChat => { - let normalized = match serde_json::from_str::(text) { - Ok(mut body) => { - normalize_openai_response_thought_signatures(&mut body); - body.to_string() - } - Err(_) => text.to_string(), - }; - OpenAIProtocol::new("").parse_response(&normalized).map_err(|e| e.to_string()) - } - Wire::OpenAiResponses => openai_responses::decode_response(text), - Wire::Anthropic => AnthropicProtocol::new("").parse_response(text).map_err(|e| e.to_string()), - } -} - -/// Encode the IR response back to the client's wire format as a buffered JSON body. -pub fn encode_client_response(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { - match client { - Wire::Anthropic => Ok(anthropic::encode_response(ir, client_model)), - Wire::OpenAiChat => Ok(openai_chat_client::encode_response(ir, client_model)), - // Hand-rolled (not the crate's chat_response_to_responses_response, which drops - // tool_calls from the output — Codex would never see a function call). - Wire::OpenAiResponses => Ok(openai_responses::encode_response(ir, client_model)), - } -} - -/// Whether we have an incremental (event-by-event) stream transcoder from `provider` to `client`. -/// When false, cross-protocol streaming falls back to buffer-upstream + synthesize-client-SSE. -pub fn can_transcode_stream(provider: Wire, client: Wire) -> bool { - stream::Transcoder::supports(provider, client) -} - -/// Encode the IR response to the client's wire format as a full SSE stream body (used when the -/// client asked to stream but we translated the upstream buffered — synthesize the event sequence). -pub fn encode_client_response_sse(client: Wire, ir: &ChatResponse, client_model: &str) -> Result { - match client { - Wire::Anthropic => Ok(anthropic::encode_response_sse(ir, client_model)), - Wire::OpenAiChat => Ok(openai_chat_client::encode_response_sse(ir, client_model)), - Wire::OpenAiResponses => Ok(openai_responses::encode_response_sse(ir, client_model)), - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn upstream_urls_respect_the_configured_base() { - let cases = [ - (Wire::Anthropic, "/messages"), - (Wire::OpenAiChat, "/chat/completions"), - (Wire::OpenAiResponses, "/responses"), - ]; - for (wire, endpoint) in cases { - for base in [ - "https://example.com", - "https://example.com/v1", - "https://example.com/v4", - "https://generativelanguage.googleapis.com/v1beta/openai", - ] { - assert_eq!(wire.upstream_url(base), format!("{}{}", base, endpoint)); - } - } - } - - #[test] - fn v1_fallback_is_only_offered_for_unversioned_bases() { - assert_eq!( - Wire::OpenAiChat.v1_fallback_url("https://example.com/api"), - Some("https://example.com/api/v1/chat/completions".to_string()) - ); - for base in [ - "https://example.com/v1", - "https://example.com/v4/", - "https://example.com/v1beta", - "https://example.com/V2alpha", - "https://generativelanguage.googleapis.com/v1beta/openai", - ] { - assert_eq!(Wire::OpenAiChat.v1_fallback_url(base), None, "{base}"); - } - } - - #[test] - fn canonical_request_endpoints_exclude_auxiliary_routes() { - assert_eq!(Wire::from_request_endpoint("/v1/messages"), Some(Wire::Anthropic)); - assert_eq!(Wire::from_request_endpoint("/v1/chat/completions"), Some(Wire::OpenAiChat)); - assert_eq!(Wire::from_request_endpoint("/v1/responses"), Some(Wire::OpenAiResponses)); - assert_eq!( - Wire::from_request_endpoint("/v1/responses/compact"), - Some(Wire::OpenAiResponses) - ); - assert_eq!( - Wire::OpenAiResponses.upstream_url_for_request( - "https://example.com/v1", - "/v1/responses/compact", - ), - "https://example.com/v1/responses/compact" - ); - assert_eq!(Wire::from_request_endpoint("/v1/messages/count_tokens"), None); - assert_eq!(Wire::from_request_endpoint("/v1/models"), None); - } - - #[test] - fn v1_fallback_statuses_exclude_non_path_errors() { - for status in [400, 404, 405] { - assert!(should_try_v1_fallback(status)); - } - for status in [401, 403, 413, 415, 422, 429, 500] { - assert!(!should_try_v1_fallback(status)); - } - } - - // Kimi/Moonshot and DeepSeek thinking models 400 on assistant tool-call history missing - // `reasoning_content`: real reasoning must survive the Responses→chat bridge, and turns whose - // reasoning didn't survive get the placeholder. - #[test] - fn chat_bodies_backfill_tool_call_reasoning() { - let codex = json!({ - "model": "m", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "go" }] }, - { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c1", "output": "ok" }, - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "real thoughts" }] }, - { "type": "function_call", "call_id": "c2", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c2", "output": "ok" } - ] - }); - let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); - let body = encode_upstream_request(Wire::OpenAiChat, &ir, "kimi-k2-thinking", true).unwrap(); - let assistants: Vec<_> = body["messages"].as_array().unwrap().iter() - .filter(|m| m["role"] == "assistant").collect(); - assert_eq!(assistants.len(), 2); - // step 1 lost its reasoning → placeholder; step 2's bridged reasoning is preserved - assert_eq!(assistants[0]["reasoning_content"], "tool call"); - assert_eq!(assistants[1]["reasoning_content"], "real thoughts"); - } - - #[test] - fn glm_chat_uses_native_thinking_switch() { - let codex = json!({ - "model": "gpt-5.4", - "input": [{ - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "inspect" }] - }], - "reasoning": { "effort": "ultra" } - }); - let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); - let body = encode_upstream_request(Wire::OpenAiChat, &ir, "glm-5.2", true).unwrap(); - assert_eq!(body["thinking"]["type"], "enabled"); - assert!(body.get("reasoning_effort").is_none()); - } - - #[test] - fn gemini_thought_signature_maps_between_openai_wire_and_ir() { - let signature = "sig-regression-abc"; - let upstream_response = json!({ - "id": "chatcmpl-gemini", "object": "chat.completion", "created": 1, - "model": "google/gemini-3-flash-preview", - "choices": [{ "index": 0, "finish_reason": "tool_calls", "message": { - "role": "assistant", "content": Value::Null, - "tool_calls": [{ - "id": "default_api:Bash", "type": "function", - "function": { "name": "default_api:Bash", "arguments": "{\"command\":\"pwd\"}" }, - "extra_content": { "google": { "thought_signature": signature } } - }] - }}], - "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } - }); - - let ir = decode_upstream_response(Wire::OpenAiChat, &upstream_response.to_string()).unwrap(); - let call = &ir.choices[0].message.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tool_call_thought_signature(call).as_deref(), Some(signature)); - assert_eq!(json_thought_signature(&json!({ - "thought_signature": "", "function": { "thought_signature": signature } - })).as_deref(), Some(signature)); - - let mut message = llm_connector::types::Message::new( - llm_connector::types::Role::Assistant, - vec![], - ); - message.tool_calls = Some(vec![call.clone()]); - let next_ir = ChatRequest::new("gemini").with_messages(vec![message]); - let outgoing = encode_upstream_request( - Wire::OpenAiChat, &next_ir, "google/gemini-3-flash-preview", false, - ).unwrap(); - let assistant = outgoing["messages"].as_array().unwrap().iter() - .find(|message| message["role"] == "assistant").unwrap(); - let outgoing_call = &assistant["tool_calls"][0]; - assert_eq!(outgoing_call["extra_content"]["google"]["thought_signature"], signature); - assert!(outgoing_call.get("thought_signature").is_none()); - assert!(outgoing_call["function"].get("thought_signature").is_none()); - } -} +mod tests; +mod wire; + +pub use codec::{ + can_transcode_stream, decode_client_request, decode_upstream_response, encode_client_response, + encode_client_response_sse, encode_upstream_request, +}; +pub use signatures::uid; +pub(crate) use signatures::{json_thought_signature, tool_call_thought_signature}; +pub use wire::{should_try_v1_fallback, Wire}; diff --git a/src-tauri/src/protocol/openai_chat_client.rs b/src-tauri/src/protocol/openai_chat_client.rs deleted file mode 100644 index 6d52e24..0000000 --- a/src-tauri/src/protocol/openai_chat_client.rs +++ /dev/null @@ -1,237 +0,0 @@ -// OpenAI Chat CLIENT-side codec (P4 reverse direction): when an OpenAI/Codex-style client hits the -// gateway at /v1/chat/completions and the provider is Anthropic, we decode the client's Chat request -// into the IR and re-encode the IR response back to Chat Completions shape. The Anthropic upstream -// side is handled by the crate's AnthropicProtocol. - -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall, -}; -use serde_json::{json, Value}; - -fn content_to_blocks(content: &Value) -> Vec { - if let Some(s) = content.as_str() { - return if s.is_empty() { vec![] } else { vec![MessageBlock::text(s)] }; - } - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for part in arr { - match part.get("type").and_then(|t| t.as_str()) { - Some("text") => { - if let Some(t) = part.get("text").and_then(|v| v.as_str()) { - out.push(MessageBlock::text(t)); - } - } - Some("image_url") => { - if let Some(u) = part.get("image_url").and_then(|i| i.get("url")).and_then(|v| v.as_str()) { - out.push(MessageBlock::image_url(u)); - } - } - _ => {} - } - } - out -} - -/// Decode an OpenAI Chat Completions REQUEST json into the IR. -pub fn decode_request(req: &Value) -> Result { - let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let mut messages: Vec = vec![]; - for m in req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter() { - let role = match m.get("role").and_then(|v| v.as_str()) { - Some("system") | Some("developer") => Role::System, - Some("assistant") => Role::Assistant, - Some("tool") => Role::Tool, - _ => Role::User, - }; - let content = m.get("content").cloned().unwrap_or(Value::Null); - let mut msg = Message::new(role, content_to_blocks(&content)); - if let Some(name) = m.get("name").and_then(|v| v.as_str()) { - msg.name = Some(name.to_string()); - } - if let Some(tcid) = m.get("tool_call_id").and_then(|v| v.as_str()) { - msg.tool_call_id = Some(tcid.to_string()); - } - if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { - let calls: Vec = tcs - .iter() - .map(|tc| ToolCall { - id: tc.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), - call_type: "function".to_string(), - function: FunctionCall { - name: tc.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string(), - arguments: tc.get("function").and_then(|f| f.get("arguments")).and_then(|v| v.as_str()).unwrap_or("{}").to_string(), - thought_signature: None, - }, - index: None, - thought_signature: None, - }) - .collect(); - if !calls.is_empty() { - msg.tool_calls = Some(calls); - } - } - messages.push(msg); - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(mt) = req.get("max_completion_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - if let Some(tools) = req.get("tools").and_then(|v| v.as_array()) { - let ts: Vec = tools - .iter() - .filter_map(|t| { - let f = t.get("function")?; - let name = f.get("name").and_then(|v| v.as_str())?; - let desc = f.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let params = f.get("parameters").cloned().unwrap_or_else(|| json!({ "type": "object" })); - Some(Tool::function(name, desc, params)) - }) - .collect(); - if !ts.is_empty() { - cr = cr.with_tools(ts); - } - } - Ok(cr) -} - -/// IR ChatResponse → OpenAI Chat Completions RESPONSE json. -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { resp.content.clone() } else { t } - }; - let mut message = json!({ "role": "assistant", "content": if text.is_empty() { Value::Null } else { json!(text) } }); - // Normalize the finish reason to OpenAI vocabulary (the IR may carry an Anthropic stop_reason - // when the upstream was Anthropic). - let mut finish = match choice.and_then(|c| c.finish_reason.as_deref()) { - Some("end_turn") | Some("stop") | None => "stop", - Some("max_tokens") | Some("length") => "length", - Some("tool_use") | Some("tool_calls") => "tool_calls", - Some(other) => other, - } - .to_string(); - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - if !calls.is_empty() { - let tcs: Vec = calls - .iter() - .enumerate() - .map(|(i, tc)| json!({ - "index": i, - "id": if tc.id.is_empty() { format!("call_{}", i) } else { tc.id.clone() }, - "type": "function", - "function": { "name": tc.function.name, "arguments": tc.function.arguments }, - })) - .collect(); - message["tool_calls"] = json!(tcs); - finish = "tool_calls".to_string(); - } - } - } - let usage = resp.usage.as_ref(); - json!({ - // never a constant fallback — clients persist this id and usage de-dupes by it - "id": if resp.id.is_empty() { super::uid("chatcmpl-ccbud") } else { resp.id.clone() }, - "object": "chat.completion", - "created": 0, - "model": client_model, - "choices": [{ "index": 0, "finish_reason": finish, "message": message }], - "usage": { - "prompt_tokens": usage.map(|u| u.prompt_tokens).unwrap_or(0), - "completion_tokens": usage.map(|u| u.completion_tokens).unwrap_or(0), - "total_tokens": usage.map(|u| u.total_tokens).unwrap_or(0), - } - }) -} - -/// IR ChatResponse → OpenAI Chat SSE stream (buffered synthesize: role chunk, content chunk(s), -/// tool_call chunk(s), final finish chunk, `[DONE]`). -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - let full = encode_response(resp, client_model); - let choice = &full["choices"][0]; - let message = &choice["message"]; - let finish = choice.get("finish_reason").and_then(|v| v.as_str()).unwrap_or("stop"); - let id = full.get("id").cloned().unwrap_or(json!("chatcmpl-ccbud")); - let chunk = |delta: Value, fin: Value| { - format!( - "data: {}\n\n", - serde_json::to_string(&json!({ - "id": id, "object": "chat.completion.chunk", "created": 0, "model": client_model, - "choices": [{ "index": 0, "delta": delta, "finish_reason": fin }], - })).unwrap_or_default() - ) - }; - let mut out = String::new(); - out.push_str(&chunk(json!({ "role": "assistant" }), Value::Null)); - if let Some(t) = message.get("content").and_then(|v| v.as_str()) { - if !t.is_empty() { - out.push_str(&chunk(json!({ "content": t }), Value::Null)); - } - } - if let Some(tcs) = message.get("tool_calls").and_then(|v| v.as_array()) { - out.push_str(&chunk(json!({ "tool_calls": tcs }), Value::Null)); - } - out.push_str(&chunk(json!({}), json!(finish))); - out.push_str("data: [DONE]\n\n"); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; - - #[test] - fn chat_request_to_ir_to_anthropic_upstream() { - let chat = json!({ - "model": "gpt-x", "max_tokens": 200, - "messages": [ - { "role": "system", "content": "be nice" }, - { "role": "user", "content": "hello" } - ], - "tools": [{ "type": "function", "function": { "name": "f", "description": "d", "parameters": { "type": "object" } } }] - }); - let ir = decode_request(&chat).unwrap(); - assert_eq!(ir.messages[0].content_as_text(), "be nice"); - assert_eq!(ir.messages[1].content_as_text(), "hello"); - assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "f"); - // crate encodes IR → Anthropic upstream request (the reverse direction's upstream half) - let body = AnthropicProtocol::new("").build_chat_request_body(&ir).unwrap(); - assert!(body.get("messages").is_some()); - } - - #[test] - fn anthropic_reply_to_ir_to_chat_response() { - // crate decodes an Anthropic response → IR; we encode IR → Chat Completions for the client. - let anthropic = r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude", - "content":[{"type":"text","text":"done"}],"stop_reason":"end_turn", - "usage":{"input_tokens":9,"output_tokens":4}}"#; - let ir = AnthropicProtocol::new("").parse_response(anthropic).unwrap(); - let out = encode_response(&ir, "gpt-x"); - assert_eq!(out["object"], "chat.completion"); - assert_eq!(out["model"], "gpt-x"); - assert_eq!(out["choices"][0]["message"]["content"], "done"); - assert_eq!(out["choices"][0]["finish_reason"], "stop"); - assert_eq!(out["usage"]["prompt_tokens"], 9); - assert_eq!(out["usage"]["completion_tokens"], 4); - } -} diff --git a/src-tauri/src/protocol/openai_chat_client/decode.rs b/src-tauri/src/protocol/openai_chat_client/decode.rs new file mode 100644 index 0000000..e030a88 --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/decode.rs @@ -0,0 +1,106 @@ +// Chat Completions REQUEST json → llm-connector ChatRequest IR. + +use llm_connector::types::{ChatRequest, FunctionCall, Message, MessageBlock, Role, Tool, ToolCall}; +use serde_json::{json, Value}; + +fn content_to_blocks(content: &Value) -> Vec { + if let Some(s) = content.as_str() { + return if s.is_empty() { vec![] } else { vec![MessageBlock::text(s)] }; + } + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for part in arr { + match part.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(t) = part.get("text").and_then(|v| v.as_str()) { + out.push(MessageBlock::text(t)); + } + } + Some("image_url") => { + if let Some(u) = part.get("image_url").and_then(|i| i.get("url")).and_then(|v| v.as_str()) { + out.push(MessageBlock::image_url(u)); + } + } + _ => {} + } + } + out +} + +/// Decode an OpenAI Chat Completions REQUEST json into the IR. +pub fn decode_request(req: &Value) -> Result { + let model = req.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mut messages: Vec = vec![]; + for m in req.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter() { + let role = match m.get("role").and_then(|v| v.as_str()) { + Some("system") | Some("developer") => Role::System, + Some("assistant") => Role::Assistant, + Some("tool") => Role::Tool, + _ => Role::User, + }; + let content = m.get("content").cloned().unwrap_or(Value::Null); + let mut msg = Message::new(role, content_to_blocks(&content)); + if let Some(name) = m.get("name").and_then(|v| v.as_str()) { + msg.name = Some(name.to_string()); + } + if let Some(tcid) = m.get("tool_call_id").and_then(|v| v.as_str()) { + msg.tool_call_id = Some(tcid.to_string()); + } + if let Some(tcs) = m.get("tool_calls").and_then(|v| v.as_array()) { + let calls: Vec = tcs + .iter() + .map(|tc| ToolCall { + id: tc.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), + call_type: "function".to_string(), + function: FunctionCall { + name: tc.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str()).unwrap_or("").to_string(), + arguments: tc.get("function").and_then(|f| f.get("arguments")).and_then(|v| v.as_str()).unwrap_or("{}").to_string(), + thought_signature: None, + }, + index: None, + thought_signature: None, + }) + .collect(); + if !calls.is_empty() { + msg.tool_calls = Some(calls); + } + } + messages.push(msg); + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(mt) = req.get("max_completion_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + if let Some(tools) = req.get("tools").and_then(|v| v.as_array()) { + let ts: Vec = tools + .iter() + .filter_map(|t| { + let f = t.get("function")?; + let name = f.get("name").and_then(|v| v.as_str())?; + let desc = f.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); + let params = f.get("parameters").cloned().unwrap_or_else(|| json!({ "type": "object" })); + Some(Tool::function(name, desc, params)) + }) + .collect(); + if !ts.is_empty() { + cr = cr.with_tools(ts); + } + } + Ok(cr) +} diff --git a/src-tauri/src/protocol/openai_chat_client/encode.rs b/src-tauri/src/protocol/openai_chat_client/encode.rs new file mode 100644 index 0000000..b64e81a --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/encode.rs @@ -0,0 +1,88 @@ +// llm-connector ChatResponse IR → Chat Completions RESPONSE json, buffered and as SSE. + +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// IR ChatResponse → OpenAI Chat Completions RESPONSE json. +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { resp.content.clone() } else { t } + }; + let mut message = json!({ "role": "assistant", "content": if text.is_empty() { Value::Null } else { json!(text) } }); + // Normalize the finish reason to OpenAI vocabulary (the IR may carry an Anthropic stop_reason + // when the upstream was Anthropic). + let mut finish = match choice.and_then(|c| c.finish_reason.as_deref()) { + Some("end_turn") | Some("stop") | None => "stop", + Some("max_tokens") | Some("length") => "length", + Some("tool_use") | Some("tool_calls") => "tool_calls", + Some(other) => other, + } + .to_string(); + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + if !calls.is_empty() { + let tcs: Vec = calls + .iter() + .enumerate() + .map(|(i, tc)| json!({ + "index": i, + "id": if tc.id.is_empty() { format!("call_{}", i) } else { tc.id.clone() }, + "type": "function", + "function": { "name": tc.function.name, "arguments": tc.function.arguments }, + })) + .collect(); + message["tool_calls"] = json!(tcs); + finish = "tool_calls".to_string(); + } + } + } + let usage = resp.usage.as_ref(); + json!({ + // never a constant fallback — clients persist this id and usage de-dupes by it + "id": if resp.id.is_empty() { crate::protocol::uid("chatcmpl-ccbud") } else { resp.id.clone() }, + "object": "chat.completion", + "created": 0, + "model": client_model, + "choices": [{ "index": 0, "finish_reason": finish, "message": message }], + "usage": { + "prompt_tokens": usage.map(|u| u.prompt_tokens).unwrap_or(0), + "completion_tokens": usage.map(|u| u.completion_tokens).unwrap_or(0), + "total_tokens": usage.map(|u| u.total_tokens).unwrap_or(0), + } + }) +} + +/// IR ChatResponse → OpenAI Chat SSE stream (buffered synthesize: role chunk, content chunk(s), +/// tool_call chunk(s), final finish chunk, `[DONE]`). +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + let full = encode_response(resp, client_model); + let choice = &full["choices"][0]; + let message = &choice["message"]; + let finish = choice.get("finish_reason").and_then(|v| v.as_str()).unwrap_or("stop"); + let id = full.get("id").cloned().unwrap_or(json!("chatcmpl-ccbud")); + let chunk = |delta: Value, fin: Value| { + format!( + "data: {}\n\n", + serde_json::to_string(&json!({ + "id": id, "object": "chat.completion.chunk", "created": 0, "model": client_model, + "choices": [{ "index": 0, "delta": delta, "finish_reason": fin }], + })).unwrap_or_default() + ) + }; + let mut out = String::new(); + out.push_str(&chunk(json!({ "role": "assistant" }), Value::Null)); + if let Some(t) = message.get("content").and_then(|v| v.as_str()) { + if !t.is_empty() { + out.push_str(&chunk(json!({ "content": t }), Value::Null)); + } + } + if let Some(tcs) = message.get("tool_calls").and_then(|v| v.as_array()) { + out.push_str(&chunk(json!({ "tool_calls": tcs }), Value::Null)); + } + out.push_str(&chunk(json!({}), json!(finish))); + out.push_str("data: [DONE]\n\n"); + out +} diff --git a/src-tauri/src/protocol/openai_chat_client/mod.rs b/src-tauri/src/protocol/openai_chat_client/mod.rs new file mode 100644 index 0000000..6b15046 --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/mod.rs @@ -0,0 +1,12 @@ +// OpenAI Chat CLIENT-side codec (P4 reverse direction): when an OpenAI/Codex-style client hits the +// gateway at /v1/chat/completions and the provider is Anthropic, we decode the client's Chat request +// into the IR and re-encode the IR response back to Chat Completions shape. The Anthropic upstream +// side is handled by the crate's AnthropicProtocol. + +mod decode; +mod encode; +#[cfg(test)] +mod tests; + +pub use decode::decode_request; +pub use encode::{encode_response, encode_response_sse}; diff --git a/src-tauri/src/protocol/openai_chat_client/tests.rs b/src-tauri/src/protocol/openai_chat_client/tests.rs new file mode 100644 index 0000000..a9e9f6d --- /dev/null +++ b/src-tauri/src/protocol/openai_chat_client/tests.rs @@ -0,0 +1,40 @@ +use super::decode::decode_request; +use super::encode::encode_response; +use serde_json::json; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; + +#[test] +fn chat_request_to_ir_to_anthropic_upstream() { + let chat = json!({ + "model": "gpt-x", "max_tokens": 200, + "messages": [ + { "role": "system", "content": "be nice" }, + { "role": "user", "content": "hello" } + ], + "tools": [{ "type": "function", "function": { "name": "f", "description": "d", "parameters": { "type": "object" } } }] + }); + let ir = decode_request(&chat).unwrap(); + assert_eq!(ir.messages[0].content_as_text(), "be nice"); + assert_eq!(ir.messages[1].content_as_text(), "hello"); + assert_eq!(ir.tools.as_ref().unwrap()[0].function.name, "f"); + // crate encodes IR → Anthropic upstream request (the reverse direction's upstream half) + let body = AnthropicProtocol::new("").build_chat_request_body(&ir).unwrap(); + assert!(body.get("messages").is_some()); +} + +#[test] +fn anthropic_reply_to_ir_to_chat_response() { + // crate decodes an Anthropic response → IR; we encode IR → Chat Completions for the client. + let anthropic = r#"{"id":"msg_1","type":"message","role":"assistant","model":"claude", + "content":[{"type":"text","text":"done"}],"stop_reason":"end_turn", + "usage":{"input_tokens":9,"output_tokens":4}}"#; + let ir = AnthropicProtocol::new("").parse_response(anthropic).unwrap(); + let out = encode_response(&ir, "gpt-x"); + assert_eq!(out["object"], "chat.completion"); + assert_eq!(out["model"], "gpt-x"); + assert_eq!(out["choices"][0]["message"]["content"], "done"); + assert_eq!(out["choices"][0]["finish_reason"], "stop"); + assert_eq!(out["usage"]["prompt_tokens"], 9); + assert_eq!(out["usage"]["completion_tokens"], 4); +} diff --git a/src-tauri/src/protocol/openai_responses.rs b/src-tauri/src/protocol/openai_responses.rs deleted file mode 100644 index 34e5ffc..0000000 --- a/src-tauri/src/protocol/openai_responses.rs +++ /dev/null @@ -1,2722 +0,0 @@ -// OpenAI Responses (/v1/responses) codec — BOTH halves: -// -// provider-side (gateway → a Responses upstream): -// encode_request: IR → Responses REQUEST body -// decode_response: Responses RESPONSE → IR -// -// client-side (a Responses client, i.e. Codex with wire_api="responses", → gateway): -// decode_request: Responses REQUEST → IR -// encode_response / encode_response_sse: IR → Responses RESPONSE (json / synthesized SSE) -// -// The Responses API uses an item-based `input` array (role messages + function_call / -// function_call_output items), `instructions` for the system prompt, `max_output_tokens`, and a -// `reasoning.effort` knob. Its response is an `output` array of items. Tool definitions are -// FLATTENED at the item level (`{"type":"function","name",...}`), unlike Chat Completions. -// -// The client-side halves are hand-rolled rather than reusing llm-connector's -// responses_request_to_chat_request / chat_response_to_responses_response: the crate's versions -// silently DROP function_call / function_call_output / assistant output_text history items and -// tool_calls in responses, and reject the flattened tool form — all fatal for Codex, whose agent -// loop is tool calls end-to-end. -// -// Codex reads the turn's items ONLY from `response.output_item.done` SSE events (text deltas are -// cosmetic; the stream MUST end with `response.completed` carrying id + usage), so the synthesized -// stream emits the full added → delta → done sequence per item. - -use llm_connector::core::Protocol; -use llm_connector::protocols::adapters::openai::OpenAIProtocol; -use llm_connector::types::{ - ChatRequest, ChatResponse, FunctionCall, Message, MessageBlock, ReasoningEffort, Role, Tool, - ToolCall, ToolChoice, -}; -use serde_json::{json, Value}; -use sha1::{Digest, Sha1}; -use std::collections::{HashMap, HashSet}; - -const CUSTOM_TOOL_INPUT_FIELD: &str = "input"; -const CUSTOM_TOOL_RAW_INPUT_INSTRUCTION: &str = - "Pass the custom tool's raw input unchanged in the `input` string field."; -const APPLY_PATCH_CHAT_INSTRUCTION: &str = "For apply_patch, the first line must be `*** Begin Patch` and the final line must be an unprefixed `*** End Patch`. Exact Add File skeleton:\n*** Begin Patch\n*** Add File: path\n+content\n*** End Patch\nPrefix every added file-content line with `+`, but never prefix either boundary marker. For updates, use `*** Update File: path` with an `@@` context hunk and ` `, `-`, or `+` line prefixes; for deletion, use `*** Delete File: path`."; -const TOOL_SEARCH_CHAT_NAME: &str = "tool_search"; -const CHAT_TOOL_NAME_MAX_LEN: usize = 64; -const CHAT_TOOL_NAME_HASH_LEN: usize = 12; - -/// The Responses tool shape that a chat-compatible upstream is standing in for. -/// -/// OpenAI Chat only has flat JSON-schema functions, while current Codex requests also carry -/// freeform custom tools, tool search, and namespace tools. The translation layer flattens all of -/// them to chat functions, then uses this metadata to restore the exact Responses item type on the -/// way back to Codex. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum CodexToolKind { - Function, - Namespace, - Custom, - ToolSearch, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct CodexToolSpec { - pub kind: CodexToolKind, - pub name: String, - pub namespace: Option, -} - -/// Request-scoped tool metadata used by both buffered and streaming Responses encoders. -/// -/// Build this from the original Codex request before decoding it to the connector IR. Loaded tools -/// embedded in `tool_search_output` history are included, so subsequent calls can be translated -/// even when their definitions are not repeated in the top-level `tools` array. -#[derive(Clone, Debug, Default)] -pub struct CodexToolContext { - ir_tools: Vec, - seen_chat_names: HashSet, - colliding_preferred_names: HashSet, - chat_name_to_spec: HashMap, - spec_to_chat_name: HashMap, -} - -impl CodexToolContext { - pub fn from_request(req: &Value) -> Self { - let mut context = Self::default(); - if let Some(tools) = req.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - if let Some(input) = req.get("input") { - // Codex Responses Lite (used by gpt-5.6-sol*) moves the complete tool registry out - // of the top-level `tools` field and into an `additional_tools` developer item. Treat - // those definitions exactly like top-level tools; the item itself is request metadata, - // not a chat message. - collect_additional_tools(input, &mut context); - collect_tool_search_output_tools(input, &mut context); - collect_response_tool_call_identities(input, &mut context); - } - collect_tool_choice_identity(req.get("tool_choice"), &mut context); - context - } - - pub fn ir_tools(&self) -> Vec { - self.ir_tools.clone() - } - - pub fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { - self.chat_name_to_spec.get(chat_name) - } - - pub fn kind_for_chat_name(&self, chat_name: &str) -> CodexToolKind { - self.lookup_chat_name(chat_name) - .map(|spec| spec.kind) - .unwrap_or(CodexToolKind::Function) - } - - pub fn chat_name_for_response_tool(&self, name: &str, namespace: Option<&str>) -> String { - let namespace = namespace.filter(|value| !value.is_empty()); - self.chat_name_for_spec(&CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - }) - } - - fn chat_name_for_custom_tool(&self, name: &str) -> String { - self.chat_name_for_spec(&CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }) - } - - fn chat_name_for_tool_search(&self) -> String { - self.chat_name_for_spec(&CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }) - } - - fn chat_name_for_spec(&self, spec: &CodexToolSpec) -> String { - self.spec_to_chat_name - .get(spec) - .cloned() - .unwrap_or_else(|| self.allocate_chat_name(spec)) - } - - pub(crate) fn response_item_id( - &self, - chat_name: &str, - response_id: &str, - index: usize, - ) -> String { - let prefix = match self.kind_for_chat_name(chat_name) { - CodexToolKind::Custom => "ctc", - CodexToolKind::ToolSearch => "tsc", - CodexToolKind::Function | CodexToolKind::Namespace => "fc", - }; - format!( - "{}_{}_{}", - prefix, - response_id.trim_start_matches("resp_"), - index - ) - } - - pub(crate) fn response_tool_item( - &self, - item_id: &str, - status: &str, - call_id: &str, - chat_name: &str, - arguments: &str, - ) -> Value { - self.response_tool_item_with_reasoning(item_id, status, call_id, chat_name, arguments, None) - } - - pub(crate) fn response_tool_item_with_reasoning( - &self, - item_id: &str, - status: &str, - call_id: &str, - chat_name: &str, - arguments: &str, - reasoning: Option<&str>, - ) -> Value { - let mut item = match self.lookup_chat_name(chat_name) { - Some(spec) if spec.kind == CodexToolKind::Custom => json!({ - "type": "custom_tool_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": spec.name, - "input": custom_tool_input_from_chat_arguments(arguments), - }), - Some(spec) if spec.kind == CodexToolKind::ToolSearch => json!({ - "type": "tool_search_call", - "status": status, - "call_id": call_id, - "execution": "client", - "arguments": parse_tool_arguments_object(arguments), - }), - Some(spec) => { - let mut item = json!({ - "type": "function_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": spec.name, - "arguments": if arguments.is_empty() { "{}" } else { arguments }, - }); - if let Some(namespace) = spec.namespace.as_deref().filter(|value| !value.is_empty()) - { - item["namespace"] = json!(namespace); - } - item - } - None => json!({ - "type": "function_call", - "id": item_id, - "status": status, - "call_id": call_id, - "name": chat_name, - "arguments": if arguments.is_empty() { "{}" } else { arguments }, - }), - }; - if let Some(reasoning) = reasoning.map(str::trim).filter(|value| !value.is_empty()) { - item["reasoning_content"] = json!(reasoning); - } - item - } - - fn add_response_tool(&mut self, tool: &Value) { - match tool { - Value::String(name) => self.add_custom_tool(&json!({ - "type": "custom", - "name": name, - })), - Value::Object(_) => match tool.get("type").and_then(Value::as_str) { - Some("function") | None => self.add_function_tool(tool, None), - Some("custom") => self.add_custom_tool(tool), - Some("tool_search") => self.add_tool_search_tool(tool), - Some("namespace") => self.add_namespace_tool(tool), - _ => {} - }, - _ => {} - } - } - - fn add_function_tool(&mut self, tool: &Value, namespace: Option<&str>) { - let function = tool - .get("function") - .filter(|value| value.is_object()) - .unwrap_or(tool); - let Some(name) = function.get("name").and_then(Value::as_str) else { - return; - }; - if name.trim().is_empty() { - return; - } - let description = function - .get("description") - .and_then(Value::as_str) - .map(ToString::to_string); - let parameters = normalize_function_parameters(function.get("parameters")); - let spec = CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - }; - self.add_chat_tool(spec, description, parameters); - } - - fn add_custom_tool(&mut self, tool: &Value) { - let Some(name) = tool.get("name").and_then(Value::as_str) else { - return; - }; - if name.trim().is_empty() { - return; - } - let mut description = tool - .get("description") - .and_then(Value::as_str) - .map(|description| format!("{description}\n\n{CUSTOM_TOOL_RAW_INPUT_INSTRUCTION}")) - .unwrap_or_else(|| CUSTOM_TOOL_RAW_INPUT_INSTRUCTION.to_string()); - if name == "apply_patch" { - description.push_str("\n\n"); - description.push_str(APPLY_PATCH_CHAT_INSTRUCTION); - } - let parameters = json!({ - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "Raw string input for the original custom tool. Preserve formatting exactly." - } - }, - "required": [CUSTOM_TOOL_INPUT_FIELD], - "additionalProperties": false, - }); - self.add_chat_tool( - CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }, - Some(description), - parameters, - ); - } - - fn add_tool_search_tool(&mut self, tool: &Value) { - let description = tool - .get("description") - .and_then(Value::as_str) - .map(ToString::to_string) - .unwrap_or_else(|| { - "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task." - .to_string() - }); - let parameters = if tool.get("parameters").is_some_and(Value::is_object) { - normalize_function_parameters(tool.get("parameters")) - } else { - json!({ - "type": "object", - "properties": { - "query": { "type": "string" }, - "limit": { "type": "integer" } - }, - "required": ["query"], - "additionalProperties": false, - }) - }; - self.add_chat_tool( - CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }, - Some(description), - parameters, - ); - } - - fn add_namespace_tool(&mut self, tool: &Value) { - let Some(namespace) = tool.get("name").and_then(Value::as_str) else { - return; - }; - if namespace.trim().is_empty() { - return; - } - let Some(children) = tool - .get("tools") - .or_else(|| tool.get("children")) - .and_then(Value::as_array) - else { - return; - }; - for child in children { - if child.get("type").and_then(Value::as_str) == Some("function") { - self.add_function_tool(child, Some(namespace)); - } - } - } - - fn add_chat_tool( - &mut self, - spec: CodexToolSpec, - description: Option, - parameters: Value, - ) { - if self.spec_to_chat_name.contains_key(&spec) { - return; - } - let chat_name = self.reserve_chat_name(&spec); - self.ir_tools - .push(Tool::function(chat_name.clone(), description, parameters)); - } - - fn register_tool_identity(&mut self, spec: CodexToolSpec) { - if self.spec_to_chat_name.contains_key(&spec) { - return; - } - self.reserve_chat_name(&spec); - } - - fn reserve_chat_name(&mut self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - let chat_name = if is_valid_chat_tool_name(&preferred) - && !self.colliding_preferred_names.contains(&preferred) - { - if let Some(existing_spec) = self.chat_name_to_spec.get(&preferred).cloned() { - self.colliding_preferred_names.insert(preferred.clone()); - if preferred_chat_tool_name(&existing_spec) == preferred { - self.move_identity_to_hashed_alias(&existing_spec, &preferred); - } - self.allocate_hashed_chat_name(spec) - } else { - preferred - } - } else { - self.allocate_hashed_chat_name(spec) - }; - self.seen_chat_names.insert(chat_name.clone()); - self.chat_name_to_spec - .insert(chat_name.clone(), spec.clone()); - self.spec_to_chat_name - .insert(spec.clone(), chat_name.clone()); - chat_name - } - - fn move_identity_to_hashed_alias(&mut self, spec: &CodexToolSpec, old_name: &str) { - self.seen_chat_names.remove(old_name); - self.chat_name_to_spec.remove(old_name); - let new_name = self.allocate_hashed_chat_name(spec); - self.seen_chat_names.insert(new_name.clone()); - self.chat_name_to_spec - .insert(new_name.clone(), spec.clone()); - self.spec_to_chat_name - .insert(spec.clone(), new_name.clone()); - if let Some(tool) = self - .ir_tools - .iter_mut() - .find(|tool| tool.function.name == old_name) - { - tool.function.name = new_name; - } - } - - fn allocate_chat_name(&self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - if is_valid_chat_tool_name(&preferred) - && !self.colliding_preferred_names.contains(&preferred) - && !self.seen_chat_names.contains(&preferred) - { - return preferred; - } - - self.allocate_hashed_chat_name(spec) - } - - fn allocate_hashed_chat_name(&self, spec: &CodexToolSpec) -> String { - let preferred = preferred_chat_tool_name(spec); - let digest = tool_identity_digest(spec); - for attempt in 0_u64.. { - let candidate = hashed_chat_tool_name(&preferred, &digest, attempt); - if !self.seen_chat_names.contains(&candidate) { - return candidate; - } - } - unreachable!("the finite request cannot exhaust all valid Chat tool aliases") - } -} - -/// Client-visible call ids must be unique even when an OpenAI-compatible upstream repeats or -/// omits its own ids for parallel calls. Scope them to the response and output position; the -/// client echoes this id, so subsequent translated history remains unambiguous. -pub(crate) fn response_scoped_call_id(response_id: &str, index: usize) -> String { - let mut digest = Sha1::new(); - digest.update(response_id.as_bytes()); - let digest = format!("{:x}", digest.finalize()); - format!("call_{}_{}", &digest[..16], index) -} - -fn normalize_function_parameters(parameters: Option<&Value>) -> Value { - let mut parameters = parameters - .filter(|value| value.is_object()) - .cloned() - .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); - if let Some(object) = parameters.as_object_mut() { - if object.get("type").and_then(Value::as_str) != Some("object") { - object.insert("type".to_string(), json!("object")); - } - object - .entry("properties".to_string()) - .or_insert_with(|| json!({})); - } - parameters -} - -fn preferred_chat_tool_name(spec: &CodexToolSpec) -> String { - match spec.namespace.as_deref() { - Some(namespace) if !namespace.is_empty() => format!("{namespace}__{}", spec.name), - _ => spec.name.clone(), - } -} - -fn is_valid_chat_tool_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= CHAT_TOOL_NAME_MAX_LEN - && name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) -} - -fn sanitized_chat_tool_name(name: &str) -> String { - let mut sanitized = String::with_capacity(name.len().min(CHAT_TOOL_NAME_MAX_LEN)); - for ch in name.chars() { - if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') { - sanitized.push(ch); - } else { - sanitized.push('_'); - } - } - if sanitized.is_empty() { - sanitized.push_str("tool"); - } - sanitized -} - -fn tool_identity_digest(spec: &CodexToolSpec) -> String { - let mut digest = Sha1::new(); - digest.update([match spec.kind { - CodexToolKind::Function => 0, - CodexToolKind::Namespace => 1, - CodexToolKind::Custom => 2, - CodexToolKind::ToolSearch => 3, - }]); - match spec.namespace.as_deref() { - Some(namespace) => { - digest.update([1]); - digest.update((namespace.len() as u64).to_be_bytes()); - digest.update(namespace.as_bytes()); - } - None => digest.update([0]), - } - digest.update((spec.name.len() as u64).to_be_bytes()); - digest.update(spec.name.as_bytes()); - format!("{:x}", digest.finalize()) -} - -fn hashed_chat_tool_name(preferred: &str, digest: &str, attempt: u64) -> String { - let suffix = if attempt == 0 { - format!("__{}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) - } else { - format!("__{}_{attempt}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) - }; - let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); - let mut prefix = sanitized_chat_tool_name(preferred); - prefix.truncate(prefix_len); - format!("{prefix}{suffix}") -} - -fn collect_additional_tools(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_additional_tools(item, context); - } - } - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("additional_tools") { - if let Some(tools) = object.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - } - for child in object.values() { - collect_additional_tools(child, context); - } - } - _ => {} - } -} - -fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_tool_search_output_tools(item, context); - } - } - Value::Object(object) => { - if object.get("type").and_then(Value::as_str) == Some("tool_search_output") { - if let Some(tools) = object.get("tools").and_then(Value::as_array) { - for tool in tools { - context.add_response_tool(tool); - } - } - } - for child in object.values() { - collect_tool_search_output_tools(child, context); - } - } - _ => {} - } -} - -fn collect_response_tool_call_identities(value: &Value, context: &mut CodexToolContext) { - match value { - Value::Array(items) => { - for item in items { - collect_response_tool_call_identities(item, context); - } - } - Value::Object(object) => { - let spec = match object.get("type").and_then(Value::as_str) { - Some("function_call") => object - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| { - let namespace = object - .get("namespace") - .and_then(Value::as_str) - .filter(|namespace| !namespace.is_empty()); - CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - } - }), - Some("custom_tool_call") => object - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }), - Some("tool_search_call") => Some(CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }), - _ => None, - }; - if let Some(spec) = spec { - context.register_tool_identity(spec); - } - for child in object.values() { - collect_response_tool_call_identities(child, context); - } - } - _ => {} - } -} - -fn collect_tool_choice_identity(tool_choice: Option<&Value>, context: &mut CodexToolContext) { - let Some(tool_choice) = tool_choice.filter(|value| value.is_object()) else { - return; - }; - let spec = match tool_choice.get("type").and_then(Value::as_str) { - Some("function") => tool_choice - .get("name") - .and_then(Value::as_str) - .or_else(|| { - tool_choice - .get("function") - .and_then(|function| function.get("name")) - .and_then(Value::as_str) - }) - .filter(|name| !name.trim().is_empty()) - .map(|name| { - let namespace = tool_choice - .get("namespace") - .and_then(Value::as_str) - .filter(|namespace| !namespace.is_empty()); - CodexToolSpec { - kind: if namespace.is_some() { - CodexToolKind::Namespace - } else { - CodexToolKind::Function - }, - name: name.to_string(), - namespace: namespace.map(ToString::to_string), - } - }), - Some("custom") => tool_choice - .get("name") - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .map(|name| CodexToolSpec { - kind: CodexToolKind::Custom, - name: name.to_string(), - namespace: None, - }), - Some("tool_search") => Some(CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: TOOL_SEARCH_CHAT_NAME.to_string(), - namespace: None, - }), - _ => None, - }; - if let Some(spec) = spec { - context.register_tool_identity(spec); - } -} - -pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String { - if arguments.trim().is_empty() { - return String::new(); - } - match serde_json::from_str::(arguments) { - Ok(Value::Object(object)) => object - .get(CUSTOM_TOOL_INPUT_FIELD) - .and_then(Value::as_str) - .unwrap_or(arguments) - .to_string(), - _ => arguments.to_string(), - } -} - -fn wrap_custom_tool_input(input: &Value) -> String { - let input = input - .as_str() - .map(ToString::to_string) - .unwrap_or_else(|| input.to_string()); - json!({ "input": input }).to_string() -} - -fn parse_tool_arguments_object(arguments: &str) -> Value { - if arguments.trim().is_empty() { - return json!({}); - } - serde_json::from_str::(arguments) - .ok() - .filter(Value::is_object) - .unwrap_or_else(|| json!({ "query": arguments })) -} - -/// Map a thinking budget (tokens) to a Responses reasoning effort tier. -fn budget_to_effort(budget: Option) -> &'static str { - match budget { - Some(b) if b >= 8192 => "high", - Some(b) if b >= 2048 => "medium", - _ => "low", - } -} - -/// Reverse of budget_to_effort: a Responses reasoning effort tier → a thinking budget (tokens). -fn effort_to_budget(effort: &str) -> u32 { - match effort { - "ultra" | "max" => 32768, - "xhigh" => 24576, - "high" => 16384, - "medium" => 4096, - _ => 1024, // "low" / "minimal" - } -} - -fn effort_to_reasoning_effort(effort: &str) -> ReasoningEffort { - match effort { - "medium" => ReasoningEffort::Medium, - "high" | "xhigh" | "max" | "ultra" => ReasoningEffort::High, - _ => ReasoningEffort::Low, - } -} - -fn response_incomplete_reason(finish_reason: Option<&str>) -> Option<&'static str> { - match finish_reason { - Some("length" | "max_tokens" | "model_context_window_exceeded") => { - Some("max_output_tokens") - } - Some("content_filter") => Some("content_filter"), - _ => None, - } -} - -/// IR (ChatRequest) → OpenAI Responses request BODY. `outgoing_model` is the provider's real model. -pub fn encode_request(ir: &ChatRequest, outgoing_model: &str, stream: bool) -> Value { - let mut instructions: Option = None; - let mut input: Vec = vec![]; - - for m in &ir.messages { - match m.role { - Role::System => { - // Responses carries the system prompt in `instructions`, not the input array. - let t = m.content_as_text(); - if !t.trim().is_empty() { - instructions = Some(match instructions.take() { - Some(prev) => format!("{}\n{}", prev, t), - None => t, - }); - } - } - Role::Tool => { - // a tool result → function_call_output item - input.push(json!({ - "type": "function_call_output", - "call_id": m.tool_call_id.clone().unwrap_or_default(), - "output": m.content_as_text(), - })); - } - Role::User => { - let text = m.content_as_text(); - let mut content: Vec = vec![]; - if !text.is_empty() { - content.push(json!({ "type": "input_text", "text": text })); - } - for b64 in m.content_as_images_base64() { - content.push(json!({ "type": "input_image", "image_url": format!("data:image/png;base64,{}", b64) })); - } - if !content.is_empty() { - input.push(json!({ "type": "message", "role": "user", "content": content })); - } - } - Role::Assistant => { - let text = m.content_as_text(); - if !text.is_empty() { - input.push(json!({ "type": "message", "role": "assistant", - "content": [{ "type": "output_text", "text": text }] })); - } - if let Some(calls) = &m.tool_calls { - for tc in calls { - input.push(json!({ - "type": "function_call", - "call_id": tc.id, - "name": tc.function.name, - "arguments": tc.function.arguments, - })); - } - } - } - } - } - - let mut body = json!({ - "model": outgoing_model, - "input": input, - "stream": stream, - }); - if let Some(instr) = instructions { - body["instructions"] = json!(instr); - } - if let Some(mt) = ir.max_tokens { - body["max_output_tokens"] = json!(mt); - } - if let Some(t) = ir.temperature { - body["temperature"] = json!(t); - } - if let Some(p) = ir.top_p { - body["top_p"] = json!(p); - } - // tools → Responses function tools (fields flattened at the item level, not nested under - // "function" like Chat Completions). - if let Some(tools) = &ir.tools { - let arr: Vec = tools - .iter() - .map(|t| { - json!({ - "type": "function", - "name": t.function.name, - "description": t.function.description, - "parameters": t.function.parameters, - }) - }) - .collect(); - if !arr.is_empty() { - body["tools"] = json!(arr); - } - } - // Anthropic extended thinking → Responses reasoning effort. - if ir.enable_thinking == Some(true) { - body["reasoning"] = json!({ "effort": budget_to_effort(ir.thinking_budget) }); - } - body -} - -/// OpenAI Responses RESPONSE (buffered) → IR. We reshape the Responses reply into an OpenAI Chat -/// completion and let the crate's parse_response build the IR — reusing its battle-tested mapping. -pub fn decode_response(text: &str) -> Result { - let v: Value = serde_json::from_str(text).map_err(|e| format!("responses parse: {}", e))?; - if v.get("status").and_then(Value::as_str) == Some("failed") { - let message = v - .pointer("/error/message") - .and_then(Value::as_str) - .unwrap_or("upstream Responses request failed"); - return Err(message.to_string()); - } - let output = v - .get("output") - .and_then(|o| o.as_array()) - .cloned() - .unwrap_or_default(); - - let mut content = String::new(); - let mut tool_calls: Vec = vec![]; - let mut had_tool = false; - for item in &output { - match item.get("type").and_then(|t| t.as_str()) { - Some("message") => { - if let Some(cs) = item.get("content").and_then(|c| c.as_array()) { - for c in cs { - if let Some(t) = c.get("text").and_then(|v| v.as_str()) { - content.push_str(t); - } - } - } - } - Some("function_call") => { - had_tool = true; - tool_calls.push(json!({ - "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(json!("")), - "type": "function", - "function": { - "name": item.get("name").cloned().unwrap_or(json!("")), - "arguments": item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}"), - } - })); - } - _ => {} - } - } - // fall back to the flattened output_text if no message items carried content - if content.is_empty() { - if let Some(t) = v.get("output_text").and_then(|v| v.as_str()) { - content = t.to_string(); - } - } - - let usage = v.get("usage").cloned().unwrap_or(json!({})); - let mut message = json!({ "role": "assistant", "content": content }); - if !tool_calls.is_empty() { - message["tool_calls"] = json!(tool_calls); - } - let finish_reason = if v.get("status").and_then(Value::as_str) == Some("incomplete") { - match v - .pointer("/incomplete_details/reason") - .and_then(Value::as_str) - { - Some("content_filter") => "content_filter", - _ => "length", - } - } else if had_tool { - "tool_calls" - } else { - "stop" - }; - let chat = json!({ - "id": v.get("id").cloned().unwrap_or(json!("resp")), - "object": "chat.completion", - "created": 0, - "model": v.get("model").cloned().unwrap_or(json!("")), - "choices": [{ "index": 0, "finish_reason": finish_reason, "message": message }], - "usage": { - "prompt_tokens": usage.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "completion_tokens": usage.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - "total_tokens": usage.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - } - }); - OpenAIProtocol::new("") - .parse_response(&chat.to_string()) - .map_err(|e| e.to_string()) -} - -// --------------------------------------------------------------------------- -// client-side half: a Responses client (Codex) in front of the gateway -// --------------------------------------------------------------------------- - -/// Pull text out of a Responses content value (string, or array of typed parts). Accepts the -/// input_text / output_text / text / summary_text part flavors. -fn parts_text(content: &Value) -> String { - if let Some(s) = content.as_str() { - return s.to_string(); - } - let arr = match content.as_array() { - Some(a) => a, - None => return String::new(), - }; - let mut out: Vec = vec![]; - for p in arr { - match p.get("type").and_then(|t| t.as_str()) { - Some("input_text") | Some("output_text") | Some("text") | Some("summary_text") => { - if let Some(t) = p.get("text").and_then(|v| v.as_str()) { - out.push(t.to_string()); - } - } - _ => {} - } - } - out.join("\n") -} - -/// input_image parts → IR image blocks. Codex sends `image_url` as a data URI (screenshots / -/// attached images); a plain URL is also accepted per the OpenAI spec. -fn parts_images(content: &Value) -> Vec { - let arr = match content.as_array() { - Some(a) => a, - None => return vec![], - }; - let mut out = vec![]; - for p in arr { - if p.get("type").and_then(|t| t.as_str()) != Some("input_image") { - continue; - } - let url = p.get("image_url").and_then(|v| v.as_str()).or_else(|| { - p.get("image_url") - .and_then(|v| v.get("url")) - .and_then(|v| v.as_str()) - }); - let Some(u) = url else { continue }; - if let Some(rest) = u.strip_prefix("data:") { - if let Some((meta, data)) = rest.split_once(";base64,") { - if !data.is_empty() { - out.push(MessageBlock::image_base64( - if meta.is_empty() { "image/png" } else { meta }, - data, - )); - } - continue; - } - } - out.push(MessageBlock::image_url(u)); - } - out -} - -/// Reasoning text carried by a Responses `reasoning` item: the summary parts (what a transcoded -/// stream emits and Codex echoes back), falling back to full `content` parts. -fn reasoning_item_text(item: &Value) -> Option { - for key in ["summary", "content"] { - let Some(parts) = item.get(key).and_then(|v| v.as_array()) else { - continue; - }; - let text = parts - .iter() - .filter_map(|p| { - p.get("text") - .and_then(|v| v.as_str()) - .or_else(|| p.as_str()) - }) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n\n"); - if !text.trim().is_empty() { - return Some(text); - } - } - None -} - -/// Append reasoning text onto a message's `reasoning_content` (the OpenAI-chat wire field). -fn append_reasoning_content(message: &mut Message, text: &str) { - let text = text.trim(); - if text.is_empty() { - return; - } - match &mut message.reasoning_content { - // Transcoded Responses output deliberately carries the same reasoning both - // as a sibling `reasoning` item and on each call item, so either surviving - // history representation is sufficient. Do not multiply it when both (or - // several parallel calls) are present. - Some(existing) if existing.trim() == text => {} - Some(existing) if !existing.is_empty() => { - existing.push_str("\n\n"); - existing.push_str(text); - } - slot => *slot = Some(text.to_string()), - } -} - -fn response_item_call_id(item: &Value) -> Option<&str> { - item.get("call_id") - .or_else(|| item.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ResponseCallKind { - Function, - Custom, - ToolSearch, -} - -impl ResponseCallKind { - fn call_item(item_type: &str) -> Option { - match item_type { - "function_call" => Some(Self::Function), - "custom_tool_call" => Some(Self::Custom), - "tool_search_call" => Some(Self::ToolSearch), - _ => None, - } - } - - fn output_item(item_type: &str) -> Option { - match item_type { - "function_call_output" => Some(Self::Function), - "custom_tool_call_output" => Some(Self::Custom), - "tool_search_output" => Some(Self::ToolSearch), - _ => None, - } - } - - fn label(self) -> &'static str { - match self { - Self::Function => "function", - Self::Custom => "custom tool", - Self::ToolSearch => "tool search", - } - } -} - -fn validate_call_output_pairs(req: &Value) -> Result<(), String> { - let items = match req.get("input") { - Some(Value::Array(items)) => items.iter().collect::>(), - Some(Value::Object(_)) => req.get("input").into_iter().collect::>(), - _ => return Ok(()), - }; - let mut calls = HashMap::::new(); - let mut seen_call_ids = HashSet::new(); - let mut unresolved = Vec::new(); - let mut consumed_in_group = false; - for item in items { - let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); - if item - .get("role") - .and_then(Value::as_str) - .is_some_and(|role| matches!(role, "user" | "system" | "developer")) - { - // A new client-authored turn closes the window in which an older call can be - // satisfied. Outputs after this point are stale/out of order. - calls.clear(); - consumed_in_group = false; - continue; - } - - if let Some(kind) = ResponseCallKind::call_item(item_type) { - let Some(call_id) = response_item_call_id(item) else { - return Err("Responses call item is missing call_id".to_string()); - }; - if consumed_in_group && !calls.is_empty() { - return Err(format!( - "Responses call order is ambiguous: new call {call_id} appeared before every preceding call produced an output" - )); - } - if calls.is_empty() { - consumed_in_group = false; - } - if !seen_call_ids.insert(call_id.to_string()) - || calls.insert(call_id.to_string(), kind).is_some() - { - return Err(format!( - "Responses call id is ambiguous because it appears more than once before output: {call_id}" - )); - } - continue; - } - - if let Some(output_kind) = ResponseCallKind::output_item(item_type) { - match response_item_call_id(item) { - Some(call_id) => match calls.remove(call_id) { - Some(call_kind) if call_kind == output_kind => { - consumed_in_group = true; - } - Some(call_kind) => unresolved.push(format!( - "{call_id} ({} output cannot satisfy {} call)", - output_kind.label(), - call_kind.label() - )), - None => { - if !unresolved.iter().any(|value| value == call_id) { - unresolved.push(call_id.to_string()); - } - } - }, - None => unresolved.push("".to_string()), - } - if !unresolved.is_empty() { - // Keep collecting only adjacent invalid outputs so the client gets useful ids, - // but never let a later call retroactively legitimize an earlier output. - consumed_in_group = true; - } - continue; - } - - match item_type { - // Reasoning and assistant output items can neighbor the same model turn and do not - // make otherwise ordered call/output pairs stale. - "reasoning" | "message" | "" => {} - _ => { - if item.get("role").is_some() { - calls.clear(); - consumed_in_group = false; - } - } - } - } - if unresolved.is_empty() { - Ok(()) - } else { - Err(format!( - "Responses call output has no preceding matching call: {}", - unresolved.join(", ") - )) - } -} - -fn response_history_tool_call(item: &Value, context: &CodexToolContext) -> Option { - let ty = item.get("type").and_then(Value::as_str).unwrap_or(""); - let id = response_item_call_id(item).unwrap_or("").to_string(); - if id.is_empty() { - return None; - } - let (name, arguments) = match ty { - "function_call" => { - let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); - let namespace = item.get("namespace").and_then(Value::as_str); - let name = context.chat_name_for_response_tool(original_name, namespace); - let arguments = match item.get("arguments") { - Some(Value::String(arguments)) => arguments.clone(), - Some(arguments) if !arguments.is_null() => arguments.to_string(), - _ => "{}".to_string(), - }; - (name, arguments) - } - "custom_tool_call" => { - let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); - let name = context.chat_name_for_custom_tool(original_name); - let input = item - .get("input") - .cloned() - .unwrap_or(Value::String(String::new())); - (name, wrap_custom_tool_input(&input)) - } - "tool_search_call" => { - let arguments = item - .get("arguments") - .map(|value| { - if let Some(arguments) = value.as_str() { - arguments.to_string() - } else { - value.to_string() - } - }) - .unwrap_or_else(|| "{}".to_string()); - (context.chat_name_for_tool_search(), arguments) - } - _ => return None, - }; - if name.is_empty() { - return None; - } - Some(ToolCall { - id, - call_type: "function".to_string(), - function: FunctionCall { - name, - arguments, - thought_signature: None, - }, - index: None, - thought_signature: None, - }) -} - -fn append_history_tool_call( - messages: &mut Vec, - pending_reasoning: &mut Option, - item_reasoning: Option<&str>, - call: ToolCall, -) { - // Codex emits a turn's prose and tool calls as sibling items. Fold the calls into the trailing - // assistant message so Chat/Anthropic upstreams receive one coherent assistant turn. - match messages.last_mut() { - Some(message) if message.role == Role::Assistant => { - if let Some(reasoning) = pending_reasoning.take() { - append_reasoning_content(message, &reasoning); - } - if let Some(reasoning) = item_reasoning { - append_reasoning_content(message, reasoning); - } - message.tool_calls.get_or_insert_with(Vec::new).push(call); - } - _ => { - let mut message = Message::new(Role::Assistant, vec![]); - if let Some(reasoning) = pending_reasoning.take() { - append_reasoning_content(&mut message, &reasoning); - } - if let Some(reasoning) = item_reasoning { - append_reasoning_content(&mut message, reasoning); - } - message.tool_calls = Some(vec![call]); - messages.push(message); - } - } -} - -fn response_tool_output_text(item: &Value) -> String { - if item.get("type").and_then(Value::as_str) == Some("tool_search_output") { - return json!({ - "status": item.get("status").cloned().unwrap_or(json!("completed")), - "execution": item.get("execution").cloned().unwrap_or(json!("client")), - "tools": item.get("tools").cloned().unwrap_or_else(|| json!([])), - }) - .to_string(); - } - match item.get("output") { - Some(Value::String(output)) => output.clone(), - Some(output @ Value::Array(_)) => { - let text = parts_text(output); - if text.is_empty() { - output.to_string() - } else { - text - } - } - Some(Value::Object(object)) => object - .get("content") - .map(|content| { - let text = parts_text(content); - if text.is_empty() { - content.to_string() - } else { - text - } - }) - .unwrap_or_else(|| Value::Object(object.clone()).to_string()), - _ => String::new(), - } -} - -/// Decode an OpenAI Responses REQUEST json (what Codex sends with wire_api="responses") into the -/// IR. Handles the full item vocabulary of an agentic history: message items (user input_text / -/// input_image, assistant output_text), all client-executed tool call/output item types, and `reasoning` -/// items — whose text is bridged onto the adjacent assistant message as `reasoning_content`, -/// because thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) reject assistant tool-call -/// history that lost its reasoning. System/developer items collapse into ONE leading system -/// message: strict providers (MiniMax) reject `role:system` anywhere but the head. Custom, -/// tool-search, and namespace tools are flattened to chat functions and restored with the returned -/// [`CodexToolContext`]. -pub fn decode_request(req: &Value) -> Result { - decode_request_with_context(req).map(|(request, _)| request) -} - -pub fn decode_request_with_context(req: &Value) -> Result<(ChatRequest, CodexToolContext), String> { - validate_call_output_pairs(req)?; - let model = req - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tool_context = CodexToolContext::from_request(req); - let mut messages: Vec = vec![]; - // All system text (instructions + system/developer message items), merged to the head. - let mut system_texts: Vec = vec![]; - // Reasoning waiting for the assistant message it belongs to (model output order is - // reasoning → prose → tool calls, so reasoning usually precedes its assistant message). - let mut pending_reasoning: Option = None; - - if let Some(instr) = req.get("instructions").and_then(|v| v.as_str()) { - if !instr.trim().is_empty() { - system_texts.push(instr.to_string()); - } - } - - match req.get("input") { - Some(Value::String(s)) => { - if !s.is_empty() { - messages.push(Message::text(Role::User, s.clone())); - } - } - Some(Value::Array(items)) => { - for item in items { - // Bare `{role, content}` items (no "type") are legal Responses input; treat them - // as message items. - let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or( - if item.get("role").is_some() { - "message" - } else { - "" - }, - ); - match ty { - "message" => { - let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); - let content = item.get("content").cloned().unwrap_or(Value::Null); - let text = parts_text(&content); - match role { - "assistant" => { - if !text.is_empty() { - let mut m = Message::text(Role::Assistant, text); - if let Some(r) = pending_reasoning.take() { - append_reasoning_content(&mut m, &r); - } - messages.push(m); - } - } - "system" | "developer" => { - pending_reasoning = None; - if !text.trim().is_empty() { - system_texts.push(text); - } - } - _ => { - pending_reasoning = None; - let mut blocks: Vec = vec![]; - if !text.is_empty() { - blocks.push(MessageBlock::text(text)); - } - blocks.extend(parts_images(&content)); - if !blocks.is_empty() { - messages.push(Message::new(Role::User, blocks)); - } - } - } - } - "reasoning" => { - // Belongs to the assistant step it neighbors: fold backward onto a - // directly preceding assistant message, else hold for the next one. - if let Some(text) = reasoning_item_text(item) { - match messages.last_mut() { - Some(m) if m.role == Role::Assistant => { - append_reasoning_content(m, &text) - } - _ => match &mut pending_reasoning { - Some(existing) if !existing.is_empty() => { - existing.push_str("\n\n"); - existing.push_str(text.trim()); - } - slot => *slot = Some(text.trim().to_string()), - }, - } - } - } - "function_call" | "custom_tool_call" | "tool_search_call" => { - if let Some(call) = response_history_tool_call(item, &tool_context) { - let item_reasoning = item - .get("reasoning_content") - .or_else(|| item.get("reasoning")) - .and_then(Value::as_str); - append_history_tool_call( - &mut messages, - &mut pending_reasoning, - item_reasoning, - call, - ); - } - } - "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { - pending_reasoning = None; - let id = response_item_call_id(item).unwrap_or("").to_string(); - if !id.is_empty() { - messages.push(Message::tool(response_tool_output_text(item), id)); - } - } - _ => {} - } - } - } - _ => {} - } - - if !system_texts.is_empty() { - messages.insert(0, Message::text(Role::System, system_texts.join("\n\n"))); - } - - let mut cr = ChatRequest::new(model).with_messages(messages); - if let Some(mt) = req.get("max_output_tokens").and_then(|v| v.as_u64()) { - cr = cr.with_max_tokens(mt as u32); - } - if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { - cr = cr.with_temperature(t as f32); - } - if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { - cr = cr.with_top_p(p as f32); - } - if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { - cr = cr.with_stream(true); - } - let tools = tool_context.ir_tools(); - if !tools.is_empty() { - cr = cr.with_tools(tools); - } - // tool_choice: mode strings pass through; both the flattened Responses object form - // ({type:"function",name}) and the nested Chat form pin a specific function. - if let Some(tc) = req.get("tool_choice") { - if let Some(mode) = tc.as_str() { - if matches!(mode, "auto" | "none" | "required") { - cr.tool_choice = Some(ToolChoice::Mode(mode.to_string())); - } - } else if let Some(kind) = tc.get("type").and_then(Value::as_str) { - let selected = match kind { - "function" => tc - .get("name") - .and_then(Value::as_str) - .or_else(|| { - tc.get("function") - .and_then(|f| f.get("name")) - .and_then(Value::as_str) - }) - .map(|name| { - tool_context.chat_name_for_response_tool( - name, - tc.get("namespace").and_then(Value::as_str), - ) - }), - "custom" => tc - .get("name") - .and_then(Value::as_str) - .map(|name| tool_context.chat_name_for_custom_tool(name)), - "tool_search" => Some(tool_context.chat_name_for_tool_search()), - _ => None, - }; - if let Some(name) = selected { - cr.tool_choice = Some(ToolChoice::function(name)); - } - } - } - // Preserve both representations: Anthropic-family encoders consume the thinking budget, - // while OpenAI-compatible Chat encoders consume reasoning_effort. Higher Responses tiers do - // not exist in the connector enum, so xhigh/max/ultra intentionally collapse to High there. - if let Some(effort) = req - .get("reasoning") - .and_then(|r| r.get("effort")) - .and_then(|v| v.as_str()) - { - cr = cr - .with_enable_thinking(true) - .with_thinking_budget(effort_to_budget(effort)) - .with_reasoning_effort(effort_to_reasoning_effort(effort)); - } - Ok((cr, tool_context)) -} - -/// Encode the IR response back into an OpenAI Responses RESPONSE json. `client_model` is the name -/// the client asked for (so Codex sees its own model, not the upstream's). Unlike the crate's -/// chat_response_to_responses_response this maps tool_calls → function_call items and provider -/// reasoning → a reasoning item — both load-bearing for Codex's agent loop. -pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { - encode_response_with_context(resp, client_model, &CodexToolContext::default()) -} - -pub fn encode_response_with_context( - resp: &ChatResponse, - client_model: &str, - tool_context: &CodexToolContext, -) -> Value { - let choice = resp.choices.first(); - let msg = choice.map(|c| &c.message); - // Same fallback as anthropic.rs: when a turn has tool_calls the crate parks the prose only in - // the top-level ChatResponse.content. - let text = { - let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); - if t.is_empty() { - resp.content.clone() - } else { - t - } - }; - // never a constant fallback — item ids derive from this and land in client history - let rid = if resp.id.is_empty() { - super::uid("ccbud") - } else { - resp.id.clone() - }; - - let mut output: Vec = vec![]; - if let Some(reasoning) = msg.and_then(|m| m.reasoning_any()) { - if !reasoning.trim().is_empty() { - output.push(json!({ "type": "reasoning", "id": format!("rs_{}", rid), - "summary": [{ "type": "summary_text", "text": reasoning }] })); - } - } - if !text.is_empty() { - output.push( - json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "annotations": [], "text": text }] }), - ); - } - if let Some(m) = msg { - if let Some(calls) = &m.tool_calls { - for (i, tc) in calls.iter().enumerate() { - let call_id = response_scoped_call_id(&format!("resp_{}", rid), i); - let item_id = tool_context.response_item_id(&tc.function.name, &rid, i); - output.push(tool_context.response_tool_item_with_reasoning( - &item_id, - "completed", - &call_id, - &tc.function.name, - &tc.function.arguments, - m.reasoning_any(), - )); - } - } - } - if output.is_empty() { - // Codex builds the turn from output items; an empty message beats an empty array. - output.push(json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", - "role": "assistant", "content": [{ "type": "output_text", "annotations": [], "text": "" }] })); - } - - let usage = resp.usage.as_ref(); - let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0) as i64; - let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0) as i64; - let total = - (usage.map(|u| u.total_tokens).unwrap_or(0) as i64).max(input_tokens + output_tokens); - let incomplete_reason = - response_incomplete_reason(choice.and_then(|choice| choice.finish_reason.as_deref())); - let mut response = json!({ - "id": format!("resp_{}", rid), - "object": "response", - "created_at": resp.created, - "status": if incomplete_reason.is_some() { "incomplete" } else { "completed" }, - "model": client_model, - "output": output, - "output_text": text, - "usage": { - "input_tokens": input_tokens, - "input_tokens_details": { "cached_tokens": 0 }, - "output_tokens": output_tokens, - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": total, - } - }); - if let Some(reason) = incomplete_reason { - response["incomplete_details"] = json!({ "reason": reason }); - } - response -} - -fn sse_ev(data: &Value) -> String { - let t = data - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("message"); - format!( - "event: {}\ndata: {}\n\n", - t, - serde_json::to_string(data).unwrap_or_default() - ) -} - -/// Synthesize an OpenAI Responses SSE event sequence from a finished IR response. Used when the -/// client (Codex) asked to stream but the upstream was translated buffered — the client still gets -/// a valid `response.created → output_item.added/delta/done per item → terminal event` stream, just -/// delivered at once. Codex materializes items only from `response.output_item.done`; truncations -/// terminate with `response.incomplete` instead of being mislabeled completed. -pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { - encode_response_sse_with_context(resp, client_model, &CodexToolContext::default()) -} - -pub fn encode_response_sse_with_context( - resp: &ChatResponse, - client_model: &str, - tool_context: &CodexToolContext, -) -> String { - let full = encode_response_with_context(resp, client_model, tool_context); - let rid = full - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("resp_ccbud") - .to_string(); - let mut out = String::new(); - out.push_str(&sse_ev(&json!({ "type": "response.created", - "response": { "id": rid, "object": "response", "status": "in_progress", "model": client_model } }))); - - let items = full - .get("output") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - for (idx, item) in items.iter().enumerate() { - let item_id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("item") - .to_string(); - match item.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "message" => { - let text = item["content"][0]["text"].as_str().unwrap_or(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, - "item": { "type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": [] } }))); - out.push_str(&sse_ev( - &json!({ "type": "response.content_part.added", "item_id": item_id, - "output_index": idx, "content_index": 0, - "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - if !text.is_empty() { - out.push_str(&sse_ev( - &json!({ "type": "response.output_text.delta", "item_id": item_id, - "output_index": idx, "content_index": 0, "delta": text }), - )); - } - out.push_str(&sse_ev( - &json!({ "type": "response.output_text.done", "item_id": item_id, - "output_index": idx, "content_index": 0, "text": text }), - )); - out.push_str(&sse_ev( - &json!({ "type": "response.content_part.done", "item_id": item_id, - "output_index": idx, "content_index": 0, - "part": { "type": "output_text", "annotations": [], "text": text } }), - )); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "function_call" => { - let args = item - .get("arguments") - .and_then(|v| v.as_str()) - .unwrap_or("{}"); - let mut added = item.clone(); - added["status"] = json!("in_progress"); - added["arguments"] = json!(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - out.push_str(&sse_ev( - &json!({ "type": "response.function_call_arguments.delta", "item_id": item_id, - "output_index": idx, "delta": args }), - )); - out.push_str(&sse_ev( - &json!({ "type": "response.function_call_arguments.done", "item_id": item_id, - "output_index": idx, "arguments": args }), - )); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "custom_tool_call" => { - let input = item.get("input").and_then(Value::as_str).unwrap_or(""); - let mut added = item.clone(); - added["status"] = json!("in_progress"); - added["input"] = json!(""); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - if !input.is_empty() { - out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.delta", - "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), - "output_index": idx, "delta": input }))); - } - out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.done", - "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), - "output_index": idx, "input": input }))); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "tool_search_call" => { - let mut added = item.clone(); - added["status"] = json!("in_progress"); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - "reasoning" => { - let think = item["summary"][0]["text"].as_str().unwrap_or(""); - out.push_str(&sse_ev( - &json!({ "type": "response.output_item.added", "output_index": idx, - "item": { "type": "reasoning", "id": item_id, "summary": [] } }), - )); - if !think.is_empty() { - out.push_str(&sse_ev(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": item_id, - "output_index": idx, "summary_index": 0, "delta": think }))); - } - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - _ => { - out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); - } - } - } - - let terminal_type = if full.get("status").and_then(Value::as_str) == Some("incomplete") { - "response.incomplete" - } else { - "response.completed" - }; - out.push_str(&sse_ev(&json!({ "type": terminal_type, "response": full }))); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_ir_to_responses_request() { - let anthropic = json!({ - "model": "claude-x", "max_tokens": 500, - "system": "be terse", - "tools": [{ "name": "grep", "description": "search", "input_schema": { "type": "object" } }], - "thinking": { "type": "enabled", "budget_tokens": 4096 }, - "messages": [ - { "role": "user", "content": "find foo" }, - { "role": "assistant", "content": [{ "type": "tool_use", "id": "c1", "name": "grep", "input": { "q": "foo" } }] }, - { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "c1", "content": "found" }] } - ] - }); - let ir = crate::protocol::anthropic::decode_request(&anthropic).unwrap(); - let body = encode_request(&ir, "gpt-5.5", false); - - assert_eq!(body["model"], "gpt-5.5"); - assert_eq!(body["instructions"], "be terse"); - assert_eq!(body["max_output_tokens"], 500); - assert_eq!(body["reasoning"]["effort"], "medium"); // 4096 → medium - // tools flattened (name at item level, not under "function") - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["name"], "grep"); - // input items: user message, function_call, function_call_output - let input = body["input"].as_array().unwrap(); - assert!(input.iter().any(|i| i["type"] == "message" - && i["role"] == "user" - && i["content"][0]["type"] == "input_text" - && i["content"][0]["text"] == "find foo")); - let fc = input.iter().find(|i| i["type"] == "function_call").unwrap(); - assert_eq!(fc["name"], "grep"); - assert_eq!(fc["call_id"], "c1"); - let fco = input - .iter() - .find(|i| i["type"] == "function_call_output") - .unwrap(); - assert_eq!(fco["call_id"], "c1"); - assert_eq!(fco["output"], "found"); - } - - #[test] - fn decodes_responses_reply_to_ir_then_anthropic() { - // A Responses reply with an assistant message + a function_call output item. - let resp = json!({ - "id": "resp_1", "object": "response", "created_at": 1, "model": "gpt-5.5", "status": "completed", - "output": [ - { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Working on it." }] }, - { "type": "function_call", "call_id": "call_7", "name": "grep", "arguments": "{\"q\":\"foo\"}" } - ], - "usage": { "input_tokens": 15, "output_tokens": 8, "total_tokens": 23 } - }); - let ir = decode_response(&resp.to_string()).unwrap(); - // reuse the Anthropic response encoder → verify the round-trip surfaces text + tool_use + usage - let out = crate::protocol::anthropic::encode_response(&ir, "claude-x"); - assert_eq!(out["stop_reason"], "tool_use"); - assert_eq!(out["usage"]["input_tokens"], 15); - assert_eq!(out["usage"]["output_tokens"], 8); - let content = out["content"].as_array().unwrap(); - assert!(content - .iter() - .any(|b| b["type"] == "text" && b["text"] == "Working on it.")); - let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); - assert_eq!(tu["name"], "grep"); - assert_eq!(tu["input"]["q"], "foo"); - } - - // A representative Codex request (wire_api="responses"): instructions, flattened function - // tools, and an agentic history — user message, assistant prose + function_call, its - // function_call_output, and a reasoning item bridged onto the assistant turn. - fn codex_request() -> Value { - json!({ - "model": "z-ai/glm-5.2", - "instructions": "You are Codex.", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list files" }] }, - { "type": "reasoning", "id": "rs_x", "summary": [{ "type": "summary_text", "text": "thinking…" }] }, - { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Running ls." }] }, - { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"ls\"]}" }, - { "type": "function_call_output", "call_id": "call_1", "output": "a.txt\nb.txt" }, - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] } - ], - "tools": [ - { "type": "function", "name": "shell", "description": "run a command", "strict": false, - "parameters": { "type": "object", "properties": { "command": { "type": "array" } } } }, - { "type": "web_search" } - ], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { "effort": "medium", "summary": "auto" }, - "store": false, - "stream": true - }) - } - - #[test] - fn decodes_codex_responses_request_to_ir() { - let ir = decode_request(&codex_request()).unwrap(); - let roles: Vec<_> = ir - .messages - .iter() - .map(|m| format!("{:?}", m.role)) - .collect(); - // instructions → System; assistant prose + function_call folded into ONE assistant turn; - // function_call_output → Tool; reasoning item bridged onto the assistant turn. - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); - assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); - assert_eq!(ir.messages[1].content_as_text(), "list files"); - assert_eq!(ir.messages[2].content_as_text(), "Running ls."); - assert_eq!( - ir.messages[2].reasoning_content.as_deref(), - Some("thinking…") - ); - let calls = ir.messages[2].tool_calls.as_ref().unwrap(); - assert_eq!(calls[0].id, "call_1"); - assert_eq!(calls[0].function.name, "shell"); - assert!(calls[0].function.arguments.contains("ls")); - assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("call_1")); - assert_eq!(ir.messages[3].content_as_text(), "a.txt\nb.txt"); - // flattened function tool recognized, non-function web_search dropped - let tools = ir.tools.as_ref().unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].function.name, "shell"); - assert_eq!(ir.stream, Some(true)); - // reasoning.effort medium → thinking budget for an Anthropic upstream - assert_eq!(ir.enable_thinking, Some(true)); - assert_eq!(ir.thinking_budget, Some(4096)); - - // The crate encodes the IR to a real Anthropic Messages body — proves the reused half - // works end-to-end (responses client → anthropic upstream). - use llm_connector::core::Protocol; - use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; - let body = AnthropicProtocol::new("") - .build_chat_request_body(&ir) - .unwrap(); - let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); - // assistant turn carries a tool_use block; tool output became a user tool_result turn - assert!(msgs.iter().any(|m| m["role"] == "assistant" - && m["content"] - .as_array() - .unwrap() - .iter() - .any(|b| b["type"] == "tool_use" && b["id"] == "call_1"))); - assert!(msgs.iter().any(|m| m["role"] == "user" - && m["content"] - .as_array() - .unwrap() - .iter() - .any(|b| b["type"] == "tool_result" && b["tool_use_id"] == "call_1"))); - assert_eq!(body["system"], "You are Codex."); - } - - fn codex_responses_lite_request() -> Value { - json!({ - "model": "gpt-5.6-sol-pro", - "input": [ - { - "type": "additional_tools", - "role": "developer", - "tools": [ - { - "type": "custom", - "name": "exec", - "description": "Run JavaScript that can call nested Codex tools.", - "format": { - "type": "grammar", - "syntax": "lark", - "definition": "start: /[\\s\\S]+/" - } - }, - { - "type": "function", - "name": "wait", - "description": "Wait for a yielded exec cell.", - "parameters": { - "type": "object", - "properties": { "cell_id": { "type": "string" } }, - "required": ["cell_id"], - "additionalProperties": false - } - }, - { - "type": "function", - "name": "request_user_input", - "description": "Ask the user a question.", - "parameters": { - "type": "object", - "properties": { "question": { "type": "string" } }, - "required": ["question"] - } - }, - { - "type": "namespace", - "name": "collaboration", - "tools": [{ - "type": "function", - "name": "spawn_agent", - "description": "Spawn a sub-agent.", - "parameters": { - "type": "object", - "properties": { "task_name": { "type": "string" } }, - "required": ["task_name"] - } - }] - } - ] - }, - { - "type": "message", - "role": "developer", - "content": [{ "type": "input_text", "text": "You are Codex." }] - }, - { - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": "Inspect the project." }] - } - ], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { "effort": "ultra", "summary": "none", "context": "all_turns" }, - "stream": true - }) - } - - #[test] - fn decodes_responses_lite_additional_tools_and_restores_custom_calls() { - let (ir, context) = - decode_request_with_context(&codex_responses_lite_request()).unwrap(); - - let roles = ir - .messages - .iter() - .map(|message| format!("{:?}", message.role)) - .collect::>(); - assert_eq!(roles, vec!["System", "User"]); - assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); - - let tools = ir.tools.as_ref().unwrap(); - let names = tools - .iter() - .map(|tool| tool.function.name.as_str()) - .collect::>(); - assert_eq!( - names, - vec![ - "exec", - "wait", - "request_user_input", - "collaboration__spawn_agent" - ] - ); - assert_eq!( - context.lookup_chat_name("exec").map(|spec| spec.kind), - Some(CodexToolKind::Custom) - ); - assert_eq!(ir.enable_thinking, Some(true)); - assert_eq!(ir.thinking_budget, Some(32768)); - assert_eq!(ir.reasoning_effort, Some(ReasoningEffort::High)); - - let chat_request = OpenAIProtocol::new("") - .build_chat_request_body(&ir) - .unwrap(); - assert_eq!(chat_request["tools"].as_array().map(Vec::len), Some(4)); - assert_eq!(chat_request["reasoning_effort"], "high"); - - let chat_response = r#"{ - "id":"chatcmpl-lite","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":null, - "tool_calls":[{"id":"call_exec","type":"function","function":{ - "name":"exec","arguments":"{\"input\":\"const result = await tools.exec_command({cmd: \\\"pwd\\\"});\"}" - }}] - }}], - "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} - }"#; - let response_ir = OpenAIProtocol::new("") - .parse_response(chat_response) - .unwrap(); - let response = - encode_response_with_context(&response_ir, "gpt-5.6-sol-pro", &context); - let exec = response["output"] - .as_array() - .unwrap() - .iter() - .find(|item| item["type"] == "custom_tool_call") - .unwrap(); - assert_eq!(exec["name"], "exec"); - assert_eq!( - exec["input"], - "const result = await tools.exec_command({cmd: \"pwd\"});" - ); - } - - #[test] - fn rejects_call_outputs_without_a_preceding_matching_call() { - for input in [ - json!([{ - "type":"function_call_output","call_id":"missing_call","output":"done" - }]), - json!([ - {"type":"custom_tool_call_output","call_id":"late_call","output":"done"}, - {"type":"custom_tool_call","call_id":"late_call","name":"apply_patch","input":"patch"} - ]), - json!([{"type":"tool_search_output","tools":[]}]), - json!([ - {"type":"function_call","call_id":"duplicate_output","name":"shell","arguments":"{}"}, - {"type":"function_call_output","call_id":"duplicate_output","output":"one"}, - {"type":"function_call_output","call_id":"duplicate_output","output":"two"} - ]), - json!([ - {"type":"function_call","call_id":"wrong_kind","name":"shell","arguments":"{}"}, - {"type":"custom_tool_call_output","call_id":"wrong_kind","output":"done"} - ]), - json!([ - {"type":"function_call","call_id":"stale_call","name":"shell","arguments":"{}"}, - {"type":"message","role":"user","content":"start another turn"}, - {"type":"function_call_output","call_id":"stale_call","output":"done"} - ]), - json!([ - {"type":"function_call","call_id":"stale_bare","name":"shell","arguments":"{}"}, - {"role":"user","content":"start another turn"}, - {"type":"function_call_output","call_id":"stale_bare","output":"done"} - ]), - ] { - let error = decode_request(&json!({ "model":"m", "input":input })).unwrap_err(); - assert!( - error.contains("no preceding matching call"), - "unexpected validation error: {error}" - ); - } - } - - #[test] - fn rejects_ambiguous_duplicate_call_ids_and_interleaved_call_groups() { - for input in [ - json!([ - {"type":"function_call","call_id":"same","name":"first","arguments":"{}"}, - {"type":"custom_tool_call","call_id":"same","name":"second","input":"x"} - ]), - json!([ - {"type":"function_call","call_id":"c1","name":"first","arguments":"{}"}, - {"type":"function_call","call_id":"c2","name":"second","arguments":"{}"}, - {"type":"function_call_output","call_id":"c1","output":"one"}, - {"type":"function_call","call_id":"c3","name":"third","arguments":"{}"} - ]), - json!([ - {"type":"function_call","call_id":"reused","name":"first","arguments":"{}"}, - {"type":"function_call_output","call_id":"reused","output":"one"}, - {"role":"user","content":"next turn"}, - {"type":"function_call","call_id":"reused","name":"second","arguments":"{}"} - ]), - ] { - let error = decode_request(&json!({"model":"m","input":input})).unwrap_err(); - assert!( - error.contains("ambiguous"), - "unexpected validation error: {error}" - ); - } - } - - // Thinking chat upstreams reject tool-call history without reasoning, and MiniMax rejects - // `role:system` anywhere but the head — the decoder must bridge reasoning items onto their - // assistant turn and merge all system/developer text into one leading system message. - #[test] - fn bridges_reasoning_and_collapses_system_into_head() { - let req = json!({ - "model": "m", - "instructions": "You are Codex.", - "input": [ - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "run ls" }] }, - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "need to list" }] }, - { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, - { "type": "function_call_output", "call_id": "c1", "output": "a.txt" }, - { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "be careful" }] }, - { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "again" }] } - ] - }); - let ir = decode_request(&req).unwrap(); - let roles: Vec<_> = ir - .messages - .iter() - .map(|m| format!("{:?}", m.role)) - .collect(); - // exactly ONE system message, at the head, carrying instructions + the developer item - assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); - assert_eq!( - ir.messages[0].content_as_text(), - "You are Codex.\n\nbe careful" - ); - // the reasoning that produced the tool call rides the tool-call assistant turn - assert_eq!( - ir.messages[2].reasoning_content.as_deref(), - Some("need to list") - ); - assert_eq!(ir.messages[2].tool_calls.as_ref().unwrap()[0].id, "c1"); - } - - #[test] - fn bridges_call_item_reasoning_without_duplicate_parallel_copies() { - let req = json!({ - "model":"m", - "input":[ - {"type":"reasoning","summary":[{"type":"summary_text","text":"inspect both"}]}, - {"type":"function_call","call_id":"c1","name":"first","arguments":"{}", - "reasoning_content":"inspect both"}, - {"type":"function_call","call_id":"c2","name":"second","arguments":"{}", - "reasoning_content":"inspect both"}, - {"type":"function_call_output","call_id":"c1","output":"one"}, - {"type":"function_call_output","call_id":"c2","output":"two"} - ] - }); - - let ir = decode_request(&req).unwrap(); - assert_eq!(ir.messages[0].role, Role::Assistant); - assert_eq!( - ir.messages[0].reasoning_content.as_deref(), - Some("inspect both") - ); - assert_eq!(ir.messages[0].tool_calls.as_ref().unwrap().len(), 2); - - let item_only = json!({ - "model":"m", - "input":[ - {"type":"function_call","call_id":"c3","name":"third","arguments":"{}", - "reasoning_content":"cached reasoning"}, - {"type":"function_call_output","call_id":"c3","output":"three"} - ] - }); - let ir = decode_request(&item_only).unwrap(); - assert_eq!( - ir.messages[0].reasoning_content.as_deref(), - Some("cached reasoning") - ); - } - - #[test] - fn encodes_ir_to_responses_response_with_tool_calls() { - // A chat upstream reply with prose + a tool call → the Responses body Codex consumes. - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"chatcmpl-9","object":"chat.completion","created":1,"model":"gpt-4o", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"Checking.", - "tool_calls":[{"id":"call_9","type":"function", - "function":{"name":"shell","arguments":"{\"command\":[\"ls\"]}"}}]}}], - "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let out = encode_response(&ir, "z-ai/glm-5.2"); - assert_eq!(out["object"], "response"); - assert_eq!(out["status"], "completed"); - assert_eq!(out["model"], "z-ai/glm-5.2"); - assert_eq!(out["usage"]["input_tokens"], 11); - assert_eq!(out["usage"]["output_tokens"], 7); - assert_eq!(out["usage"]["total_tokens"], 18); - let output = out["output"].as_array().unwrap(); - let m = output.iter().find(|i| i["type"] == "message").unwrap(); - assert_eq!(m["content"][0]["type"], "output_text"); - assert_eq!(m["content"][0]["text"], "Checking."); - let fc = output - .iter() - .find(|i| i["type"] == "function_call") - .unwrap(); - assert_eq!( - fc["call_id"], - response_scoped_call_id(out["id"].as_str().unwrap(), 0) - ); - assert_eq!(fc["name"], "shell"); - assert_eq!(fc["arguments"], "{\"command\":[\"ls\"]}"); - } - - #[test] - fn synthesized_responses_sse_carries_items_and_completed() { - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"c1","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"On it.", - "tool_calls":[{"id":"call_2","type":"function", - "function":{"name":"apply_patch","arguments":"{\"p\":1}"}}]}}], - "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let sse = encode_response_sse(&ir, "alias-model"); - // ordered: created → message item events → function_call item events → completed - let created = sse.find("\"type\":\"response.created\"").unwrap(); - let item_done = sse.find("response.output_item.done").unwrap(); - let completed = sse.find("\"type\":\"response.completed\"").unwrap(); - assert!(created < item_done && item_done < completed); - // Codex reads items exclusively from output_item.done: both items must appear there. - assert!(sse.contains(r#""delta":"On it.""#)); - assert!(sse.contains(&format!( - r#""call_id":"{}""#, - response_scoped_call_id("resp_c1", 0) - ))); - assert!(sse.contains(r#""name":"apply_patch""#)); - assert!(sse.contains(r#""arguments":"{\"p\":1}""#)); - // completed carries id + usage (codex errors without them) - assert!(sse.contains(r#""input_tokens":5"#)); - assert!(sse.contains(r#""output_tokens":3"#)); - assert!(sse.contains(r#""id":"resp_c1""#)); - } - - #[test] - fn buffered_duplicate_upstream_call_ids_become_unique_and_response_scoped() { - use llm_connector::core::Protocol; - - let parse = |response_id: &str| { - OpenAIProtocol::new("") - .parse_response( - &json!({ - "id":response_id, - "object":"chat.completion", - "created":1, - "model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":"", - "tool_calls":[ - {"id":"same","type":"function","function":{"name":"first","arguments":"{}"}}, - {"id":"same","type":"function","function":{"name":"second","arguments":"{}"}} - ] - }}] - }) - .to_string(), - ) - .unwrap() - }; - let first = encode_response(&parse("turn-1"), "alias"); - let second = encode_response(&parse("turn-2"), "alias"); - let call_ids = |response: &Value| { - response["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("call_id").and_then(Value::as_str)) - .map(str::to_string) - .collect::>() - }; - let first_ids = call_ids(&first); - let second_ids = call_ids(&second); - - assert_eq!(first_ids.len(), 2); - assert_ne!(first_ids[0], first_ids[1]); - assert_ne!(first_ids[0], second_ids[0]); - assert_eq!( - first_ids[0], - response_scoped_call_id(first["id"].as_str().unwrap(), 0) - ); - assert_eq!( - first_ids[1], - response_scoped_call_id(first["id"].as_str().unwrap(), 1) - ); - } - - #[test] - fn buffered_truncation_stays_incomplete_across_responses_encoding() { - use llm_connector::core::Protocol; - let chat = r#"{ - "id":"c-length","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"length","message":{ - "role":"assistant","content":"partial"}}], - "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let response = encode_response(&ir, "alias-model"); - assert_eq!(response["status"], "incomplete"); - assert_eq!( - response["incomplete_details"]["reason"], - "max_output_tokens" - ); - - let sse = encode_response_sse(&ir, "alias-model"); - assert!(sse.contains("event: response.incomplete")); - assert!(!sse.contains("event: response.completed")); - - let decoded = decode_response(&response.to_string()).unwrap(); - assert_eq!(decoded.choices[0].finish_reason.as_deref(), Some("length")); - let failed = json!({ - "id":"resp_failed","status":"failed", - "error":{"message":"provider failed"} - }); - assert_eq!( - decode_response(&failed.to_string()).unwrap_err(), - "provider failed" - ); - } - - fn codex_request_with_extended_tools() -> Value { - json!({ - "model": "gpt-5.4", - "input": [ - { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "use tools" }] }, - { "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_custom", - "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch" }, - { "type": "custom_tool_call_output", "call_id": "call_custom", "output": "Done!" }, - { "type": "function_call", "id": "fc_1", "call_id": "call_spawn", - "namespace": "multi_agent_v1", "name": "spawn_agent", - "arguments": "{\"task_name\":\"audit\"}" }, - { "type": "function_call_output", "call_id": "call_spawn", "output": "spawned" }, - { "type": "tool_search_call", "call_id": "call_search", "status": "completed", - "execution": "client", "arguments": { "query": "browser", "limit": 3 } }, - { "type": "tool_search_output", "call_id": "call_search", "status": "completed", - "execution": "client", "tools": [ - { "type": "custom", "name": "exec", "description": "Run JavaScript" } - ] } - ], - "tools": [ - { "type": "custom", "name": "apply_patch", "description": "Apply a patch", - "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/" } }, - { "type": "namespace", "name": "multi_agent_v1", "tools": [ - { "type": "function", "name": "spawn_agent", "description": "Spawn an agent", - "parameters": { "type": "object", "properties": { - "task_name": { "type": "string" } - }, "required": ["task_name"] } } - ] }, - { "type": "tool_search", "execution": "client", - "description": "Search deferred tools from Drive and MCP; always prefer this over MCP listing.", - "parameters": { "type": "object", "properties": { - "query": { "type": "string" }, "limit": { "type": "number" } - }, "required": ["query"], "additionalProperties": false } } - ] - }) - } - - #[test] - fn preserves_custom_namespace_and_tool_search_request_semantics() { - let (ir, context) = - decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); - let tools = ir.tools.as_ref().unwrap(); - let names = tools - .iter() - .map(|tool| tool.function.name.as_str()) - .collect::>(); - assert!(names.contains(&"apply_patch")); - assert!(names.contains(&"multi_agent_v1__spawn_agent")); - assert!(names.contains(&"tool_search")); - assert!(names.contains(&"exec")); - let search = tools - .iter() - .find(|tool| tool.function.name == "tool_search") - .unwrap(); - assert!(search - .function - .description - .as_deref() - .unwrap() - .contains("always prefer this")); - - let calls = ir - .messages - .iter() - .filter_map(|message| message.tool_calls.as_ref()) - .flatten() - .collect::>(); - let custom = calls - .iter() - .find(|call| call.function.name == "apply_patch") - .unwrap(); - assert_eq!( - serde_json::from_str::(&custom.function.arguments).unwrap()["input"], - "*** Begin Patch\n*** End Patch" - ); - assert!(calls - .iter() - .any(|call| call.function.name == "multi_agent_v1__spawn_agent")); - assert!(calls.iter().any(|call| call.function.name == "tool_search")); - assert_eq!( - context - .lookup_chat_name("multi_agent_v1__spawn_agent") - .unwrap() - .kind, - CodexToolKind::Namespace - ); - } - - #[test] - fn restores_extended_tool_types_in_buffered_response_and_sse() { - use llm_connector::core::Protocol; - - let (_, context) = - decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); - let chat = r#"{ - "id":"chatcmpl-tools","object":"chat.completion","created":1,"model":"up", - "choices":[{"index":0,"finish_reason":"tool_calls","message":{ - "role":"assistant","content":null,"reasoning_content":"pick the right tools", - "tool_calls":[ - {"id":"call_custom","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n*** End Patch\"}"}}, - {"id":"call_spawn","type":"function","function":{"name":"multi_agent_v1__spawn_agent","arguments":"{\"task_name\":\"audit\"}"}}, - {"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\",\"limit\":3}"}} - ]}}], - "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} - }"#; - let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); - let response = encode_response_with_context(&ir, "gpt-5.4", &context); - let output = response["output"].as_array().unwrap(); - - let custom = output - .iter() - .find(|item| item["type"] == "custom_tool_call") - .unwrap(); - assert_eq!(custom["name"], "apply_patch"); - assert_eq!(custom["input"], "*** Begin Patch\n*** End Patch"); - assert_eq!(custom["reasoning_content"], "pick the right tools"); - - let namespaced = output - .iter() - .find(|item| item["namespace"] == "multi_agent_v1") - .unwrap(); - assert_eq!(namespaced["type"], "function_call"); - assert_eq!(namespaced["name"], "spawn_agent"); - assert_eq!(namespaced["namespace"], "multi_agent_v1"); - - let search = output - .iter() - .find(|item| item["type"] == "tool_search_call") - .unwrap(); - assert!(search.get("id").is_none()); - assert_eq!(search["arguments"]["query"], "browser"); - assert_eq!(search["arguments"]["limit"], 3); - - let sse = encode_response_sse_with_context(&ir, "gpt-5.4", &context); - assert!(sse.contains("event: response.custom_tool_call_input.delta")); - assert!(sse.contains("event: response.custom_tool_call_input.done")); - assert!(sse.contains(r#""type":"custom_tool_call""#)); - assert!(sse.contains(r#""type":"tool_search_call""#)); - assert!(sse.contains(r#""namespace":"multi_agent_v1""#)); - } - - fn assert_valid_chat_tool_alias(alias: &str) { - assert!( - is_valid_chat_tool_name(alias), - "invalid Chat tool alias: {alias:?}" - ); - assert!(alias.is_ascii()); - assert!(alias.len() <= CHAT_TOOL_NAME_MAX_LEN); - } - - #[test] - fn aliases_illegal_tool_names_and_restores_exact_response_identities() { - let req = json!({ - "model": "gpt-5.4", - "input": [ - { "type": "function_call", "call_id": "call_fn", "name": "read file/现在", "arguments": "{}" }, - { "type": "custom_tool_call", "call_id": "call_custom", "name": "apply.patch/β", "input": "raw" }, - { "type": "function_call", "call_id": "call_ns", "namespace": "multi agent/一", - "name": "spawn.agent?", "arguments": "{}" } - ], - "tools": [ - { "type": "function", "name": "read file/现在" }, - { "type": "custom", "name": "apply.patch/β" }, - { "type": "namespace", "name": "multi agent/一", "tools": [ - { "type": "function", "name": "spawn.agent?" } - ] } - ] - }); - let (ir, context) = decode_request_with_context(&req).unwrap(); - let tools = ir.tools.as_ref().unwrap(); - assert_eq!(tools.len(), 3); - for tool in tools { - assert_valid_chat_tool_alias(&tool.function.name); - } - for call in ir - .messages - .iter() - .filter_map(|message| message.tool_calls.as_ref()) - .flatten() - { - assert_valid_chat_tool_alias(&call.function.name); - } - - let function_alias = context.chat_name_for_response_tool("read file/现在", None); - let function_item = - context.response_tool_item("fc_fn", "completed", "call_fn", &function_alias, "{}"); - assert_eq!(function_item["type"], "function_call"); - assert_eq!(function_item["name"], "read file/现在"); - - let custom_alias = context.chat_name_for_custom_tool("apply.patch/β"); - let custom_item = context.response_tool_item( - "ctc_custom", - "completed", - "call_custom", - &custom_alias, - r#"{"input":"raw"}"#, - ); - assert_eq!(custom_item["type"], "custom_tool_call"); - assert_eq!(custom_item["name"], "apply.patch/β"); - assert_eq!(custom_item["input"], "raw"); - - let namespace_alias = - context.chat_name_for_response_tool("spawn.agent?", Some("multi agent/一")); - let namespace_item = - context.response_tool_item("fc_ns", "completed", "call_ns", &namespace_alias, "{}"); - assert_eq!(namespace_item["type"], "function_call"); - assert_eq!(namespace_item["namespace"], "multi agent/一"); - assert_eq!(namespace_item["name"], "spawn.agent?"); - } - - #[test] - fn aliases_long_utf8_names_deterministically_within_64_bytes() { - let first_name = format!("读取工具-{}-甲", "界".repeat(40)); - let second_name = format!("读取工具-{}-乙", "界".repeat(40)); - let req = json!({ - "tools": [ - { "type": "function", "name": first_name }, - { "type": "function", "name": second_name } - ] - }); - let first_context = CodexToolContext::from_request(&req); - let second_context = CodexToolContext::from_request(&req); - let first_alias = first_context.chat_name_for_response_tool(&first_name, None); - let second_alias = first_context.chat_name_for_response_tool(&second_name, None); - assert_valid_chat_tool_alias(&first_alias); - assert_valid_chat_tool_alias(&second_alias); - assert_ne!(first_alias, second_alias); - assert_eq!( - first_alias, - second_context.chat_name_for_response_tool(&first_name, None) - ); - assert_eq!( - second_alias, - second_context.chat_name_for_response_tool(&second_name, None) - ); - - let restored = first_context.response_tool_item( - "fc_long", - "completed", - "call_long", - &first_alias, - "{}", - ); - assert_eq!(restored["name"], first_name); - } - - #[test] - fn keeps_colliding_function_custom_search_and_namespace_identities_distinct() { - let definitions = vec![ - json!({ "type": "function", "name": "same" }), - json!({ "type": "custom", "name": "same" }), - json!({ "type": "function", "name": "tool_search" }), - json!({ "type": "tool_search" }), - json!({ "type": "function", "name": "a__b" }), - json!({ "type": "namespace", "name": "a", "tools": [ - { "type": "function", "name": "b" } - ] }), - ]; - let req = json!({ "tools": definitions }); - let mut reversed_definitions = req["tools"].as_array().unwrap().clone(); - reversed_definitions.reverse(); - let reversed_req = json!({ "tools": reversed_definitions }); - let context = CodexToolContext::from_request(&req); - let reversed_context = CodexToolContext::from_request(&reversed_req); - let specs = vec![ - CodexToolSpec { - kind: CodexToolKind::Function, - name: "same".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Custom, - name: "same".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Function, - name: "tool_search".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::ToolSearch, - name: "tool_search".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Function, - name: "a__b".into(), - namespace: None, - }, - CodexToolSpec { - kind: CodexToolKind::Namespace, - name: "b".into(), - namespace: Some("a".into()), - }, - ]; - - assert_eq!(context.ir_tools().len(), specs.len()); - let aliases = specs - .iter() - .map(|spec| { - let alias = context.chat_name_for_spec(spec); - assert_valid_chat_tool_alias(&alias); - assert_eq!(context.lookup_chat_name(&alias), Some(spec)); - assert_eq!(alias, reversed_context.chat_name_for_spec(spec)); - alias - }) - .collect::>(); - assert_eq!(aliases.len(), specs.len()); - - let function_search_alias = context.chat_name_for_spec(&specs[2]); - let function_search = context.response_tool_item( - "fc_search", - "completed", - "call_fn_search", - &function_search_alias, - "{}", - ); - assert_eq!(function_search["type"], "function_call"); - assert_eq!(function_search["name"], "tool_search"); - - let actual_search_alias = context.chat_name_for_spec(&specs[3]); - let actual_search = context.response_tool_item( - "tsc_search", - "completed", - "call_search", - &actual_search_alias, - r#"{"query":"x"}"#, - ); - assert_eq!(actual_search["type"], "tool_search_call"); - } - - #[test] - fn custom_tool_description_omits_full_definition_and_lark_grammar() { - let req = json!({ - "tools": [{ - "type": "custom", - "name": "apply_patch", - "description": "Apply a patch to the workspace.", - "format": { - "type": "grammar", - "syntax": "lark", - "definition": "start: patch+\npatch: /.+/" - } - }] - }); - let context = CodexToolContext::from_request(&req); - let tool = &context.ir_tools()[0]; - let description = tool.function.description.as_deref().unwrap(); - assert!(description.starts_with( - "Apply a patch to the workspace.\n\nPass the custom tool's raw input unchanged in the `input` string field." - )); - assert!(description.contains("*** Add File: path")); - assert!( - description.contains("*** Begin Patch\n*** Add File: path\n+content\n*** End Patch") - ); - assert!(description.contains("never prefix either boundary marker")); - assert!(!description.contains("Original Responses custom-tool definition")); - assert!(!description.contains("definition")); - assert!(!description.contains("start: patch")); - assert!(!description.contains("lark")); - } -} diff --git a/src-tauri/src/protocol/openai_responses/decode_request.rs b/src-tauri/src/protocol/openai_responses/decode_request.rs new file mode 100644 index 0000000..316865d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/decode_request.rs @@ -0,0 +1,215 @@ +// OpenAI Responses REQUEST → IR (client side: what Codex sends the gateway with +// wire_api="responses"). + +use super::helpers::{effort_to_budget, effort_to_reasoning_effort}; +use super::history::{append_history_tool_call, response_history_tool_call, response_tool_output_text}; +use super::parts::{ + append_reasoning_content, parts_images, parts_text, reasoning_item_text, response_item_call_id, +}; +use super::tools::CodexToolContext; +use super::validate::validate_call_output_pairs; +use llm_connector::types::{ChatRequest, Message, MessageBlock, Role, ToolChoice}; +use serde_json::Value; + +/// Decode an OpenAI Responses REQUEST json (what Codex sends with wire_api="responses") into the +/// IR. Handles the full item vocabulary of an agentic history: message items (user input_text / +/// input_image, assistant output_text), all client-executed tool call/output item types, and `reasoning` +/// items — whose text is bridged onto the adjacent assistant message as `reasoning_content`, +/// because thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) reject assistant tool-call +/// history that lost its reasoning. System/developer items collapse into ONE leading system +/// message: strict providers (MiniMax) reject `role:system` anywhere but the head. Custom, +/// tool-search, and namespace tools are flattened to chat functions and restored with the returned +/// [`CodexToolContext`]. +pub fn decode_request(req: &Value) -> Result { + decode_request_with_context(req).map(|(request, _)| request) +} + +pub fn decode_request_with_context(req: &Value) -> Result<(ChatRequest, CodexToolContext), String> { + validate_call_output_pairs(req)?; + let model = req + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_context = CodexToolContext::from_request(req); + let mut messages: Vec = vec![]; + // All system text (instructions + system/developer message items), merged to the head. + let mut system_texts: Vec = vec![]; + // Reasoning waiting for the assistant message it belongs to (model output order is + // reasoning → prose → tool calls, so reasoning usually precedes its assistant message). + let mut pending_reasoning: Option = None; + + if let Some(instr) = req.get("instructions").and_then(|v| v.as_str()) { + if !instr.trim().is_empty() { + system_texts.push(instr.to_string()); + } + } + + match req.get("input") { + Some(Value::String(s)) => { + if !s.is_empty() { + messages.push(Message::text(Role::User, s.clone())); + } + } + Some(Value::Array(items)) => { + for item in items { + // Bare `{role, content}` items (no "type") are legal Responses input; treat them + // as message items. + let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or( + if item.get("role").is_some() { + "message" + } else { + "" + }, + ); + match ty { + "message" => { + let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = item.get("content").cloned().unwrap_or(Value::Null); + let text = parts_text(&content); + match role { + "assistant" => { + if !text.is_empty() { + let mut m = Message::text(Role::Assistant, text); + if let Some(r) = pending_reasoning.take() { + append_reasoning_content(&mut m, &r); + } + messages.push(m); + } + } + "system" | "developer" => { + pending_reasoning = None; + if !text.trim().is_empty() { + system_texts.push(text); + } + } + _ => { + pending_reasoning = None; + let mut blocks: Vec = vec![]; + if !text.is_empty() { + blocks.push(MessageBlock::text(text)); + } + blocks.extend(parts_images(&content)); + if !blocks.is_empty() { + messages.push(Message::new(Role::User, blocks)); + } + } + } + } + "reasoning" => { + // Belongs to the assistant step it neighbors: fold backward onto a + // directly preceding assistant message, else hold for the next one. + if let Some(text) = reasoning_item_text(item) { + match messages.last_mut() { + Some(m) if m.role == Role::Assistant => { + append_reasoning_content(m, &text) + } + _ => match &mut pending_reasoning { + Some(existing) if !existing.is_empty() => { + existing.push_str("\n\n"); + existing.push_str(text.trim()); + } + slot => *slot = Some(text.trim().to_string()), + }, + } + } + } + "function_call" | "custom_tool_call" | "tool_search_call" => { + if let Some(call) = response_history_tool_call(item, &tool_context) { + let item_reasoning = item + .get("reasoning_content") + .or_else(|| item.get("reasoning")) + .and_then(Value::as_str); + append_history_tool_call( + &mut messages, + &mut pending_reasoning, + item_reasoning, + call, + ); + } + } + "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { + pending_reasoning = None; + let id = response_item_call_id(item).unwrap_or("").to_string(); + if !id.is_empty() { + messages.push(Message::tool(response_tool_output_text(item), id)); + } + } + _ => {} + } + } + } + _ => {} + } + + if !system_texts.is_empty() { + messages.insert(0, Message::text(Role::System, system_texts.join("\n\n"))); + } + + let mut cr = ChatRequest::new(model).with_messages(messages); + if let Some(mt) = req.get("max_output_tokens").and_then(|v| v.as_u64()) { + cr = cr.with_max_tokens(mt as u32); + } + if let Some(t) = req.get("temperature").and_then(|v| v.as_f64()) { + cr = cr.with_temperature(t as f32); + } + if let Some(p) = req.get("top_p").and_then(|v| v.as_f64()) { + cr = cr.with_top_p(p as f32); + } + if req.get("stream").and_then(|v| v.as_bool()).unwrap_or(false) { + cr = cr.with_stream(true); + } + let tools = tool_context.ir_tools(); + if !tools.is_empty() { + cr = cr.with_tools(tools); + } + // tool_choice: mode strings pass through; both the flattened Responses object form + // ({type:"function",name}) and the nested Chat form pin a specific function. + if let Some(tc) = req.get("tool_choice") { + if let Some(mode) = tc.as_str() { + if matches!(mode, "auto" | "none" | "required") { + cr.tool_choice = Some(ToolChoice::Mode(mode.to_string())); + } + } else if let Some(kind) = tc.get("type").and_then(Value::as_str) { + let selected = match kind { + "function" => tc + .get("name") + .and_then(Value::as_str) + .or_else(|| { + tc.get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + }) + .map(|name| { + tool_context.chat_name_for_response_tool( + name, + tc.get("namespace").and_then(Value::as_str), + ) + }), + "custom" => tc + .get("name") + .and_then(Value::as_str) + .map(|name| tool_context.chat_name_for_custom_tool(name)), + "tool_search" => Some(tool_context.chat_name_for_tool_search()), + _ => None, + }; + if let Some(name) = selected { + cr.tool_choice = Some(ToolChoice::function(name)); + } + } + } + // Preserve both representations: Anthropic-family encoders consume the thinking budget, + // while OpenAI-compatible Chat encoders consume reasoning_effort. Higher Responses tiers do + // not exist in the connector enum, so xhigh/max/ultra intentionally collapse to High there. + if let Some(effort) = req + .get("reasoning") + .and_then(|r| r.get("effort")) + .and_then(|v| v.as_str()) + { + cr = cr + .with_enable_thinking(true) + .with_thinking_budget(effort_to_budget(effort)) + .with_reasoning_effort(effort_to_reasoning_effort(effort)); + } + Ok((cr, tool_context)) +} diff --git a/src-tauri/src/protocol/openai_responses/decode_response.rs b/src-tauri/src/protocol/openai_responses/decode_response.rs new file mode 100644 index 0000000..0b9e9fa --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/decode_response.rs @@ -0,0 +1,93 @@ +// OpenAI Responses RESPONSE → IR (provider side: parsing what a Responses upstream returned). + +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// OpenAI Responses RESPONSE (buffered) → IR. We reshape the Responses reply into an OpenAI Chat +/// completion and let the crate's parse_response build the IR — reusing its battle-tested mapping. +pub fn decode_response(text: &str) -> Result { + let v: Value = serde_json::from_str(text).map_err(|e| format!("responses parse: {}", e))?; + if v.get("status").and_then(Value::as_str) == Some("failed") { + let message = v + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("upstream Responses request failed"); + return Err(message.to_string()); + } + let output = v + .get("output") + .and_then(|o| o.as_array()) + .cloned() + .unwrap_or_default(); + + let mut content = String::new(); + let mut tool_calls: Vec = vec![]; + let mut had_tool = false; + for item in &output { + match item.get("type").and_then(|t| t.as_str()) { + Some("message") => { + if let Some(cs) = item.get("content").and_then(|c| c.as_array()) { + for c in cs { + if let Some(t) = c.get("text").and_then(|v| v.as_str()) { + content.push_str(t); + } + } + } + } + Some("function_call") => { + had_tool = true; + tool_calls.push(json!({ + "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(json!("")), + "type": "function", + "function": { + "name": item.get("name").cloned().unwrap_or(json!("")), + "arguments": item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}"), + } + })); + } + _ => {} + } + } + // fall back to the flattened output_text if no message items carried content + if content.is_empty() { + if let Some(t) = v.get("output_text").and_then(|v| v.as_str()) { + content = t.to_string(); + } + } + + let usage = v.get("usage").cloned().unwrap_or(json!({})); + let mut message = json!({ "role": "assistant", "content": content }); + if !tool_calls.is_empty() { + message["tool_calls"] = json!(tool_calls); + } + let finish_reason = if v.get("status").and_then(Value::as_str) == Some("incomplete") { + match v + .pointer("/incomplete_details/reason") + .and_then(Value::as_str) + { + Some("content_filter") => "content_filter", + _ => "length", + } + } else if had_tool { + "tool_calls" + } else { + "stop" + }; + let chat = json!({ + "id": v.get("id").cloned().unwrap_or(json!("resp")), + "object": "chat.completion", + "created": 0, + "model": v.get("model").cloned().unwrap_or(json!("")), + "choices": [{ "index": 0, "finish_reason": finish_reason, "message": message }], + "usage": { + "prompt_tokens": usage.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "completion_tokens": usage.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + "total_tokens": usage.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + } + }); + OpenAIProtocol::new("") + .parse_response(&chat.to_string()) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/protocol/openai_responses/encode_request.rs b/src-tauri/src/protocol/openai_responses/encode_request.rs new file mode 100644 index 0000000..c930003 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_request.rs @@ -0,0 +1,105 @@ +// IR → OpenAI Responses REQUEST (provider side: the gateway talking to a Responses upstream). + +use super::helpers::budget_to_effort; +use llm_connector::types::{ChatRequest, Role}; +use serde_json::{json, Value}; + +/// IR (ChatRequest) → OpenAI Responses request BODY. `outgoing_model` is the provider's real model. +pub fn encode_request(ir: &ChatRequest, outgoing_model: &str, stream: bool) -> Value { + let mut instructions: Option = None; + let mut input: Vec = vec![]; + + for m in &ir.messages { + match m.role { + Role::System => { + // Responses carries the system prompt in `instructions`, not the input array. + let t = m.content_as_text(); + if !t.trim().is_empty() { + instructions = Some(match instructions.take() { + Some(prev) => format!("{}\n{}", prev, t), + None => t, + }); + } + } + Role::Tool => { + // a tool result → function_call_output item + input.push(json!({ + "type": "function_call_output", + "call_id": m.tool_call_id.clone().unwrap_or_default(), + "output": m.content_as_text(), + })); + } + Role::User => { + let text = m.content_as_text(); + let mut content: Vec = vec![]; + if !text.is_empty() { + content.push(json!({ "type": "input_text", "text": text })); + } + for b64 in m.content_as_images_base64() { + content.push(json!({ "type": "input_image", "image_url": format!("data:image/png;base64,{}", b64) })); + } + if !content.is_empty() { + input.push(json!({ "type": "message", "role": "user", "content": content })); + } + } + Role::Assistant => { + let text = m.content_as_text(); + if !text.is_empty() { + input.push(json!({ "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": text }] })); + } + if let Some(calls) = &m.tool_calls { + for tc in calls { + input.push(json!({ + "type": "function_call", + "call_id": tc.id, + "name": tc.function.name, + "arguments": tc.function.arguments, + })); + } + } + } + } + } + + let mut body = json!({ + "model": outgoing_model, + "input": input, + "stream": stream, + }); + if let Some(instr) = instructions { + body["instructions"] = json!(instr); + } + if let Some(mt) = ir.max_tokens { + body["max_output_tokens"] = json!(mt); + } + if let Some(t) = ir.temperature { + body["temperature"] = json!(t); + } + if let Some(p) = ir.top_p { + body["top_p"] = json!(p); + } + // tools → Responses function tools (fields flattened at the item level, not nested under + // "function" like Chat Completions). + if let Some(tools) = &ir.tools { + let arr: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "name": t.function.name, + "description": t.function.description, + "parameters": t.function.parameters, + }) + }) + .collect(); + if !arr.is_empty() { + body["tools"] = json!(arr); + } + } + // Anthropic extended thinking → Responses reasoning effort. + if ir.enable_thinking == Some(true) { + body["reasoning"] = json!({ "effort": budget_to_effort(ir.thinking_budget) }); + } + body +} diff --git a/src-tauri/src/protocol/openai_responses/encode_response.rs b/src-tauri/src/protocol/openai_responses/encode_response.rs new file mode 100644 index 0000000..3b32b9d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_response.rs @@ -0,0 +1,103 @@ +// IR → OpenAI Responses RESPONSE json (client side: the buffered body Codex consumes). + +use super::helpers::{response_incomplete_reason, response_scoped_call_id}; +use super::tools::CodexToolContext; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +/// Encode the IR response back into an OpenAI Responses RESPONSE json. `client_model` is the name +/// the client asked for (so Codex sees its own model, not the upstream's). Unlike the crate's +/// chat_response_to_responses_response this maps tool_calls → function_call items and provider +/// reasoning → a reasoning item — both load-bearing for Codex's agent loop. +pub fn encode_response(resp: &ChatResponse, client_model: &str) -> Value { + encode_response_with_context(resp, client_model, &CodexToolContext::default()) +} + +pub fn encode_response_with_context( + resp: &ChatResponse, + client_model: &str, + tool_context: &CodexToolContext, +) -> Value { + let choice = resp.choices.first(); + let msg = choice.map(|c| &c.message); + // Same fallback as anthropic.rs: when a turn has tool_calls the crate parks the prose only in + // the top-level ChatResponse.content. + let text = { + let t = msg.map(|m| m.content_as_text()).unwrap_or_default(); + if t.is_empty() { + resp.content.clone() + } else { + t + } + }; + // never a constant fallback — item ids derive from this and land in client history + let rid = if resp.id.is_empty() { + super::super::uid("ccbud") + } else { + resp.id.clone() + }; + + let mut output: Vec = vec![]; + if let Some(reasoning) = msg.and_then(|m| m.reasoning_any()) { + if !reasoning.trim().is_empty() { + output.push(json!({ "type": "reasoning", "id": format!("rs_{}", rid), + "summary": [{ "type": "summary_text", "text": reasoning }] })); + } + } + if !text.is_empty() { + output.push( + json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", + "role": "assistant", + "content": [{ "type": "output_text", "annotations": [], "text": text }] }), + ); + } + if let Some(m) = msg { + if let Some(calls) = &m.tool_calls { + for (i, tc) in calls.iter().enumerate() { + let call_id = response_scoped_call_id(&format!("resp_{}", rid), i); + let item_id = tool_context.response_item_id(&tc.function.name, &rid, i); + output.push(tool_context.response_tool_item_with_reasoning( + &item_id, + "completed", + &call_id, + &tc.function.name, + &tc.function.arguments, + m.reasoning_any(), + )); + } + } + } + if output.is_empty() { + // Codex builds the turn from output items; an empty message beats an empty array. + output.push(json!({ "type": "message", "id": format!("msg_{}", rid), "status": "completed", + "role": "assistant", "content": [{ "type": "output_text", "annotations": [], "text": "" }] })); + } + + let usage = resp.usage.as_ref(); + let input_tokens = usage.map(|u| u.prompt_tokens).unwrap_or(0) as i64; + let output_tokens = usage.map(|u| u.completion_tokens).unwrap_or(0) as i64; + let total = + (usage.map(|u| u.total_tokens).unwrap_or(0) as i64).max(input_tokens + output_tokens); + let incomplete_reason = + response_incomplete_reason(choice.and_then(|choice| choice.finish_reason.as_deref())); + let mut response = json!({ + "id": format!("resp_{}", rid), + "object": "response", + "created_at": resp.created, + "status": if incomplete_reason.is_some() { "incomplete" } else { "completed" }, + "model": client_model, + "output": output, + "output_text": text, + "usage": { + "input_tokens": input_tokens, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens": output_tokens, + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": total, + } + }); + if let Some(reason) = incomplete_reason { + response["incomplete_details"] = json!({ "reason": reason }); + } + response +} diff --git a/src-tauri/src/protocol/openai_responses/encode_sse.rs b/src-tauri/src/protocol/openai_responses/encode_sse.rs new file mode 100644 index 0000000..c15b501 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/encode_sse.rs @@ -0,0 +1,149 @@ +// IR → a synthesized OpenAI Responses SSE event sequence (client side: what Codex reads when the +// upstream was translated buffered). + +use super::encode_response::encode_response_with_context; +use super::tools::CodexToolContext; +use llm_connector::types::ChatResponse; +use serde_json::{json, Value}; + +fn sse_ev(data: &Value) -> String { + let t = data + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("message"); + format!( + "event: {}\ndata: {}\n\n", + t, + serde_json::to_string(data).unwrap_or_default() + ) +} + +/// Synthesize an OpenAI Responses SSE event sequence from a finished IR response. Used when the +/// client (Codex) asked to stream but the upstream was translated buffered — the client still gets +/// a valid `response.created → output_item.added/delta/done per item → terminal event` stream, just +/// delivered at once. Codex materializes items only from `response.output_item.done`; truncations +/// terminate with `response.incomplete` instead of being mislabeled completed. +pub fn encode_response_sse(resp: &ChatResponse, client_model: &str) -> String { + encode_response_sse_with_context(resp, client_model, &CodexToolContext::default()) +} + +pub fn encode_response_sse_with_context( + resp: &ChatResponse, + client_model: &str, + tool_context: &CodexToolContext, +) -> String { + let full = encode_response_with_context(resp, client_model, tool_context); + let rid = full + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("resp_ccbud") + .to_string(); + let mut out = String::new(); + out.push_str(&sse_ev(&json!({ "type": "response.created", + "response": { "id": rid, "object": "response", "status": "in_progress", "model": client_model } }))); + + let items = full + .get("output") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + for (idx, item) in items.iter().enumerate() { + let item_id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("item") + .to_string(); + match item.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "message" => { + let text = item["content"][0]["text"].as_str().unwrap_or(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, + "item": { "type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": [] } }))); + out.push_str(&sse_ev( + &json!({ "type": "response.content_part.added", "item_id": item_id, + "output_index": idx, "content_index": 0, + "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + if !text.is_empty() { + out.push_str(&sse_ev( + &json!({ "type": "response.output_text.delta", "item_id": item_id, + "output_index": idx, "content_index": 0, "delta": text }), + )); + } + out.push_str(&sse_ev( + &json!({ "type": "response.output_text.done", "item_id": item_id, + "output_index": idx, "content_index": 0, "text": text }), + )); + out.push_str(&sse_ev( + &json!({ "type": "response.content_part.done", "item_id": item_id, + "output_index": idx, "content_index": 0, + "part": { "type": "output_text", "annotations": [], "text": text } }), + )); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "function_call" => { + let args = item + .get("arguments") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let mut added = item.clone(); + added["status"] = json!("in_progress"); + added["arguments"] = json!(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + out.push_str(&sse_ev( + &json!({ "type": "response.function_call_arguments.delta", "item_id": item_id, + "output_index": idx, "delta": args }), + )); + out.push_str(&sse_ev( + &json!({ "type": "response.function_call_arguments.done", "item_id": item_id, + "output_index": idx, "arguments": args }), + )); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "custom_tool_call" => { + let input = item.get("input").and_then(Value::as_str).unwrap_or(""); + let mut added = item.clone(); + added["status"] = json!("in_progress"); + added["input"] = json!(""); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + if !input.is_empty() { + out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.delta", + "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), + "output_index": idx, "delta": input }))); + } + out.push_str(&sse_ev(&json!({ "type": "response.custom_tool_call_input.done", + "item_id": item_id, "call_id": item.get("call_id").cloned().unwrap_or(json!("")), + "output_index": idx, "input": input }))); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "tool_search_call" => { + let mut added = item.clone(); + added["status"] = json!("in_progress"); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.added", "output_index": idx, "item": added }))); + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + "reasoning" => { + let think = item["summary"][0]["text"].as_str().unwrap_or(""); + out.push_str(&sse_ev( + &json!({ "type": "response.output_item.added", "output_index": idx, + "item": { "type": "reasoning", "id": item_id, "summary": [] } }), + )); + if !think.is_empty() { + out.push_str(&sse_ev(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": item_id, + "output_index": idx, "summary_index": 0, "delta": think }))); + } + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + _ => { + out.push_str(&sse_ev(&json!({ "type": "response.output_item.done", "output_index": idx, "item": item }))); + } + } + } + + let terminal_type = if full.get("status").and_then(Value::as_str) == Some("incomplete") { + "response.incomplete" + } else { + "response.completed" + }; + out.push_str(&sse_ev(&json!({ "type": terminal_type, "response": full }))); + out +} diff --git a/src-tauri/src/protocol/openai_responses/helpers.rs b/src-tauri/src/protocol/openai_responses/helpers.rs new file mode 100644 index 0000000..11399ee --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/helpers.rs @@ -0,0 +1,87 @@ +// Small shared conversions used by both Responses halves: call-id scoping, custom-tool argument +// wrapping, and the reasoning-effort ↔ thinking-budget mapping. + +use super::tools::CUSTOM_TOOL_INPUT_FIELD; +use llm_connector::types::ReasoningEffort; +use serde_json::{json, Value}; +use sha1::{Digest, Sha1}; + +/// Client-visible call ids must be unique even when an OpenAI-compatible upstream repeats or +/// omits its own ids for parallel calls. Scope them to the response and output position; the +/// client echoes this id, so subsequent translated history remains unambiguous. +pub(crate) fn response_scoped_call_id(response_id: &str, index: usize) -> String { + let mut digest = Sha1::new(); + digest.update(response_id.as_bytes()); + let digest = format!("{:x}", digest.finalize()); + format!("call_{}_{}", &digest[..16], index) +} + +pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String { + if arguments.trim().is_empty() { + return String::new(); + } + match serde_json::from_str::(arguments) { + Ok(Value::Object(object)) => object + .get(CUSTOM_TOOL_INPUT_FIELD) + .and_then(Value::as_str) + .unwrap_or(arguments) + .to_string(), + _ => arguments.to_string(), + } +} + +pub(super) fn wrap_custom_tool_input(input: &Value) -> String { + let input = input + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| input.to_string()); + json!({ "input": input }).to_string() +} + +pub(super) fn parse_tool_arguments_object(arguments: &str) -> Value { + if arguments.trim().is_empty() { + return json!({}); + } + serde_json::from_str::(arguments) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({ "query": arguments })) +} + +/// Map a thinking budget (tokens) to a Responses reasoning effort tier. +pub(super) fn budget_to_effort(budget: Option) -> &'static str { + match budget { + Some(b) if b >= 8192 => "high", + Some(b) if b >= 2048 => "medium", + _ => "low", + } +} + +/// Reverse of budget_to_effort: a Responses reasoning effort tier → a thinking budget (tokens). +pub(super) fn effort_to_budget(effort: &str) -> u32 { + match effort { + "ultra" | "max" => 32768, + "xhigh" => 24576, + "high" => 16384, + "medium" => 4096, + _ => 1024, // "low" / "minimal" + } +} + +pub(super) fn effort_to_reasoning_effort(effort: &str) -> ReasoningEffort { + match effort { + "medium" => ReasoningEffort::Medium, + "high" | "xhigh" | "max" | "ultra" => ReasoningEffort::High, + _ => ReasoningEffort::Low, + } +} + +pub(super) fn response_incomplete_reason(finish_reason: Option<&str>) -> Option<&'static str> { + match finish_reason { + Some("length" | "max_tokens" | "model_context_window_exceeded") => { + Some("max_output_tokens") + } + Some("content_filter") => Some("content_filter"), + _ => None, + } +} diff --git a/src-tauri/src/protocol/openai_responses/history.rs b/src-tauri/src/protocol/openai_responses/history.rs new file mode 100644 index 0000000..ae10b38 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/history.rs @@ -0,0 +1,131 @@ +// Turning Responses history items (tool calls and their outputs) into IR assistant/tool messages. + +use super::helpers::wrap_custom_tool_input; +use super::parts::{append_reasoning_content, parts_text, response_item_call_id}; +use super::tools::CodexToolContext; +use llm_connector::types::{FunctionCall, Message, Role, ToolCall}; +use serde_json::{json, Value}; + +pub(super) fn response_history_tool_call(item: &Value, context: &CodexToolContext) -> Option { + let ty = item.get("type").and_then(Value::as_str).unwrap_or(""); + let id = response_item_call_id(item).unwrap_or("").to_string(); + if id.is_empty() { + return None; + } + let (name, arguments) = match ty { + "function_call" => { + let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); + let namespace = item.get("namespace").and_then(Value::as_str); + let name = context.chat_name_for_response_tool(original_name, namespace); + let arguments = match item.get("arguments") { + Some(Value::String(arguments)) => arguments.clone(), + Some(arguments) if !arguments.is_null() => arguments.to_string(), + _ => "{}".to_string(), + }; + (name, arguments) + } + "custom_tool_call" => { + let original_name = item.get("name").and_then(Value::as_str).unwrap_or(""); + let name = context.chat_name_for_custom_tool(original_name); + let input = item + .get("input") + .cloned() + .unwrap_or(Value::String(String::new())); + (name, wrap_custom_tool_input(&input)) + } + "tool_search_call" => { + let arguments = item + .get("arguments") + .map(|value| { + if let Some(arguments) = value.as_str() { + arguments.to_string() + } else { + value.to_string() + } + }) + .unwrap_or_else(|| "{}".to_string()); + (context.chat_name_for_tool_search(), arguments) + } + _ => return None, + }; + if name.is_empty() { + return None; + } + Some(ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name, + arguments, + thought_signature: None, + }, + index: None, + thought_signature: None, + }) +} + +pub(super) fn append_history_tool_call( + messages: &mut Vec, + pending_reasoning: &mut Option, + item_reasoning: Option<&str>, + call: ToolCall, +) { + // Codex emits a turn's prose and tool calls as sibling items. Fold the calls into the trailing + // assistant message so Chat/Anthropic upstreams receive one coherent assistant turn. + match messages.last_mut() { + Some(message) if message.role == Role::Assistant => { + if let Some(reasoning) = pending_reasoning.take() { + append_reasoning_content(message, &reasoning); + } + if let Some(reasoning) = item_reasoning { + append_reasoning_content(message, reasoning); + } + message.tool_calls.get_or_insert_with(Vec::new).push(call); + } + _ => { + let mut message = Message::new(Role::Assistant, vec![]); + if let Some(reasoning) = pending_reasoning.take() { + append_reasoning_content(&mut message, &reasoning); + } + if let Some(reasoning) = item_reasoning { + append_reasoning_content(&mut message, reasoning); + } + message.tool_calls = Some(vec![call]); + messages.push(message); + } + } +} + +pub(super) fn response_tool_output_text(item: &Value) -> String { + if item.get("type").and_then(Value::as_str) == Some("tool_search_output") { + return json!({ + "status": item.get("status").cloned().unwrap_or(json!("completed")), + "execution": item.get("execution").cloned().unwrap_or(json!("client")), + "tools": item.get("tools").cloned().unwrap_or_else(|| json!([])), + }) + .to_string(); + } + match item.get("output") { + Some(Value::String(output)) => output.clone(), + Some(output @ Value::Array(_)) => { + let text = parts_text(output); + if text.is_empty() { + output.to_string() + } else { + text + } + } + Some(Value::Object(object)) => object + .get("content") + .map(|content| { + let text = parts_text(content); + if text.is_empty() { + content.to_string() + } else { + text + } + }) + .unwrap_or_else(|| Value::Object(object.clone()).to_string()), + _ => String::new(), + } +} diff --git a/src-tauri/src/protocol/openai_responses/mod.rs b/src-tauri/src/protocol/openai_responses/mod.rs new file mode 100644 index 0000000..e858f72 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/mod.rs @@ -0,0 +1,65 @@ +// OpenAI Responses (/v1/responses) codec — BOTH halves: +// +// provider-side (gateway → a Responses upstream): +// encode_request: IR → Responses REQUEST body +// decode_response: Responses RESPONSE → IR +// +// client-side (a Responses client, i.e. Codex with wire_api="responses", → gateway): +// decode_request: Responses REQUEST → IR +// encode_response / encode_response_sse: IR → Responses RESPONSE (json / synthesized SSE) +// +// The Responses API uses an item-based `input` array (role messages + function_call / +// function_call_output items), `instructions` for the system prompt, `max_output_tokens`, and a +// `reasoning.effort` knob. Its response is an `output` array of items. Tool definitions are +// FLATTENED at the item level (`{"type":"function","name",...}`), unlike Chat Completions. +// +// The client-side halves are hand-rolled rather than reusing llm-connector's +// responses_request_to_chat_request / chat_response_to_responses_response: the crate's versions +// silently DROP function_call / function_call_output / assistant output_text history items and +// tool_calls in responses, and reject the flattened tool form — all fatal for Codex, whose agent +// loop is tool calls end-to-end. +// +// Codex reads the turn's items ONLY from `response.output_item.done` SSE events (text deltas are +// cosmetic; the stream MUST end with `response.completed` carrying id + usage), so the synthesized +// stream emits the full added → delta → done sequence per item. + +mod decode_request; +mod decode_response; +mod encode_request; +mod encode_response; +mod encode_sse; +mod helpers; +mod history; +mod parts; +mod tool_collect; +mod tool_items; +mod tool_names; +mod tool_registry; +mod tools; +mod validate; +#[cfg(test)] +mod tests_aliases; +#[cfg(test)] +mod tests_collisions; +#[cfg(test)] +mod tests_extended; +#[cfg(test)] +mod tests_lite; +#[cfg(test)] +mod tests_request; +#[cfg(test)] +mod tests_response; +#[cfg(test)] +mod tests_validate; + +pub use decode_request::{decode_request, decode_request_with_context}; +pub use decode_response::decode_response; +pub use encode_request::encode_request; +pub use encode_response::{encode_response, encode_response_with_context}; +pub use encode_sse::{encode_response_sse, encode_response_sse_with_context}; +pub use tools::{CodexToolContext, CodexToolKind}; +// CodexToolSpec is part of this module's public surface (kept resolving at +// crate::protocol::openai_responses::CodexToolSpec) but only referenced internally today. +#[allow(unused_imports)] +pub use tools::CodexToolSpec; +pub(crate) use helpers::{custom_tool_input_from_chat_arguments, response_scoped_call_id}; diff --git a/src-tauri/src/protocol/openai_responses/parts.rs b/src-tauri/src/protocol/openai_responses/parts.rs new file mode 100644 index 0000000..fec6576 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/parts.rs @@ -0,0 +1,118 @@ +// Reading the content of Responses items: text / image parts, reasoning summaries, and call ids. + +use llm_connector::types::{Message, MessageBlock}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// client-side half: a Responses client (Codex) in front of the gateway +// --------------------------------------------------------------------------- + +/// Pull text out of a Responses content value (string, or array of typed parts). Accepts the +/// input_text / output_text / text / summary_text part flavors. +pub(super) fn parts_text(content: &Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + let arr = match content.as_array() { + Some(a) => a, + None => return String::new(), + }; + let mut out: Vec = vec![]; + for p in arr { + match p.get("type").and_then(|t| t.as_str()) { + Some("input_text") | Some("output_text") | Some("text") | Some("summary_text") => { + if let Some(t) = p.get("text").and_then(|v| v.as_str()) { + out.push(t.to_string()); + } + } + _ => {} + } + } + out.join("\n") +} + +/// input_image parts → IR image blocks. Codex sends `image_url` as a data URI (screenshots / +/// attached images); a plain URL is also accepted per the OpenAI spec. +pub(super) fn parts_images(content: &Value) -> Vec { + let arr = match content.as_array() { + Some(a) => a, + None => return vec![], + }; + let mut out = vec![]; + for p in arr { + if p.get("type").and_then(|t| t.as_str()) != Some("input_image") { + continue; + } + let url = p.get("image_url").and_then(|v| v.as_str()).or_else(|| { + p.get("image_url") + .and_then(|v| v.get("url")) + .and_then(|v| v.as_str()) + }); + let Some(u) = url else { continue }; + if let Some(rest) = u.strip_prefix("data:") { + if let Some((meta, data)) = rest.split_once(";base64,") { + if !data.is_empty() { + out.push(MessageBlock::image_base64( + if meta.is_empty() { "image/png" } else { meta }, + data, + )); + } + continue; + } + } + out.push(MessageBlock::image_url(u)); + } + out +} + +/// Reasoning text carried by a Responses `reasoning` item: the summary parts (what a transcoded +/// stream emits and Codex echoes back), falling back to full `content` parts. +pub(super) fn reasoning_item_text(item: &Value) -> Option { + for key in ["summary", "content"] { + let Some(parts) = item.get(key).and_then(|v| v.as_array()) else { + continue; + }; + let text = parts + .iter() + .filter_map(|p| { + p.get("text") + .and_then(|v| v.as_str()) + .or_else(|| p.as_str()) + }) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n\n"); + if !text.trim().is_empty() { + return Some(text); + } + } + None +} + +/// Append reasoning text onto a message's `reasoning_content` (the OpenAI-chat wire field). +pub(super) fn append_reasoning_content(message: &mut Message, text: &str) { + let text = text.trim(); + if text.is_empty() { + return; + } + match &mut message.reasoning_content { + // Transcoded Responses output deliberately carries the same reasoning both + // as a sibling `reasoning` item and on each call item, so either surviving + // history representation is sufficient. Do not multiply it when both (or + // several parallel calls) are present. + Some(existing) if existing.trim() == text => {} + Some(existing) if !existing.is_empty() => { + existing.push_str("\n\n"); + existing.push_str(text); + } + slot => *slot = Some(text.to_string()), + } +} + +pub(super) fn response_item_call_id(item: &Value) -> Option<&str> { + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} diff --git a/src-tauri/src/protocol/openai_responses/tests_aliases.rs b/src-tauri/src/protocol/openai_responses/tests_aliases.rs new file mode 100644 index 0000000..8865ada --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_aliases.rs @@ -0,0 +1,110 @@ +use super::tool_names::is_valid_chat_tool_name; +use super::tools::CHAT_TOOL_NAME_MAX_LEN; +use super::*; +use serde_json::json; + +pub(super) fn assert_valid_chat_tool_alias(alias: &str) { + assert!( + is_valid_chat_tool_name(alias), + "invalid Chat tool alias: {alias:?}" + ); + assert!(alias.is_ascii()); + assert!(alias.len() <= CHAT_TOOL_NAME_MAX_LEN); +} + +#[test] +fn aliases_illegal_tool_names_and_restores_exact_response_identities() { + let req = json!({ + "model": "gpt-5.4", + "input": [ + { "type": "function_call", "call_id": "call_fn", "name": "read file/现在", "arguments": "{}" }, + { "type": "custom_tool_call", "call_id": "call_custom", "name": "apply.patch/β", "input": "raw" }, + { "type": "function_call", "call_id": "call_ns", "namespace": "multi agent/一", + "name": "spawn.agent?", "arguments": "{}" } + ], + "tools": [ + { "type": "function", "name": "read file/现在" }, + { "type": "custom", "name": "apply.patch/β" }, + { "type": "namespace", "name": "multi agent/一", "tools": [ + { "type": "function", "name": "spawn.agent?" } + ] } + ] + }); + let (ir, context) = decode_request_with_context(&req).unwrap(); + let tools = ir.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 3); + for tool in tools { + assert_valid_chat_tool_alias(&tool.function.name); + } + for call in ir + .messages + .iter() + .filter_map(|message| message.tool_calls.as_ref()) + .flatten() + { + assert_valid_chat_tool_alias(&call.function.name); + } + + let function_alias = context.chat_name_for_response_tool("read file/现在", None); + let function_item = + context.response_tool_item("fc_fn", "completed", "call_fn", &function_alias, "{}"); + assert_eq!(function_item["type"], "function_call"); + assert_eq!(function_item["name"], "read file/现在"); + + let custom_alias = context.chat_name_for_custom_tool("apply.patch/β"); + let custom_item = context.response_tool_item( + "ctc_custom", + "completed", + "call_custom", + &custom_alias, + r#"{"input":"raw"}"#, + ); + assert_eq!(custom_item["type"], "custom_tool_call"); + assert_eq!(custom_item["name"], "apply.patch/β"); + assert_eq!(custom_item["input"], "raw"); + + let namespace_alias = + context.chat_name_for_response_tool("spawn.agent?", Some("multi agent/一")); + let namespace_item = + context.response_tool_item("fc_ns", "completed", "call_ns", &namespace_alias, "{}"); + assert_eq!(namespace_item["type"], "function_call"); + assert_eq!(namespace_item["namespace"], "multi agent/一"); + assert_eq!(namespace_item["name"], "spawn.agent?"); +} + +#[test] +fn aliases_long_utf8_names_deterministically_within_64_bytes() { + let first_name = format!("读取工具-{}-甲", "界".repeat(40)); + let second_name = format!("读取工具-{}-乙", "界".repeat(40)); + let req = json!({ + "tools": [ + { "type": "function", "name": first_name }, + { "type": "function", "name": second_name } + ] + }); + let first_context = CodexToolContext::from_request(&req); + let second_context = CodexToolContext::from_request(&req); + let first_alias = first_context.chat_name_for_response_tool(&first_name, None); + let second_alias = first_context.chat_name_for_response_tool(&second_name, None); + assert_valid_chat_tool_alias(&first_alias); + assert_valid_chat_tool_alias(&second_alias); + assert_ne!(first_alias, second_alias); + assert_eq!( + first_alias, + second_context.chat_name_for_response_tool(&first_name, None) + ); + assert_eq!( + second_alias, + second_context.chat_name_for_response_tool(&second_name, None) + ); + + let restored = first_context.response_tool_item( + "fc_long", + "completed", + "call_long", + &first_alias, + "{}", + ); + assert_eq!(restored["name"], first_name); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_collisions.rs b/src-tauri/src/protocol/openai_responses/tests_collisions.rs new file mode 100644 index 0000000..f55315e --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_collisions.rs @@ -0,0 +1,121 @@ +use super::tests_aliases::assert_valid_chat_tool_alias; +use super::*; +use serde_json::json; +use std::collections::HashSet; + +#[test] +fn keeps_colliding_function_custom_search_and_namespace_identities_distinct() { + let definitions = vec![ + json!({ "type": "function", "name": "same" }), + json!({ "type": "custom", "name": "same" }), + json!({ "type": "function", "name": "tool_search" }), + json!({ "type": "tool_search" }), + json!({ "type": "function", "name": "a__b" }), + json!({ "type": "namespace", "name": "a", "tools": [ + { "type": "function", "name": "b" } + ] }), + ]; + let req = json!({ "tools": definitions }); + let mut reversed_definitions = req["tools"].as_array().unwrap().clone(); + reversed_definitions.reverse(); + let reversed_req = json!({ "tools": reversed_definitions }); + let context = CodexToolContext::from_request(&req); + let reversed_context = CodexToolContext::from_request(&reversed_req); + let specs = vec![ + CodexToolSpec { + kind: CodexToolKind::Function, + name: "same".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Custom, + name: "same".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Function, + name: "tool_search".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: "tool_search".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Function, + name: "a__b".into(), + namespace: None, + }, + CodexToolSpec { + kind: CodexToolKind::Namespace, + name: "b".into(), + namespace: Some("a".into()), + }, + ]; + + assert_eq!(context.ir_tools().len(), specs.len()); + let aliases = specs + .iter() + .map(|spec| { + let alias = context.chat_name_for_spec(spec); + assert_valid_chat_tool_alias(&alias); + assert_eq!(context.lookup_chat_name(&alias), Some(spec)); + assert_eq!(alias, reversed_context.chat_name_for_spec(spec)); + alias + }) + .collect::>(); + assert_eq!(aliases.len(), specs.len()); + + let function_search_alias = context.chat_name_for_spec(&specs[2]); + let function_search = context.response_tool_item( + "fc_search", + "completed", + "call_fn_search", + &function_search_alias, + "{}", + ); + assert_eq!(function_search["type"], "function_call"); + assert_eq!(function_search["name"], "tool_search"); + + let actual_search_alias = context.chat_name_for_spec(&specs[3]); + let actual_search = context.response_tool_item( + "tsc_search", + "completed", + "call_search", + &actual_search_alias, + r#"{"query":"x"}"#, + ); + assert_eq!(actual_search["type"], "tool_search_call"); +} + +#[test] +fn custom_tool_description_omits_full_definition_and_lark_grammar() { + let req = json!({ + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch to the workspace.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: patch+\npatch: /.+/" + } + }] + }); + let context = CodexToolContext::from_request(&req); + let tool = &context.ir_tools()[0]; + let description = tool.function.description.as_deref().unwrap(); + assert!(description.starts_with( + "Apply a patch to the workspace.\n\nPass the custom tool's raw input unchanged in the `input` string field." + )); + assert!(description.contains("*** Add File: path")); + assert!( + description.contains("*** Begin Patch\n*** Add File: path\n+content\n*** End Patch") + ); + assert!(description.contains("never prefix either boundary marker")); + assert!(!description.contains("Original Responses custom-tool definition")); + assert!(!description.contains("definition")); + assert!(!description.contains("start: patch")); + assert!(!description.contains("lark")); +} diff --git a/src-tauri/src/protocol/openai_responses/tests_extended.rs b/src-tauri/src/protocol/openai_responses/tests_extended.rs new file mode 100644 index 0000000..a16588a --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_extended.rs @@ -0,0 +1,145 @@ +use super::*; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use serde_json::{json, Value}; + +fn codex_request_with_extended_tools() -> Value { + json!({ + "model": "gpt-5.4", + "input": [ + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "use tools" }] }, + { "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_custom", + "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch" }, + { "type": "custom_tool_call_output", "call_id": "call_custom", "output": "Done!" }, + { "type": "function_call", "id": "fc_1", "call_id": "call_spawn", + "namespace": "multi_agent_v1", "name": "spawn_agent", + "arguments": "{\"task_name\":\"audit\"}" }, + { "type": "function_call_output", "call_id": "call_spawn", "output": "spawned" }, + { "type": "tool_search_call", "call_id": "call_search", "status": "completed", + "execution": "client", "arguments": { "query": "browser", "limit": 3 } }, + { "type": "tool_search_output", "call_id": "call_search", "status": "completed", + "execution": "client", "tools": [ + { "type": "custom", "name": "exec", "description": "Run JavaScript" } + ] } + ], + "tools": [ + { "type": "custom", "name": "apply_patch", "description": "Apply a patch", + "format": { "type": "grammar", "syntax": "lark", "definition": "start: /.+/" } }, + { "type": "namespace", "name": "multi_agent_v1", "tools": [ + { "type": "function", "name": "spawn_agent", "description": "Spawn an agent", + "parameters": { "type": "object", "properties": { + "task_name": { "type": "string" } + }, "required": ["task_name"] } } + ] }, + { "type": "tool_search", "execution": "client", + "description": "Search deferred tools from Drive and MCP; always prefer this over MCP listing.", + "parameters": { "type": "object", "properties": { + "query": { "type": "string" }, "limit": { "type": "number" } + }, "required": ["query"], "additionalProperties": false } } + ] + }) +} + +#[test] +fn preserves_custom_namespace_and_tool_search_request_semantics() { + let (ir, context) = + decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); + let tools = ir.tools.as_ref().unwrap(); + let names = tools + .iter() + .map(|tool| tool.function.name.as_str()) + .collect::>(); + assert!(names.contains(&"apply_patch")); + assert!(names.contains(&"multi_agent_v1__spawn_agent")); + assert!(names.contains(&"tool_search")); + assert!(names.contains(&"exec")); + let search = tools + .iter() + .find(|tool| tool.function.name == "tool_search") + .unwrap(); + assert!(search + .function + .description + .as_deref() + .unwrap() + .contains("always prefer this")); + + let calls = ir + .messages + .iter() + .filter_map(|message| message.tool_calls.as_ref()) + .flatten() + .collect::>(); + let custom = calls + .iter() + .find(|call| call.function.name == "apply_patch") + .unwrap(); + assert_eq!( + serde_json::from_str::(&custom.function.arguments).unwrap()["input"], + "*** Begin Patch\n*** End Patch" + ); + assert!(calls + .iter() + .any(|call| call.function.name == "multi_agent_v1__spawn_agent")); + assert!(calls.iter().any(|call| call.function.name == "tool_search")); + assert_eq!( + context + .lookup_chat_name("multi_agent_v1__spawn_agent") + .unwrap() + .kind, + CodexToolKind::Namespace + ); +} + +#[test] +fn restores_extended_tool_types_in_buffered_response_and_sse() { + use llm_connector::core::Protocol; + + let (_, context) = + decode_request_with_context(&codex_request_with_extended_tools()).unwrap(); + let chat = r#"{ + "id":"chatcmpl-tools","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":null,"reasoning_content":"pick the right tools", + "tool_calls":[ + {"id":"call_custom","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n*** End Patch\"}"}}, + {"id":"call_spawn","type":"function","function":{"name":"multi_agent_v1__spawn_agent","arguments":"{\"task_name\":\"audit\"}"}}, + {"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\",\"limit\":3}"}} + ]}}], + "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let response = encode_response_with_context(&ir, "gpt-5.4", &context); + let output = response["output"].as_array().unwrap(); + + let custom = output + .iter() + .find(|item| item["type"] == "custom_tool_call") + .unwrap(); + assert_eq!(custom["name"], "apply_patch"); + assert_eq!(custom["input"], "*** Begin Patch\n*** End Patch"); + assert_eq!(custom["reasoning_content"], "pick the right tools"); + + let namespaced = output + .iter() + .find(|item| item["namespace"] == "multi_agent_v1") + .unwrap(); + assert_eq!(namespaced["type"], "function_call"); + assert_eq!(namespaced["name"], "spawn_agent"); + assert_eq!(namespaced["namespace"], "multi_agent_v1"); + + let search = output + .iter() + .find(|item| item["type"] == "tool_search_call") + .unwrap(); + assert!(search.get("id").is_none()); + assert_eq!(search["arguments"]["query"], "browser"); + assert_eq!(search["arguments"]["limit"], 3); + + let sse = encode_response_sse_with_context(&ir, "gpt-5.4", &context); + assert!(sse.contains("event: response.custom_tool_call_input.delta")); + assert!(sse.contains("event: response.custom_tool_call_input.done")); + assert!(sse.contains(r#""type":"custom_tool_call""#)); + assert!(sse.contains(r#""type":"tool_search_call""#)); + assert!(sse.contains(r#""namespace":"multi_agent_v1""#)); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_lite.rs b/src-tauri/src/protocol/openai_responses/tests_lite.rs new file mode 100644 index 0000000..4255096 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_lite.rs @@ -0,0 +1,148 @@ +use super::*; +use llm_connector::core::Protocol; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use llm_connector::types::ReasoningEffort; +use serde_json::{json, Value}; + +fn codex_responses_lite_request() -> Value { + json!({ + "model": "gpt-5.6-sol-pro", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Run JavaScript that can call nested Codex tools.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /[\\s\\S]+/" + } + }, + { + "type": "function", + "name": "wait", + "description": "Wait for a yielded exec cell.", + "parameters": { + "type": "object", + "properties": { "cell_id": { "type": "string" } }, + "required": ["cell_id"], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "request_user_input", + "description": "Ask the user a question.", + "parameters": { + "type": "object", + "properties": { "question": { "type": "string" } }, + "required": ["question"] + } + }, + { + "type": "namespace", + "name": "collaboration", + "tools": [{ + "type": "function", + "name": "spawn_agent", + "description": "Spawn a sub-agent.", + "parameters": { + "type": "object", + "properties": { "task_name": { "type": "string" } }, + "required": ["task_name"] + } + }] + } + ] + }, + { + "type": "message", + "role": "developer", + "content": [{ "type": "input_text", "text": "You are Codex." }] + }, + { + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "Inspect the project." }] + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": { "effort": "ultra", "summary": "none", "context": "all_turns" }, + "stream": true + }) +} + +#[test] +fn decodes_responses_lite_additional_tools_and_restores_custom_calls() { + let (ir, context) = + decode_request_with_context(&codex_responses_lite_request()).unwrap(); + + let roles = ir + .messages + .iter() + .map(|message| format!("{:?}", message.role)) + .collect::>(); + assert_eq!(roles, vec!["System", "User"]); + assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); + + let tools = ir.tools.as_ref().unwrap(); + let names = tools + .iter() + .map(|tool| tool.function.name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "exec", + "wait", + "request_user_input", + "collaboration__spawn_agent" + ] + ); + assert_eq!( + context.lookup_chat_name("exec").map(|spec| spec.kind), + Some(CodexToolKind::Custom) + ); + assert_eq!(ir.enable_thinking, Some(true)); + assert_eq!(ir.thinking_budget, Some(32768)); + assert_eq!(ir.reasoning_effort, Some(ReasoningEffort::High)); + + let chat_request = OpenAIProtocol::new("") + .build_chat_request_body(&ir) + .unwrap(); + assert_eq!(chat_request["tools"].as_array().map(Vec::len), Some(4)); + assert_eq!(chat_request["reasoning_effort"], "high"); + + let chat_response = r#"{ + "id":"chatcmpl-lite","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":null, + "tool_calls":[{"id":"call_exec","type":"function","function":{ + "name":"exec","arguments":"{\"input\":\"const result = await tools.exec_command({cmd: \\\"pwd\\\"});\"}" + }}] + }}], + "usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13} + }"#; + let response_ir = OpenAIProtocol::new("") + .parse_response(chat_response) + .unwrap(); + let response = + encode_response_with_context(&response_ir, "gpt-5.6-sol-pro", &context); + let exec = response["output"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "custom_tool_call") + .unwrap(); + assert_eq!(exec["name"], "exec"); + assert_eq!( + exec["input"], + "const result = await tools.exec_command({cmd: \"pwd\"});" + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_request.rs b/src-tauri/src/protocol/openai_responses/tests_request.rs new file mode 100644 index 0000000..030b564 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_request.rs @@ -0,0 +1,154 @@ +use super::*; +use serde_json::{json, Value}; + +#[test] +fn encodes_ir_to_responses_request() { + let anthropic = json!({ + "model": "claude-x", "max_tokens": 500, + "system": "be terse", + "tools": [{ "name": "grep", "description": "search", "input_schema": { "type": "object" } }], + "thinking": { "type": "enabled", "budget_tokens": 4096 }, + "messages": [ + { "role": "user", "content": "find foo" }, + { "role": "assistant", "content": [{ "type": "tool_use", "id": "c1", "name": "grep", "input": { "q": "foo" } }] }, + { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "c1", "content": "found" }] } + ] + }); + let ir = crate::protocol::anthropic::decode_request(&anthropic).unwrap(); + let body = encode_request(&ir, "gpt-5.5", false); + + assert_eq!(body["model"], "gpt-5.5"); + assert_eq!(body["instructions"], "be terse"); + assert_eq!(body["max_output_tokens"], 500); + assert_eq!(body["reasoning"]["effort"], "medium"); // 4096 → medium + // tools flattened (name at item level, not under "function") + assert_eq!(body["tools"][0]["type"], "function"); + assert_eq!(body["tools"][0]["name"], "grep"); + // input items: user message, function_call, function_call_output + let input = body["input"].as_array().unwrap(); + assert!(input.iter().any(|i| i["type"] == "message" + && i["role"] == "user" + && i["content"][0]["type"] == "input_text" + && i["content"][0]["text"] == "find foo")); + let fc = input.iter().find(|i| i["type"] == "function_call").unwrap(); + assert_eq!(fc["name"], "grep"); + assert_eq!(fc["call_id"], "c1"); + let fco = input + .iter() + .find(|i| i["type"] == "function_call_output") + .unwrap(); + assert_eq!(fco["call_id"], "c1"); + assert_eq!(fco["output"], "found"); +} + +#[test] +fn decodes_responses_reply_to_ir_then_anthropic() { + // A Responses reply with an assistant message + a function_call output item. + let resp = json!({ + "id": "resp_1", "object": "response", "created_at": 1, "model": "gpt-5.5", "status": "completed", + "output": [ + { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Working on it." }] }, + { "type": "function_call", "call_id": "call_7", "name": "grep", "arguments": "{\"q\":\"foo\"}" } + ], + "usage": { "input_tokens": 15, "output_tokens": 8, "total_tokens": 23 } + }); + let ir = decode_response(&resp.to_string()).unwrap(); + // reuse the Anthropic response encoder → verify the round-trip surfaces text + tool_use + usage + let out = crate::protocol::anthropic::encode_response(&ir, "claude-x"); + assert_eq!(out["stop_reason"], "tool_use"); + assert_eq!(out["usage"]["input_tokens"], 15); + assert_eq!(out["usage"]["output_tokens"], 8); + let content = out["content"].as_array().unwrap(); + assert!(content + .iter() + .any(|b| b["type"] == "text" && b["text"] == "Working on it.")); + let tu = content.iter().find(|b| b["type"] == "tool_use").unwrap(); + assert_eq!(tu["name"], "grep"); + assert_eq!(tu["input"]["q"], "foo"); +} + +// A representative Codex request (wire_api="responses"): instructions, flattened function +// tools, and an agentic history — user message, assistant prose + function_call, its +// function_call_output, and a reasoning item bridged onto the assistant turn. +fn codex_request() -> Value { + json!({ + "model": "z-ai/glm-5.2", + "instructions": "You are Codex.", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "list files" }] }, + { "type": "reasoning", "id": "rs_x", "summary": [{ "type": "summary_text", "text": "thinking…" }] }, + { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "Running ls." }] }, + { "type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"command\":[\"ls\"]}" }, + { "type": "function_call_output", "call_id": "call_1", "output": "a.txt\nb.txt" }, + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "read a.txt" }] } + ], + "tools": [ + { "type": "function", "name": "shell", "description": "run a command", "strict": false, + "parameters": { "type": "object", "properties": { "command": { "type": "array" } } } }, + { "type": "web_search" } + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": { "effort": "medium", "summary": "auto" }, + "store": false, + "stream": true + }) +} + +#[test] +fn decodes_codex_responses_request_to_ir() { + let ir = decode_request(&codex_request()).unwrap(); + let roles: Vec<_> = ir + .messages + .iter() + .map(|m| format!("{:?}", m.role)) + .collect(); + // instructions → System; assistant prose + function_call folded into ONE assistant turn; + // function_call_output → Tool; reasoning item bridged onto the assistant turn. + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); + assert_eq!(ir.messages[0].content_as_text(), "You are Codex."); + assert_eq!(ir.messages[1].content_as_text(), "list files"); + assert_eq!(ir.messages[2].content_as_text(), "Running ls."); + assert_eq!( + ir.messages[2].reasoning_content.as_deref(), + Some("thinking…") + ); + let calls = ir.messages[2].tool_calls.as_ref().unwrap(); + assert_eq!(calls[0].id, "call_1"); + assert_eq!(calls[0].function.name, "shell"); + assert!(calls[0].function.arguments.contains("ls")); + assert_eq!(ir.messages[3].tool_call_id.as_deref(), Some("call_1")); + assert_eq!(ir.messages[3].content_as_text(), "a.txt\nb.txt"); + // flattened function tool recognized, non-function web_search dropped + let tools = ir.tools.as_ref().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].function.name, "shell"); + assert_eq!(ir.stream, Some(true)); + // reasoning.effort medium → thinking budget for an Anthropic upstream + assert_eq!(ir.enable_thinking, Some(true)); + assert_eq!(ir.thinking_budget, Some(4096)); + + // The crate encodes the IR to a real Anthropic Messages body — proves the reused half + // works end-to-end (responses client → anthropic upstream). + use llm_connector::core::Protocol; + use llm_connector::protocols::adapters::anthropic::AnthropicProtocol; + let body = AnthropicProtocol::new("") + .build_chat_request_body(&ir) + .unwrap(); + let msgs = body.get("messages").and_then(|v| v.as_array()).unwrap(); + // assistant turn carries a tool_use block; tool output became a user tool_result turn + assert!(msgs.iter().any(|m| m["role"] == "assistant" + && m["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_use" && b["id"] == "call_1"))); + assert!(msgs.iter().any(|m| m["role"] == "user" + && m["content"] + .as_array() + .unwrap() + .iter() + .any(|b| b["type"] == "tool_result" && b["tool_use_id"] == "call_1"))); + assert_eq!(body["system"], "You are Codex."); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_response.rs b/src-tauri/src/protocol/openai_responses/tests_response.rs new file mode 100644 index 0000000..026007c --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_response.rs @@ -0,0 +1,156 @@ +use super::*; +use llm_connector::protocols::adapters::openai::OpenAIProtocol; +use serde_json::{json, Value}; + +#[test] +fn encodes_ir_to_responses_response_with_tool_calls() { + // A chat upstream reply with prose + a tool call → the Responses body Codex consumes. + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"chatcmpl-9","object":"chat.completion","created":1,"model":"gpt-4o", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"Checking.", + "tool_calls":[{"id":"call_9","type":"function", + "function":{"name":"shell","arguments":"{\"command\":[\"ls\"]}"}}]}}], + "usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let out = encode_response(&ir, "z-ai/glm-5.2"); + assert_eq!(out["object"], "response"); + assert_eq!(out["status"], "completed"); + assert_eq!(out["model"], "z-ai/glm-5.2"); + assert_eq!(out["usage"]["input_tokens"], 11); + assert_eq!(out["usage"]["output_tokens"], 7); + assert_eq!(out["usage"]["total_tokens"], 18); + let output = out["output"].as_array().unwrap(); + let m = output.iter().find(|i| i["type"] == "message").unwrap(); + assert_eq!(m["content"][0]["type"], "output_text"); + assert_eq!(m["content"][0]["text"], "Checking."); + let fc = output + .iter() + .find(|i| i["type"] == "function_call") + .unwrap(); + assert_eq!( + fc["call_id"], + response_scoped_call_id(out["id"].as_str().unwrap(), 0) + ); + assert_eq!(fc["name"], "shell"); + assert_eq!(fc["arguments"], "{\"command\":[\"ls\"]}"); +} + +#[test] +fn synthesized_responses_sse_carries_items_and_completed() { + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"c1","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"On it.", + "tool_calls":[{"id":"call_2","type":"function", + "function":{"name":"apply_patch","arguments":"{\"p\":1}"}}]}}], + "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let sse = encode_response_sse(&ir, "alias-model"); + // ordered: created → message item events → function_call item events → completed + let created = sse.find("\"type\":\"response.created\"").unwrap(); + let item_done = sse.find("response.output_item.done").unwrap(); + let completed = sse.find("\"type\":\"response.completed\"").unwrap(); + assert!(created < item_done && item_done < completed); + // Codex reads items exclusively from output_item.done: both items must appear there. + assert!(sse.contains(r#""delta":"On it.""#)); + assert!(sse.contains(&format!( + r#""call_id":"{}""#, + response_scoped_call_id("resp_c1", 0) + ))); + assert!(sse.contains(r#""name":"apply_patch""#)); + assert!(sse.contains(r#""arguments":"{\"p\":1}""#)); + // completed carries id + usage (codex errors without them) + assert!(sse.contains(r#""input_tokens":5"#)); + assert!(sse.contains(r#""output_tokens":3"#)); + assert!(sse.contains(r#""id":"resp_c1""#)); +} + +#[test] +fn buffered_duplicate_upstream_call_ids_become_unique_and_response_scoped() { + use llm_connector::core::Protocol; + + let parse = |response_id: &str| { + OpenAIProtocol::new("") + .parse_response( + &json!({ + "id":response_id, + "object":"chat.completion", + "created":1, + "model":"up", + "choices":[{"index":0,"finish_reason":"tool_calls","message":{ + "role":"assistant","content":"", + "tool_calls":[ + {"id":"same","type":"function","function":{"name":"first","arguments":"{}"}}, + {"id":"same","type":"function","function":{"name":"second","arguments":"{}"}} + ] + }}] + }) + .to_string(), + ) + .unwrap() + }; + let first = encode_response(&parse("turn-1"), "alias"); + let second = encode_response(&parse("turn-2"), "alias"); + let call_ids = |response: &Value| { + response["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("call_id").and_then(Value::as_str)) + .map(str::to_string) + .collect::>() + }; + let first_ids = call_ids(&first); + let second_ids = call_ids(&second); + + assert_eq!(first_ids.len(), 2); + assert_ne!(first_ids[0], first_ids[1]); + assert_ne!(first_ids[0], second_ids[0]); + assert_eq!( + first_ids[0], + response_scoped_call_id(first["id"].as_str().unwrap(), 0) + ); + assert_eq!( + first_ids[1], + response_scoped_call_id(first["id"].as_str().unwrap(), 1) + ); +} + +#[test] +fn buffered_truncation_stays_incomplete_across_responses_encoding() { + use llm_connector::core::Protocol; + let chat = r#"{ + "id":"c-length","object":"chat.completion","created":1,"model":"up", + "choices":[{"index":0,"finish_reason":"length","message":{ + "role":"assistant","content":"partial"}}], + "usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8} + }"#; + let ir = OpenAIProtocol::new("").parse_response(chat).unwrap(); + let response = encode_response(&ir, "alias-model"); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + + let sse = encode_response_sse(&ir, "alias-model"); + assert!(sse.contains("event: response.incomplete")); + assert!(!sse.contains("event: response.completed")); + + let decoded = decode_response(&response.to_string()).unwrap(); + assert_eq!(decoded.choices[0].finish_reason.as_deref(), Some("length")); + let failed = json!({ + "id":"resp_failed","status":"failed", + "error":{"message":"provider failed"} + }); + assert_eq!( + decode_response(&failed.to_string()).unwrap_err(), + "provider failed" + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tests_validate.rs b/src-tauri/src/protocol/openai_responses/tests_validate.rs new file mode 100644 index 0000000..34c5b89 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tests_validate.rs @@ -0,0 +1,146 @@ +use super::*; +use llm_connector::types::Role; +use serde_json::json; + +#[test] +fn rejects_call_outputs_without_a_preceding_matching_call() { + for input in [ + json!([{ + "type":"function_call_output","call_id":"missing_call","output":"done" + }]), + json!([ + {"type":"custom_tool_call_output","call_id":"late_call","output":"done"}, + {"type":"custom_tool_call","call_id":"late_call","name":"apply_patch","input":"patch"} + ]), + json!([{"type":"tool_search_output","tools":[]}]), + json!([ + {"type":"function_call","call_id":"duplicate_output","name":"shell","arguments":"{}"}, + {"type":"function_call_output","call_id":"duplicate_output","output":"one"}, + {"type":"function_call_output","call_id":"duplicate_output","output":"two"} + ]), + json!([ + {"type":"function_call","call_id":"wrong_kind","name":"shell","arguments":"{}"}, + {"type":"custom_tool_call_output","call_id":"wrong_kind","output":"done"} + ]), + json!([ + {"type":"function_call","call_id":"stale_call","name":"shell","arguments":"{}"}, + {"type":"message","role":"user","content":"start another turn"}, + {"type":"function_call_output","call_id":"stale_call","output":"done"} + ]), + json!([ + {"type":"function_call","call_id":"stale_bare","name":"shell","arguments":"{}"}, + {"role":"user","content":"start another turn"}, + {"type":"function_call_output","call_id":"stale_bare","output":"done"} + ]), + ] { + let error = decode_request(&json!({ "model":"m", "input":input })).unwrap_err(); + assert!( + error.contains("no preceding matching call"), + "unexpected validation error: {error}" + ); + } +} + +#[test] +fn rejects_ambiguous_duplicate_call_ids_and_interleaved_call_groups() { + for input in [ + json!([ + {"type":"function_call","call_id":"same","name":"first","arguments":"{}"}, + {"type":"custom_tool_call","call_id":"same","name":"second","input":"x"} + ]), + json!([ + {"type":"function_call","call_id":"c1","name":"first","arguments":"{}"}, + {"type":"function_call","call_id":"c2","name":"second","arguments":"{}"}, + {"type":"function_call_output","call_id":"c1","output":"one"}, + {"type":"function_call","call_id":"c3","name":"third","arguments":"{}"} + ]), + json!([ + {"type":"function_call","call_id":"reused","name":"first","arguments":"{}"}, + {"type":"function_call_output","call_id":"reused","output":"one"}, + {"role":"user","content":"next turn"}, + {"type":"function_call","call_id":"reused","name":"second","arguments":"{}"} + ]), + ] { + let error = decode_request(&json!({"model":"m","input":input})).unwrap_err(); + assert!( + error.contains("ambiguous"), + "unexpected validation error: {error}" + ); + } +} + +// Thinking chat upstreams reject tool-call history without reasoning, and MiniMax rejects +// `role:system` anywhere but the head — the decoder must bridge reasoning items onto their +// assistant turn and merge all system/developer text into one leading system message. +#[test] +fn bridges_reasoning_and_collapses_system_into_head() { + let req = json!({ + "model": "m", + "instructions": "You are Codex.", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "run ls" }] }, + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "need to list" }] }, + { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "a.txt" }, + { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "be careful" }] }, + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "again" }] } + ] + }); + let ir = decode_request(&req).unwrap(); + let roles: Vec<_> = ir + .messages + .iter() + .map(|m| format!("{:?}", m.role)) + .collect(); + // exactly ONE system message, at the head, carrying instructions + the developer item + assert_eq!(roles, vec!["System", "User", "Assistant", "Tool", "User"]); + assert_eq!( + ir.messages[0].content_as_text(), + "You are Codex.\n\nbe careful" + ); + // the reasoning that produced the tool call rides the tool-call assistant turn + assert_eq!( + ir.messages[2].reasoning_content.as_deref(), + Some("need to list") + ); + assert_eq!(ir.messages[2].tool_calls.as_ref().unwrap()[0].id, "c1"); +} + +#[test] +fn bridges_call_item_reasoning_without_duplicate_parallel_copies() { + let req = json!({ + "model":"m", + "input":[ + {"type":"reasoning","summary":[{"type":"summary_text","text":"inspect both"}]}, + {"type":"function_call","call_id":"c1","name":"first","arguments":"{}", + "reasoning_content":"inspect both"}, + {"type":"function_call","call_id":"c2","name":"second","arguments":"{}", + "reasoning_content":"inspect both"}, + {"type":"function_call_output","call_id":"c1","output":"one"}, + {"type":"function_call_output","call_id":"c2","output":"two"} + ] + }); + + let ir = decode_request(&req).unwrap(); + assert_eq!(ir.messages[0].role, Role::Assistant); + assert_eq!( + ir.messages[0].reasoning_content.as_deref(), + Some("inspect both") + ); + assert_eq!(ir.messages[0].tool_calls.as_ref().unwrap().len(), 2); + + let item_only = json!({ + "model":"m", + "input":[ + {"type":"function_call","call_id":"c3","name":"third","arguments":"{}", + "reasoning_content":"cached reasoning"}, + {"type":"function_call_output","call_id":"c3","output":"three"} + ] + }); + let ir = decode_request(&item_only).unwrap(); + assert_eq!( + ir.messages[0].reasoning_content.as_deref(), + Some("cached reasoning") + ); +} + diff --git a/src-tauri/src/protocol/openai_responses/tool_collect.rs b/src-tauri/src/protocol/openai_responses/tool_collect.rs new file mode 100644 index 0000000..948bc15 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_collect.rs @@ -0,0 +1,157 @@ +// Walking a Codex Responses request for tool definitions and tool identities that are not in the +// top-level `tools` array (Responses Lite `additional_tools`, `tool_search_output`, history calls). + +use super::tools::{CodexToolContext, CodexToolKind, CodexToolSpec, TOOL_SEARCH_CHAT_NAME}; +use serde_json::Value; + +pub(super) fn collect_additional_tools(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_additional_tools(item, context); + } + } + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("additional_tools") { + if let Some(tools) = object.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + } + for child in object.values() { + collect_additional_tools(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_tool_search_output_tools(item, context); + } + } + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("tool_search_output") { + if let Some(tools) = object.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + } + for child in object.values() { + collect_tool_search_output_tools(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_response_tool_call_identities(value: &Value, context: &mut CodexToolContext) { + match value { + Value::Array(items) => { + for item in items { + collect_response_tool_call_identities(item, context); + } + } + Value::Object(object) => { + let spec = match object.get("type").and_then(Value::as_str) { + Some("function_call") => object + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| { + let namespace = object + .get("namespace") + .and_then(Value::as_str) + .filter(|namespace| !namespace.is_empty()); + CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + } + }), + Some("custom_tool_call") => object + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }), + Some("tool_search_call") => Some(CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }), + _ => None, + }; + if let Some(spec) = spec { + context.register_tool_identity(spec); + } + for child in object.values() { + collect_response_tool_call_identities(child, context); + } + } + _ => {} + } +} + +pub(super) fn collect_tool_choice_identity(tool_choice: Option<&Value>, context: &mut CodexToolContext) { + let Some(tool_choice) = tool_choice.filter(|value| value.is_object()) else { + return; + }; + let spec = match tool_choice.get("type").and_then(Value::as_str) { + Some("function") => tool_choice + .get("name") + .and_then(Value::as_str) + .or_else(|| { + tool_choice + .get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + }) + .filter(|name| !name.trim().is_empty()) + .map(|name| { + let namespace = tool_choice + .get("namespace") + .and_then(Value::as_str) + .filter(|namespace| !namespace.is_empty()); + CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + } + }), + Some("custom") => tool_choice + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .map(|name| CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }), + Some("tool_search") => Some(CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }), + _ => None, + }; + if let Some(spec) = spec { + context.register_tool_identity(spec); + } +} diff --git a/src-tauri/src/protocol/openai_responses/tool_items.rs b/src-tauri/src/protocol/openai_responses/tool_items.rs new file mode 100644 index 0000000..51da8e3 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_items.rs @@ -0,0 +1,93 @@ +// Rebuilding Responses output items from a chat-shaped tool call: the item id prefix and the +// exact `function_call` / `custom_tool_call` / `tool_search_call` shape Codex expects back. + +use super::helpers::{custom_tool_input_from_chat_arguments, parse_tool_arguments_object}; +use super::tools::{CodexToolContext, CodexToolKind}; +use serde_json::{json, Value}; + +impl CodexToolContext { + pub(crate) fn response_item_id( + &self, + chat_name: &str, + response_id: &str, + index: usize, + ) -> String { + let prefix = match self.kind_for_chat_name(chat_name) { + CodexToolKind::Custom => "ctc", + CodexToolKind::ToolSearch => "tsc", + CodexToolKind::Function | CodexToolKind::Namespace => "fc", + }; + format!( + "{}_{}_{}", + prefix, + response_id.trim_start_matches("resp_"), + index + ) + } + + pub(crate) fn response_tool_item( + &self, + item_id: &str, + status: &str, + call_id: &str, + chat_name: &str, + arguments: &str, + ) -> Value { + self.response_tool_item_with_reasoning(item_id, status, call_id, chat_name, arguments, None) + } + + pub(crate) fn response_tool_item_with_reasoning( + &self, + item_id: &str, + status: &str, + call_id: &str, + chat_name: &str, + arguments: &str, + reasoning: Option<&str>, + ) -> Value { + let mut item = match self.lookup_chat_name(chat_name) { + Some(spec) if spec.kind == CodexToolKind::Custom => json!({ + "type": "custom_tool_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": spec.name, + "input": custom_tool_input_from_chat_arguments(arguments), + }), + Some(spec) if spec.kind == CodexToolKind::ToolSearch => json!({ + "type": "tool_search_call", + "status": status, + "call_id": call_id, + "execution": "client", + "arguments": parse_tool_arguments_object(arguments), + }), + Some(spec) => { + let mut item = json!({ + "type": "function_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": spec.name, + "arguments": if arguments.is_empty() { "{}" } else { arguments }, + }); + if let Some(namespace) = spec.namespace.as_deref().filter(|value| !value.is_empty()) + { + item["namespace"] = json!(namespace); + } + item + } + None => json!({ + "type": "function_call", + "id": item_id, + "status": status, + "call_id": call_id, + "name": chat_name, + "arguments": if arguments.is_empty() { "{}" } else { arguments }, + }), + }; + if let Some(reasoning) = reasoning.map(str::trim).filter(|value| !value.is_empty()) { + item["reasoning_content"] = json!(reasoning); + } + item + } +} diff --git a/src-tauri/src/protocol/openai_responses/tool_names.rs b/src-tauri/src/protocol/openai_responses/tool_names.rs new file mode 100644 index 0000000..26f7889 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_names.rs @@ -0,0 +1,140 @@ +// Chat tool-name allocation: keep the Responses identity recoverable while satisfying the +// OpenAI Chat name grammar (<=64 chars, alphanumeric/_/-), aliasing collisions deterministically. + +use super::tools::{ + CodexToolContext, CodexToolKind, CodexToolSpec, CHAT_TOOL_NAME_HASH_LEN, + CHAT_TOOL_NAME_MAX_LEN, +}; +use sha1::{Digest, Sha1}; + +impl CodexToolContext { + pub(super) fn reserve_chat_name(&mut self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + let chat_name = if is_valid_chat_tool_name(&preferred) + && !self.colliding_preferred_names.contains(&preferred) + { + if let Some(existing_spec) = self.chat_name_to_spec.get(&preferred).cloned() { + self.colliding_preferred_names.insert(preferred.clone()); + if preferred_chat_tool_name(&existing_spec) == preferred { + self.move_identity_to_hashed_alias(&existing_spec, &preferred); + } + self.allocate_hashed_chat_name(spec) + } else { + preferred + } + } else { + self.allocate_hashed_chat_name(spec) + }; + self.seen_chat_names.insert(chat_name.clone()); + self.chat_name_to_spec + .insert(chat_name.clone(), spec.clone()); + self.spec_to_chat_name + .insert(spec.clone(), chat_name.clone()); + chat_name + } + + fn move_identity_to_hashed_alias(&mut self, spec: &CodexToolSpec, old_name: &str) { + self.seen_chat_names.remove(old_name); + self.chat_name_to_spec.remove(old_name); + let new_name = self.allocate_hashed_chat_name(spec); + self.seen_chat_names.insert(new_name.clone()); + self.chat_name_to_spec + .insert(new_name.clone(), spec.clone()); + self.spec_to_chat_name + .insert(spec.clone(), new_name.clone()); + if let Some(tool) = self + .ir_tools + .iter_mut() + .find(|tool| tool.function.name == old_name) + { + tool.function.name = new_name; + } + } + + pub(super) fn allocate_chat_name(&self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + if is_valid_chat_tool_name(&preferred) + && !self.colliding_preferred_names.contains(&preferred) + && !self.seen_chat_names.contains(&preferred) + { + return preferred; + } + + self.allocate_hashed_chat_name(spec) + } + + fn allocate_hashed_chat_name(&self, spec: &CodexToolSpec) -> String { + let preferred = preferred_chat_tool_name(spec); + let digest = tool_identity_digest(spec); + for attempt in 0_u64.. { + let candidate = hashed_chat_tool_name(&preferred, &digest, attempt); + if !self.seen_chat_names.contains(&candidate) { + return candidate; + } + } + unreachable!("the finite request cannot exhaust all valid Chat tool aliases") + } +} + +fn preferred_chat_tool_name(spec: &CodexToolSpec) -> String { + match spec.namespace.as_deref() { + Some(namespace) if !namespace.is_empty() => format!("{namespace}__{}", spec.name), + _ => spec.name.clone(), + } +} + +pub(super) fn is_valid_chat_tool_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= CHAT_TOOL_NAME_MAX_LEN + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn sanitized_chat_tool_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len().min(CHAT_TOOL_NAME_MAX_LEN)); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') { + sanitized.push(ch); + } else { + sanitized.push('_'); + } + } + if sanitized.is_empty() { + sanitized.push_str("tool"); + } + sanitized +} + +fn tool_identity_digest(spec: &CodexToolSpec) -> String { + let mut digest = Sha1::new(); + digest.update([match spec.kind { + CodexToolKind::Function => 0, + CodexToolKind::Namespace => 1, + CodexToolKind::Custom => 2, + CodexToolKind::ToolSearch => 3, + }]); + match spec.namespace.as_deref() { + Some(namespace) => { + digest.update([1]); + digest.update((namespace.len() as u64).to_be_bytes()); + digest.update(namespace.as_bytes()); + } + None => digest.update([0]), + } + digest.update((spec.name.len() as u64).to_be_bytes()); + digest.update(spec.name.as_bytes()); + format!("{:x}", digest.finalize()) +} + +fn hashed_chat_tool_name(preferred: &str, digest: &str, attempt: u64) -> String { + let suffix = if attempt == 0 { + format!("__{}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) + } else { + format!("__{}_{attempt}", &digest[..CHAT_TOOL_NAME_HASH_LEN]) + }; + let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); + let mut prefix = sanitized_chat_tool_name(preferred); + prefix.truncate(prefix_len); + format!("{prefix}{suffix}") +} diff --git a/src-tauri/src/protocol/openai_responses/tool_registry.rs b/src-tauri/src/protocol/openai_responses/tool_registry.rs new file mode 100644 index 0000000..201d1a5 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tool_registry.rs @@ -0,0 +1,185 @@ +// Registering Responses tool definitions (function / custom / tool_search / namespace) as the +// flat chat functions an OpenAI Chat upstream accepts. + +use super::tools::{ + CodexToolContext, CodexToolKind, CodexToolSpec, APPLY_PATCH_CHAT_INSTRUCTION, + CUSTOM_TOOL_INPUT_FIELD, CUSTOM_TOOL_RAW_INPUT_INSTRUCTION, TOOL_SEARCH_CHAT_NAME, +}; +use llm_connector::types::Tool; +use serde_json::{json, Value}; + +impl CodexToolContext { + pub(super) fn add_response_tool(&mut self, tool: &Value) { + match tool { + Value::String(name) => self.add_custom_tool(&json!({ + "type": "custom", + "name": name, + })), + Value::Object(_) => match tool.get("type").and_then(Value::as_str) { + Some("function") | None => self.add_function_tool(tool, None), + Some("custom") => self.add_custom_tool(tool), + Some("tool_search") => self.add_tool_search_tool(tool), + Some("namespace") => self.add_namespace_tool(tool), + _ => {} + }, + _ => {} + } + } + + fn add_function_tool(&mut self, tool: &Value, namespace: Option<&str>) { + let function = tool + .get("function") + .filter(|value| value.is_object()) + .unwrap_or(tool); + let Some(name) = function.get("name").and_then(Value::as_str) else { + return; + }; + if name.trim().is_empty() { + return; + } + let description = function + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string); + let parameters = normalize_function_parameters(function.get("parameters")); + let spec = CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + }; + self.add_chat_tool(spec, description, parameters); + } + + fn add_custom_tool(&mut self, tool: &Value) { + let Some(name) = tool.get("name").and_then(Value::as_str) else { + return; + }; + if name.trim().is_empty() { + return; + } + let mut description = tool + .get("description") + .and_then(Value::as_str) + .map(|description| format!("{description}\n\n{CUSTOM_TOOL_RAW_INPUT_INSTRUCTION}")) + .unwrap_or_else(|| CUSTOM_TOOL_RAW_INPUT_INSTRUCTION.to_string()); + if name == "apply_patch" { + description.push_str("\n\n"); + description.push_str(APPLY_PATCH_CHAT_INSTRUCTION); + } + let parameters = json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Raw string input for the original custom tool. Preserve formatting exactly." + } + }, + "required": [CUSTOM_TOOL_INPUT_FIELD], + "additionalProperties": false, + }); + self.add_chat_tool( + CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }, + Some(description), + parameters, + ); + } + + fn add_tool_search_tool(&mut self, tool: &Value) { + let description = tool + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| { + "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task." + .to_string() + }); + let parameters = if tool.get("parameters").is_some_and(Value::is_object) { + normalize_function_parameters(tool.get("parameters")) + } else { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "required": ["query"], + "additionalProperties": false, + }) + }; + self.add_chat_tool( + CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }, + Some(description), + parameters, + ); + } + + fn add_namespace_tool(&mut self, tool: &Value) { + let Some(namespace) = tool.get("name").and_then(Value::as_str) else { + return; + }; + if namespace.trim().is_empty() { + return; + } + let Some(children) = tool + .get("tools") + .or_else(|| tool.get("children")) + .and_then(Value::as_array) + else { + return; + }; + for child in children { + if child.get("type").and_then(Value::as_str) == Some("function") { + self.add_function_tool(child, Some(namespace)); + } + } + } + + fn add_chat_tool( + &mut self, + spec: CodexToolSpec, + description: Option, + parameters: Value, + ) { + if self.spec_to_chat_name.contains_key(&spec) { + return; + } + let chat_name = self.reserve_chat_name(&spec); + self.ir_tools + .push(Tool::function(chat_name.clone(), description, parameters)); + } + + pub(super) fn register_tool_identity(&mut self, spec: CodexToolSpec) { + if self.spec_to_chat_name.contains_key(&spec) { + return; + } + self.reserve_chat_name(&spec); + } +} + +fn normalize_function_parameters(parameters: Option<&Value>) -> Value { + let mut parameters = parameters + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })); + if let Some(object) = parameters.as_object_mut() { + if object.get("type").and_then(Value::as_str) != Some("object") { + object.insert("type".to_string(), json!("object")); + } + object + .entry("properties".to_string()) + .or_insert_with(|| json!({})); + } + parameters +} diff --git a/src-tauri/src/protocol/openai_responses/tools.rs b/src-tauri/src/protocol/openai_responses/tools.rs new file mode 100644 index 0000000..f36091d --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/tools.rs @@ -0,0 +1,125 @@ +// Codex tool metadata shared by the Responses request decoder and the Responses response +// encoders: the tool identity types and the request-scoped registry built from a Codex request. + +use super::tool_collect::{ + collect_additional_tools, collect_response_tool_call_identities, collect_tool_choice_identity, + collect_tool_search_output_tools, +}; +use llm_connector::types::Tool; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +pub(super) const CUSTOM_TOOL_INPUT_FIELD: &str = "input"; +pub(super) const CUSTOM_TOOL_RAW_INPUT_INSTRUCTION: &str = + "Pass the custom tool's raw input unchanged in the `input` string field."; +pub(super) const APPLY_PATCH_CHAT_INSTRUCTION: &str = "For apply_patch, the first line must be `*** Begin Patch` and the final line must be an unprefixed `*** End Patch`. Exact Add File skeleton:\n*** Begin Patch\n*** Add File: path\n+content\n*** End Patch\nPrefix every added file-content line with `+`, but never prefix either boundary marker. For updates, use `*** Update File: path` with an `@@` context hunk and ` `, `-`, or `+` line prefixes; for deletion, use `*** Delete File: path`."; +pub(super) const TOOL_SEARCH_CHAT_NAME: &str = "tool_search"; +pub(super) const CHAT_TOOL_NAME_MAX_LEN: usize = 64; +pub(super) const CHAT_TOOL_NAME_HASH_LEN: usize = 12; + +/// The Responses tool shape that a chat-compatible upstream is standing in for. +/// +/// OpenAI Chat only has flat JSON-schema functions, while current Codex requests also carry +/// freeform custom tools, tool search, and namespace tools. The translation layer flattens all of +/// them to chat functions, then uses this metadata to restore the exact Responses item type on the +/// way back to Codex. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CodexToolKind { + Function, + Namespace, + Custom, + ToolSearch, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CodexToolSpec { + pub kind: CodexToolKind, + pub name: String, + pub namespace: Option, +} + +/// Request-scoped tool metadata used by both buffered and streaming Responses encoders. +/// +/// Build this from the original Codex request before decoding it to the connector IR. Loaded tools +/// embedded in `tool_search_output` history are included, so subsequent calls can be translated +/// even when their definitions are not repeated in the top-level `tools` array. +#[derive(Clone, Debug, Default)] +pub struct CodexToolContext { + pub(super) ir_tools: Vec, + pub(super) seen_chat_names: HashSet, + pub(super) colliding_preferred_names: HashSet, + pub(super) chat_name_to_spec: HashMap, + pub(super) spec_to_chat_name: HashMap, +} + +impl CodexToolContext { + pub fn from_request(req: &Value) -> Self { + let mut context = Self::default(); + if let Some(tools) = req.get("tools").and_then(Value::as_array) { + for tool in tools { + context.add_response_tool(tool); + } + } + if let Some(input) = req.get("input") { + // Codex Responses Lite (used by gpt-5.6-sol*) moves the complete tool registry out + // of the top-level `tools` field and into an `additional_tools` developer item. Treat + // those definitions exactly like top-level tools; the item itself is request metadata, + // not a chat message. + collect_additional_tools(input, &mut context); + collect_tool_search_output_tools(input, &mut context); + collect_response_tool_call_identities(input, &mut context); + } + collect_tool_choice_identity(req.get("tool_choice"), &mut context); + context + } + + pub fn ir_tools(&self) -> Vec { + self.ir_tools.clone() + } + + pub fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { + self.chat_name_to_spec.get(chat_name) + } + + pub fn kind_for_chat_name(&self, chat_name: &str) -> CodexToolKind { + self.lookup_chat_name(chat_name) + .map(|spec| spec.kind) + .unwrap_or(CodexToolKind::Function) + } + + pub fn chat_name_for_response_tool(&self, name: &str, namespace: Option<&str>) -> String { + let namespace = namespace.filter(|value| !value.is_empty()); + self.chat_name_for_spec(&CodexToolSpec { + kind: if namespace.is_some() { + CodexToolKind::Namespace + } else { + CodexToolKind::Function + }, + name: name.to_string(), + namespace: namespace.map(ToString::to_string), + }) + } + + pub(super) fn chat_name_for_custom_tool(&self, name: &str) -> String { + self.chat_name_for_spec(&CodexToolSpec { + kind: CodexToolKind::Custom, + name: name.to_string(), + namespace: None, + }) + } + + pub(super) fn chat_name_for_tool_search(&self) -> String { + self.chat_name_for_spec(&CodexToolSpec { + kind: CodexToolKind::ToolSearch, + name: TOOL_SEARCH_CHAT_NAME.to_string(), + namespace: None, + }) + } + + pub(super) fn chat_name_for_spec(&self, spec: &CodexToolSpec) -> String { + self.spec_to_chat_name + .get(spec) + .cloned() + .unwrap_or_else(|| self.allocate_chat_name(spec)) + } +} diff --git a/src-tauri/src/protocol/openai_responses/validate.rs b/src-tauri/src/protocol/openai_responses/validate.rs new file mode 100644 index 0000000..e353a98 --- /dev/null +++ b/src-tauri/src/protocol/openai_responses/validate.rs @@ -0,0 +1,135 @@ +// Structural validation of a Responses request's call/output pairing, before any translation. + +use super::parts::response_item_call_id; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseCallKind { + Function, + Custom, + ToolSearch, +} + +impl ResponseCallKind { + fn call_item(item_type: &str) -> Option { + match item_type { + "function_call" => Some(Self::Function), + "custom_tool_call" => Some(Self::Custom), + "tool_search_call" => Some(Self::ToolSearch), + _ => None, + } + } + + fn output_item(item_type: &str) -> Option { + match item_type { + "function_call_output" => Some(Self::Function), + "custom_tool_call_output" => Some(Self::Custom), + "tool_search_output" => Some(Self::ToolSearch), + _ => None, + } + } + + fn label(self) -> &'static str { + match self { + Self::Function => "function", + Self::Custom => "custom tool", + Self::ToolSearch => "tool search", + } + } +} + +pub(super) fn validate_call_output_pairs(req: &Value) -> Result<(), String> { + let items = match req.get("input") { + Some(Value::Array(items)) => items.iter().collect::>(), + Some(Value::Object(_)) => req.get("input").into_iter().collect::>(), + _ => return Ok(()), + }; + let mut calls = HashMap::::new(); + let mut seen_call_ids = HashSet::new(); + let mut unresolved = Vec::new(); + let mut consumed_in_group = false; + for item in items { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + if item + .get("role") + .and_then(Value::as_str) + .is_some_and(|role| matches!(role, "user" | "system" | "developer")) + { + // A new client-authored turn closes the window in which an older call can be + // satisfied. Outputs after this point are stale/out of order. + calls.clear(); + consumed_in_group = false; + continue; + } + + if let Some(kind) = ResponseCallKind::call_item(item_type) { + let Some(call_id) = response_item_call_id(item) else { + return Err("Responses call item is missing call_id".to_string()); + }; + if consumed_in_group && !calls.is_empty() { + return Err(format!( + "Responses call order is ambiguous: new call {call_id} appeared before every preceding call produced an output" + )); + } + if calls.is_empty() { + consumed_in_group = false; + } + if !seen_call_ids.insert(call_id.to_string()) + || calls.insert(call_id.to_string(), kind).is_some() + { + return Err(format!( + "Responses call id is ambiguous because it appears more than once before output: {call_id}" + )); + } + continue; + } + + if let Some(output_kind) = ResponseCallKind::output_item(item_type) { + match response_item_call_id(item) { + Some(call_id) => match calls.remove(call_id) { + Some(call_kind) if call_kind == output_kind => { + consumed_in_group = true; + } + Some(call_kind) => unresolved.push(format!( + "{call_id} ({} output cannot satisfy {} call)", + output_kind.label(), + call_kind.label() + )), + None => { + if !unresolved.iter().any(|value| value == call_id) { + unresolved.push(call_id.to_string()); + } + } + }, + None => unresolved.push("".to_string()), + } + if !unresolved.is_empty() { + // Keep collecting only adjacent invalid outputs so the client gets useful ids, + // but never let a later call retroactively legitimize an earlier output. + consumed_in_group = true; + } + continue; + } + + match item_type { + // Reasoning and assistant output items can neighbor the same model turn and do not + // make otherwise ordered call/output pairs stale. + "reasoning" | "message" | "" => {} + _ => { + if item.get("role").is_some() { + calls.clear(); + consumed_in_group = false; + } + } + } + } + if unresolved.is_empty() { + Ok(()) + } else { + Err(format!( + "Responses call output has no preceding matching call: {}", + unresolved.join(", ") + )) + } +} diff --git a/src-tauri/src/protocol/signatures.rs b/src-tauri/src/protocol/signatures.rs new file mode 100644 index 0000000..43eca2d --- /dev/null +++ b/src-tauri/src/protocol/signatures.rs @@ -0,0 +1,112 @@ +// Response-id minting plus Gemini/OpenAI "thought signature" plumbing: providers round-trip an +// opaque reasoning token on tool calls, and it must survive translation in both directions or the +// upstream rejects the follow-up turn. + +use llm_connector::types::ToolCall; +use serde_json::{json, Value}; + +/// Unique id for a synthesized response ("msg_ccbud__"). Clients persist these ids into +/// their history, and usage analytics de-dupes assistant messages BY id — a constant fallback id +/// would collapse every translated turn into a single counted request. +pub fn uid(prefix: &str) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{}_{}_{}", prefix, ms, N.fetch_add(1, Ordering::Relaxed)) +} + +/// Extract Gemini's opaque thought signature from its OpenAI-compatible wire location, or from +/// an internal/native spelling encountered while translating. The canonical OpenAI compatibility +/// shape is `extra_content.google.thought_signature`. +pub(crate) fn json_thought_signature(value: &Value) -> Option { + value + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .or_else(|| value.get("thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) + .or_else(|| value.pointer("/function/thought_signature").and_then(Value::as_str).filter(|s| !s.is_empty())) + .map(str::to_string) +} + +/// Read the signature from the llm-connector IR. The crate supports both placements for native +/// Gemini, so accept either while keeping a single canonical wire representation at the edge. +pub(crate) fn tool_call_thought_signature(call: &ToolCall) -> Option { + call.thought_signature + .as_deref() + .filter(|s| !s.is_empty()) + .or_else(|| call.function.thought_signature.as_deref().filter(|s| !s.is_empty())) + .map(str::to_string) +} + +fn strip_internal_thought_signature(call: &mut Value) { + let Some(call_obj) = call.as_object_mut() else { return }; + call_obj.remove("thought_signature"); + if let Some(function) = call_obj.get_mut("function").and_then(Value::as_object_mut) { + function.remove("thought_signature"); + } +} + +fn set_google_thought_signature(call: &mut Value, signature: &str) { + strip_internal_thought_signature(call); + call["extra_content"]["google"]["thought_signature"] = json!(signature); +} + +/// llm-connector serializes its internal signature fields literally. Rewrite them into Gemini's +/// OpenAI-compatible `extra_content.google.thought_signature` before forwarding. +pub(super) fn normalize_openai_request_thought_signatures(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; + for message in messages { + let Some(calls) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else { continue }; + for call in calls { + if let Some(signature) = json_thought_signature(call) { + set_google_thought_signature(call, &signature); + } + } + } +} + +/// Thinking chat upstreams (Kimi/Moonshot, DeepSeek, …) require every assistant message that +/// carries `tool_calls` to also carry a non-empty `reasoning_content`, and answer +/// "reasoning_content is missing in assistant tool call message" otherwise. Real reasoning is +/// bridged from the client history where available (thinking blocks, Responses reasoning items); +/// this is the last-resort placeholder for turns whose reasoning didn't survive the wire. +/// Providers without the requirement ignore the extra field. +pub(super) fn ensure_chat_tool_call_reasoning_content(body: &mut Value) { + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { return }; + for message in messages { + let has_tool_calls = message.get("role").and_then(Value::as_str) == Some("assistant") + && message.get("tool_calls").and_then(Value::as_array).is_some_and(|c| !c.is_empty()); + if !has_tool_calls { + continue; + } + let missing = message + .get("reasoning_content") + .and_then(Value::as_str) + .map_or(true, |s| s.trim().is_empty()); + if missing { + message["reasoning_content"] = json!("tool call"); + } + } +} + +/// Gemini/OpenRouter/Cloudflare return provider metadata in `extra_content`, which serde ignores +/// when llm-connector parses a standard OpenAI ToolCall. Copy the opaque signature into the +/// crate's internal field before parsing; the original response remains otherwise unchanged. +pub(super) fn normalize_openai_response_thought_signatures(body: &mut Value) { + let Some(choices) = body.get_mut("choices").and_then(Value::as_array_mut) else { return }; + for choice in choices { + let Some(calls) = choice + .get_mut("message") + .and_then(|message| message.get_mut("tool_calls")) + .and_then(Value::as_array_mut) + else { continue }; + for call in calls { + if let Some(signature) = json_thought_signature(call) { + call["thought_signature"] = json!(signature); + } + } + } +} diff --git a/src-tauri/src/protocol/stream.rs b/src-tauri/src/protocol/stream.rs deleted file mode 100644 index 4c2e52f..0000000 --- a/src-tauri/src/protocol/stream.rs +++ /dev/null @@ -1,2447 +0,0 @@ -// Incremental SSE transcoders (P2). Consume an upstream provider's streaming events line-by-line -// and emit the client protocol's SSE events as they arrive — true token-by-token streaming, not the -// buffer-then-synthesize first cut. Wired pairs (see `Transcoder`): -// - OpenAI Chat `chat.completion.chunk` → Anthropic Messages events (Claude Code client) -// - OpenAI Chat `chat.completion.chunk` → OpenAI Responses events (Codex client) -// - Anthropic Messages events → OpenAI Responses events (Codex client) - -use super::openai_responses::{ - custom_tool_input_from_chat_arguments, response_scoped_call_id, CodexToolContext, CodexToolKind, -}; -use super::Wire; -use serde_json::{json, Value}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CapturedToolCall { - pub call_id: String, - pub name: String, - pub arguments: String, - pub thought_signature: Option, -} - -fn ev(event: &str, data: Value) -> String { - format!( - "event: {}\ndata: {}\n\n", - event, - serde_json::to_string(&data).unwrap_or_default() - ) -} - -fn upstream_error_message(event: &Value) -> Option<&str> { - let error = event.get("error").filter(|value| !value.is_null()); - let is_error = error.is_some() || event.get("type").and_then(Value::as_str) == Some("error"); - if !is_error { - return None; - } - error - .and_then(|value| value.get("message").and_then(Value::as_str)) - .or_else(|| error.and_then(Value::as_str)) - .or_else(|| event.get("message").and_then(Value::as_str)) - .or(Some("upstream error")) -} - -/// Dispatcher over the wired (provider → client) incremental transcoders, so gateway.rs holds one -/// value regardless of the pair. `supports` is the single source of truth behind -/// `protocol::can_transcode_stream`. -pub enum Transcoder { - ChatToAnthropic(ChatToAnthropic), - ChatToResponses(ChatToResponses), - AnthropicToResponses(AnthropicToResponses), -} - -impl Transcoder { - pub fn supports(provider: Wire, client: Wire) -> bool { - matches!( - (provider, client), - (Wire::OpenAiChat, Wire::Anthropic) - | (Wire::OpenAiChat, Wire::OpenAiResponses) - | (Wire::Anthropic, Wire::OpenAiResponses) - ) - } - - pub fn new(provider: Wire, client: Wire, client_model: &str) -> Option { - Self::new_with_context(provider, client, client_model, CodexToolContext::default()) - } - - pub fn new_with_context( - provider: Wire, - client: Wire, - client_model: &str, - tool_context: CodexToolContext, - ) -> Option { - match (provider, client) { - (Wire::OpenAiChat, Wire::Anthropic) => { - Some(Self::ChatToAnthropic(ChatToAnthropic::new(client_model))) - } - (Wire::OpenAiChat, Wire::OpenAiResponses) => Some(Self::ChatToResponses( - ChatToResponses::new_with_context(client_model, tool_context), - )), - (Wire::Anthropic, Wire::OpenAiResponses) => Some(Self::AnthropicToResponses( - AnthropicToResponses::new_with_context(client_model, tool_context), - )), - _ => None, - } - } - - pub fn push(&mut self, line: &str) -> String { - match self { - Self::ChatToAnthropic(t) => t.push(line), - Self::ChatToResponses(t) => t.push(line), - Self::AnthropicToResponses(t) => t.push(line), - } - } - - pub fn finish(&mut self) -> String { - match self { - Self::ChatToAnthropic(t) => t.finish(), - Self::ChatToResponses(t) => t.finish(), - Self::AnthropicToResponses(t) => t.finish(), - } - } - - /// Terminate a translated stream without allowing EOF finalization to synthesize success. - pub fn fail(&mut self, message: &str) -> String { - match self { - Self::ChatToAnthropic(t) => t.fail(message), - Self::ChatToResponses(t) => t.fail(message), - Self::AnthropicToResponses(t) => t.fail(message), - } - } - - pub fn input_tokens(&self) -> i64 { - match self { - Self::ChatToAnthropic(t) => t.input_tokens(), - Self::ChatToResponses(t) => t.input_tokens(), - Self::AnthropicToResponses(t) => t.input_tokens(), - } - } - - pub fn output_tokens(&self) -> i64 { - match self { - Self::ChatToAnthropic(t) => t.output_tokens(), - Self::ChatToResponses(t) => t.output_tokens(), - Self::AnthropicToResponses(t) => t.output_tokens(), - } - } - - pub fn captured_tool_calls(&self) -> Vec { - match self { - Self::ChatToAnthropic(t) => t.captured_tool_calls(), - Self::ChatToResponses(t) => t.captured_tool_calls(), - _ => vec![], - } - } - - /// True once the terminal client event (`message_stop` / `response.completed` / - /// `response.incomplete` / `response.failed`) has been emitted: the turn is semantically - /// complete even though the upstream socket may not have hit EOF yet — Responses clients - /// (Codex) hang up exactly at this point, so the gateway must not treat that disconnect as an - /// abort. - pub fn done(&self) -> bool { - match self { - Self::ChatToAnthropic(t) => t.stopped, - Self::ChatToResponses(t) => t.stopped, - Self::AnthropicToResponses(t) => t.stopped, - } - } - - pub fn succeeded(&self) -> bool { - match self { - Self::ChatToAnthropic(t) => t.stopped && !t.failed, - Self::ChatToResponses(t) => t.stopped && !t.failed, - Self::AnthropicToResponses(t) => t.stopped && !t.failed, - } - } -} - -fn map_stop(finish: Option<&str>, had_tool: bool) -> &'static str { - match finish { - Some("length") => "max_tokens", - Some("tool_calls") | Some("function_call") => "tool_use", - _ if had_tool => "tool_use", - _ => "end_turn", - } -} - -/// Stateful OpenAI-Chat-stream → Anthropic-stream transcoder. Feed each raw upstream SSE line to -/// `push`; call `finish` at end. Anthropic requires an ordered `message_start`, then content blocks -/// (each `content_block_start`/`_delta`/`_stop`), then `message_delta` + `message_stop`. We open a -/// text block on the first text delta and one tool_use block per OpenAI tool_call index, assigning -/// Anthropic block indices in first-appearance order. -pub struct ChatToAnthropic { - client_model: String, - started: bool, - // message id sent in message_start — from the upstream chunk id when it has one, else a - // generated unique id. Clients persist this id; it must never repeat across turns (usage - // analytics de-dupes assistant messages by id). - msg_id: Option, - next_index: usize, - // text block - text_index: Option, - // openai tool_call index → (anthropic block index, open?) - tools: Vec, - input_tokens: i64, - output_tokens: i64, - finish_reason: Option, - stopped: bool, - failed: bool, -} - -struct ToolSlot { - oa_index: u64, - an_index: usize, - open: bool, - id: String, - name: String, - thought_signature: Option, - arguments: String, -} - -impl ChatToAnthropic { - pub fn new(client_model: &str) -> Self { - Self { - client_model: client_model.to_string(), - started: false, - msg_id: None, - next_index: 0, - text_index: None, - tools: vec![], - input_tokens: 0, - output_tokens: 0, - finish_reason: None, - stopped: false, - failed: false, - } - } - - fn ensure_started(&mut self, out: &mut String) { - if self.started { - return; - } - self.started = true; - let id = self - .msg_id - .get_or_insert_with(|| super::uid("msg_ccbud")) - .clone(); - out.push_str(&ev( - "message_start", - json!({ "type": "message_start", "message": { - "id": id, "type": "message", "role": "assistant", "model": self.client_model, - "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, - "usage": { "input_tokens": self.input_tokens.max(0), "output_tokens": 0 }, - }}), - )); - } - - fn open_text(&mut self, out: &mut String) -> usize { - if let Some(i) = self.text_index { - return i; - } - let idx = self.next_index; - self.next_index += 1; - self.text_index = Some(idx); - out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": idx, "content_block": { "type": "text", "text": "" } }))); - idx - } - - fn captured_tool_calls(&self) -> Vec { - self.tools - .iter() - .map(|slot| CapturedToolCall { - call_id: slot.id.clone(), - name: slot.name.clone(), - arguments: slot.arguments.clone(), - thought_signature: slot.thought_signature.clone(), - }) - .collect() - } - - /// Feed one raw upstream SSE line (e.g. "data: {...}\n" or "data: [DONE]\n"). Returns the - /// Anthropic SSE text to forward (possibly empty). - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, // ignore "event:" lines / blanks; chat SSE carries data-only - }; - if payload.is_empty() { - return out; - } - if payload == "[DONE]" { - out.push_str(&self.complete()); - return out; - } - let chunk: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - if let Some(message) = upstream_error_message(&chunk) { - return self.fail(message); - } - if self.msg_id.is_none() { - if let Some(id) = chunk - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.msg_id = Some(format!("msg_{}", id)); - } - } - // usage may ride the final chunk (stream_options.include_usage) - if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { - self.input_tokens = u - .get("prompt_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.input_tokens); - self.output_tokens = u - .get("completion_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.output_tokens); - } - let choice = chunk - .get("choices") - .and_then(|c| c.as_array()) - .and_then(|a| a.first()); - let choice = match choice { - Some(c) => c, - None => return out, - }; - self.ensure_started(&mut out); - let delta = choice.get("delta").cloned().unwrap_or(Value::Null); - - // text delta - if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { - if !txt.is_empty() { - let idx = self.open_text(&mut out); - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": idx, "delta": { "type": "text_delta", "text": txt } }))); - } - } - - // tool_call deltas (streamed in fragments, keyed by their OpenAI index) - if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { - // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider - // repeats the same id, each array item in this delta must claim a distinct slot. - let mut claimed_slots: Vec = vec![]; - for (fallback_index, tc) in tcs.iter().enumerate() { - let explicit_index = tc.get("index").and_then(|v| v.as_u64()); - let oa_index = explicit_index.unwrap_or(fallback_index as u64); - let name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let args = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let thought_signature = super::json_thought_signature(tc); - - // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. - // In that case prefer the stable call id, then the call's position in this delta. - let pos = explicit_index - .and_then(|_| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .or_else(|| { - (!id.is_empty()) - .then(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.id == id && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .flatten() - }) - .or_else(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }); - let slot_idx = match pos { - Some(i) => i, - None => { - let an_index = self.next_index; - self.next_index += 1; - self.tools.push(ToolSlot { - oa_index, - an_index, - open: false, - id: String::new(), - name: String::new(), - thought_signature: None, - arguments: String::new(), - }); - self.tools.len() - 1 - } - }; - claimed_slots.push(slot_idx); - let (an_index, should_open, open_id, open_name) = { - let slot = &mut self.tools[slot_idx]; - if !id.is_empty() { - slot.id = id.to_string(); - } - if !name.is_empty() { - slot.name = name.to_string(); - } - if thought_signature.is_some() { - slot.thought_signature = thought_signature; - } - if !args.is_empty() { - slot.arguments.push_str(args); - } - let should_open = !slot.open; - if should_open { - slot.open = true; - } - ( - slot.an_index, - should_open, - slot.id.clone(), - slot.name.clone(), - ) - }; - if should_open { - out.push_str(&ev( - "content_block_start", - json!({ "type": "content_block_start", - "index": an_index, "content_block": { "type": "tool_use", - "id": open_id, "name": open_name, "input": {} } }), - )); - } - if !args.is_empty() { - out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", - "index": an_index, "delta": { "type": "input_json_delta", "partial_json": args } }))); - } - } - } - - if let Some(fr) = choice.get("finish_reason").and_then(|v| v.as_str()) { - self.finish_reason = Some(fr.to_string()); - } - out - } - - /// Close any open blocks and emit message_delta + message_stop. Idempotent. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_started(&mut out); - // close blocks in ascending Anthropic index order - let mut closes: Vec = vec![]; - if let Some(i) = self.text_index { - closes.push(i); - } - for s in &self.tools { - if s.open { - closes.push(s.an_index); - } - } - closes.sort_unstable(); - for i in closes { - out.push_str(&ev( - "content_block_stop", - json!({ "type": "content_block_stop", "index": i }), - )); - } - let had_tool = self.tools.iter().any(|s| s.open); - out.push_str(&ev( - "message_delta", - json!({ "type": "message_delta", - "delta": { "stop_reason": map_stop(self.finish_reason.as_deref(), had_tool), "stop_sequence": Value::Null }, - "usage": { "output_tokens": self.output_tokens.max(0) } }), - )); - out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); - out - } - - /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls - /// `complete` directly; an EOF without either signal is a truncated stream. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.finish_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before [DONE] or a finish reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - self.failed = true; - ev( - "error", - json!({ "type": "error", "error": { "type": "api_error", "message": message } }), - ) - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -// ---- shared Responses-side item builders (final `output_item.done` / `completed` payloads) ---- - -fn resp_message_item(id: &str, text: &str) -> Value { - json!({ "type": "message", "id": id, "status": "completed", "role": "assistant", - "content": [{ "type": "output_text", "annotations": [], "text": text }] }) -} - -fn resp_function_call_item(id: &str, call_id: &str, name: &str, args: &str) -> Value { - json!({ "type": "function_call", "id": id, "status": "completed", "call_id": call_id, - "name": name, "arguments": if args.is_empty() { "{}" } else { args } }) -} - -fn resp_in_progress_tool_item( - context: &CodexToolContext, - id: &str, - call_id: &str, - name: &str, - reasoning: Option<&str>, -) -> Value { - let mut item = - context.response_tool_item_with_reasoning(id, "in_progress", call_id, name, "", reasoning); - if item.get("type").and_then(Value::as_str) == Some("function_call") { - item["arguments"] = json!(""); - } - item -} - -fn normalized_tool_arguments(arguments: &str) -> &str { - if arguments.trim().is_empty() { - "{}" - } else { - arguments - } -} - -fn resp_reasoning_item(id: &str, text: &str) -> Value { - json!({ "type": "reasoning", "id": id, "summary": [{ "type": "summary_text", "text": text }] }) -} - -fn response_scoped_item_id(prefix: &str, response_id: &str, index: usize) -> String { - format!( - "{}_{}_{}", - prefix, - response_id.trim_start_matches("resp_"), - index - ) -} - -/// The terminal `response.completed` event. Codex parses `response.id` + `response.usage` from it -/// and treats a stream that closes without it as an error, so every Responses-emitting transcoder -/// must end with this exactly once. -fn resp_completed( - id: &str, - model: &str, - output: Vec, - input: i64, - cached: i64, - output_tokens: i64, -) -> String { - ev( - "response.completed", - json!({ "type": "response.completed", "response": { - "id": id, "object": "response", "status": "completed", "model": model, - "output": output, - "usage": { - "input_tokens": input.max(0), - "input_tokens_details": { "cached_tokens": cached.max(0) }, - "output_tokens": output_tokens.max(0), - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": (input + output_tokens).max(0), - } } }), - ) -} - -fn resp_incomplete( - id: &str, - model: &str, - output: Vec, - input: i64, - cached: i64, - output_tokens: i64, - reason: &str, -) -> String { - ev( - "response.incomplete", - json!({ "type": "response.incomplete", "response": { - "id": id, "object": "response", "status": "incomplete", "model": model, - "output": output, - "incomplete_details": { "reason": reason }, - "usage": { - "input_tokens": input.max(0), - "input_tokens_details": { "cached_tokens": cached.max(0) }, - "output_tokens": output_tokens.max(0), - "output_tokens_details": { "reasoning_tokens": 0 }, - "total_tokens": (input + output_tokens).max(0), - } } }), - ) -} - -fn incomplete_reason(stop_reason: Option<&str>) -> Option<&'static str> { - match stop_reason { - Some("length" | "max_tokens" | "model_context_window_exceeded") => { - Some("max_output_tokens") - } - Some("content_filter") => Some("content_filter"), - _ => None, - } -} - -fn resp_failed(id: &str, message: &str) -> String { - ev( - "response.failed", - json!({ "type": "response.failed", "response": { - "id": id, "object": "response", "status": "failed", - "error": { "code": "upstream_error", "message": message } - } }), - ) -} - -/// Stateful OpenAI-Chat-stream → OpenAI-Responses-stream transcoder (Codex client, chat upstream). -/// Text deltas stream through as `response.output_text.delta`; provider reasoning deltas -/// (`reasoning_content` / `reasoning`) as `response.reasoning_summary_text.delta`; tool-call -/// fragments accumulate per OpenAI index (with the same no-index Gemini slot handling as -/// ChatToAnthropic, including thought-signature capture) and surface whole in -/// `response.output_item.done` — the only place Codex materializes items from. -pub struct ChatToResponses { - client_model: String, - tool_context: CodexToolContext, - // Construction-time fallback. An upstream id may replace it only until response.created is - // emitted; afterward this id is immutable so every event in the response agrees. - resp_id: String, - created: bool, - next_index: usize, - reasoning: Option, - reasoning_open: bool, - message: Option, - tools: Vec, - input_tokens: i64, - cached_tokens: i64, - output_tokens: i64, - finish_reason: Option, - stopped: bool, - failed: bool, -} - -struct TextItemAcc { - index: usize, - id: String, - acc: String, -} - -struct RespToolAcc { - oa_index: u64, - index: usize, - id: String, - upstream_call_id: String, - call_id: String, - name: String, - args: String, - thought_signature: Option, - announced: bool, - emitted_args_len: usize, -} - -impl ChatToResponses { - pub fn new(client_model: &str) -> Self { - Self::new_with_context(client_model, CodexToolContext::default()) - } - - pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { - Self { - client_model: client_model.to_string(), - tool_context, - resp_id: super::uid("resp_ccbud"), - created: false, - next_index: 0, - reasoning: None, - reasoning_open: false, - message: None, - tools: vec![], - input_tokens: 0, - cached_tokens: 0, - output_tokens: 0, - finish_reason: None, - stopped: false, - failed: false, - } - } - - fn rid(&self) -> String { - self.resp_id.clone() - } - - /// The turn's tool calls (with any Gemini thought signatures sniffed from the chat stream), - /// keyed by the call_id the Responses client will echo back — feeds the gateway's - /// session-scoped signature cache exactly like ChatToAnthropic. Nameless slots are excluded, - /// matching what finish() emits (and therefore what the client can echo). - pub fn captured_tool_calls(&self) -> Vec { - self.tools - .iter() - .filter(|slot| !slot.name.is_empty()) - .map(|slot| CapturedToolCall { - call_id: slot.call_id.clone(), - name: slot.name.clone(), - arguments: slot.args.clone(), - thought_signature: slot.thought_signature.clone(), - }) - .collect() - } - - fn ensure_created(&mut self, out: &mut String) { - if self.created { - return; - } - self.created = true; - let id = self.rid(); - out.push_str(&ev( - "response.created", - json!({ "type": "response.created", - "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), - )); - } - - fn close_reasoning(&mut self, out: &mut String) { - if !self.reasoning_open { - return; - } - self.reasoning_open = false; - if let Some(r) = &self.reasoning { - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": r.index, - "item": resp_reasoning_item(&r.id, &r.acc) }), - )); - } - } - - fn announce_tool_if_ready(&mut self, pos: usize, out: &mut String) { - let Some(slot) = self.tools.get(pos) else { - return; - }; - if slot.announced || slot.name.is_empty() { - return; - } - - let id = self - .tool_context - .response_item_id(&slot.name, &self.rid(), slot.index); - let item = resp_in_progress_tool_item( - &self.tool_context, - &id, - &slot.call_id, - &slot.name, - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ); - let index = slot.index; - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, "item": item }), - )); - - let slot = &mut self.tools[pos]; - slot.id = id; - slot.announced = true; - self.emit_pending_tool_arguments(pos, out); - } - - fn emit_pending_tool_arguments(&mut self, pos: usize, out: &mut String) { - let Some(slot) = self.tools.get_mut(pos) else { - return; - }; - if !slot.announced - || slot.name.is_empty() - || self.tool_context.kind_for_chat_name(&slot.name) == CodexToolKind::Custom - || slot.emitted_args_len >= slot.args.len() - { - return; - } - let delta = slot.args[slot.emitted_args_len..].to_string(); - slot.emitted_args_len = slot.args.len(); - out.push_str(&ev( - "response.function_call_arguments.delta", - json!({ "type": "response.function_call_arguments.delta", "item_id": slot.id, - "output_index": slot.index, "delta": delta }), - )); - } - - fn close_tool_events(&self, slot: &RespToolAcc) -> String { - let mut out = String::new(); - let arguments = normalized_tool_arguments(&slot.args); - let item = self.tool_context.response_tool_item_with_reasoning( - &slot.id, - "completed", - &slot.call_id, - &slot.name, - arguments, - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ); - match self.tool_context.kind_for_chat_name(&slot.name) { - CodexToolKind::Custom => { - let input = custom_tool_input_from_chat_arguments(arguments); - if !input.is_empty() { - out.push_str(&ev( - "response.custom_tool_call_input.delta", - json!({ "type": "response.custom_tool_call_input.delta", "item_id": slot.id, - "call_id": slot.call_id, "output_index": slot.index, "delta": input }), - )); - } - out.push_str(&ev( - "response.custom_tool_call_input.done", - json!({ "type": "response.custom_tool_call_input.done", "item_id": slot.id, - "call_id": slot.call_id, "output_index": slot.index, "input": input }), - )); - } - CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { - out.push_str(&ev( - "response.function_call_arguments.done", - json!({ "type": "response.function_call_arguments.done", "item_id": slot.id, - "output_index": slot.index, "arguments": arguments }), - )); - } - } - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": slot.index, "item": item }), - )); - out - } - - /// Feed one raw upstream SSE line ("data: {...}" or "data: [DONE]"). Returns Responses SSE - /// text to forward (possibly empty). - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, // chat SSE is data-only; ignore blanks/event: lines - }; - if payload.is_empty() { - return out; - } - if payload == "[DONE]" { - out.push_str(&self.complete()); - return out; - } - let chunk: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - if let Some(message) = upstream_error_message(&chunk) { - return self.fail(message); - } - if !self.created { - if let Some(id) = chunk - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.resp_id = format!("resp_{}", id); - } - } - // usage rides the final chunk (stream_options.include_usage) - if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { - self.input_tokens = u - .get("prompt_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.input_tokens); - self.output_tokens = u - .get("completion_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(self.output_tokens); - if let Some(c) = u - .pointer("/prompt_tokens_details/cached_tokens") - .and_then(|v| v.as_i64()) - { - self.cached_tokens = c; - } - } - let choice = match chunk - .get("choices") - .and_then(|c| c.as_array()) - .and_then(|a| a.first()) - { - Some(c) => c, - None => return out, - }; - self.ensure_created(&mut out); - let delta = choice.get("delta").cloned().unwrap_or(Value::Null); - - // provider reasoning stream (DeepSeek/GLM-style `reasoning_content`, or `reasoning`) - let think = delta - .get("reasoning_content") - .and_then(|v| v.as_str()) - .or_else(|| delta.get("reasoning").and_then(|v| v.as_str())) - .unwrap_or(""); - if !think.is_empty() { - if self.reasoning.is_none() { - let index = self.next_index; - self.next_index += 1; - let id = response_scoped_item_id("rs", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "reasoning", "id": id, "summary": [] } }), - )); - self.reasoning = Some(TextItemAcc { - index, - id, - acc: String::new(), - }); - self.reasoning_open = true; - } - let r = self.reasoning.as_mut().unwrap(); - r.acc.push_str(think); - out.push_str(&ev( - "response.reasoning_summary_text.delta", - json!({ "type": "response.reasoning_summary_text.delta", "item_id": r.id, - "output_index": r.index, "summary_index": 0, "delta": think }), - )); - } - - // text delta - if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { - if !txt.is_empty() { - self.close_reasoning(&mut out); - if self.message.is_none() { - let index = self.next_index; - self.next_index += 1; - let id = response_scoped_item_id("msg", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), - )); - out.push_str(&ev( - "response.content_part.added", - json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - self.message = Some(TextItemAcc { - index, - id, - acc: String::new(), - }); - } - let m = self.message.as_mut().unwrap(); - m.acc.push_str(txt); - out.push_str(&ev( - "response.output_text.delta", - json!({ "type": "response.output_text.delta", "item_id": m.id, "output_index": m.index, - "content_index": 0, "delta": txt }), - )); - } - } - - // tool_call deltas (streamed in fragments, keyed by their OpenAI index) - if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { - if !tcs.is_empty() { - self.close_reasoning(&mut out); - } - // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider - // repeats the same id, each array item in this delta must claim a distinct slot - // (mirrors ChatToAnthropic). - let mut claimed_slots: Vec = vec![]; - for (fallback_index, tc) in tcs.iter().enumerate() { - let explicit_index = tc.get("index").and_then(|v| v.as_u64()); - let oa_index = explicit_index.unwrap_or(fallback_index as u64); - let frag_id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let frag_name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let args = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let thought_signature = super::json_thought_signature(tc); - - // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. - // In that case prefer the stable call id, then the call's position in this delta. - let pos = explicit_index - .and_then(|_| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .or_else(|| { - (!frag_id.is_empty()) - .then(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.upstream_call_id == frag_id - && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }) - .flatten() - }) - .or_else(|| { - self.tools - .iter() - .enumerate() - .find(|(index, slot)| { - slot.oa_index == oa_index && !claimed_slots.contains(index) - }) - .map(|(index, _)| index) - }); - let pos = match pos { - Some(p) => p, - None => { - let index = self.next_index; - self.next_index += 1; - let call_id = response_scoped_call_id(&self.rid(), index); - let slot = RespToolAcc { - oa_index, - index, - id: String::new(), - upstream_call_id: frag_id.to_string(), - call_id, - name: frag_name.to_string(), - args: String::new(), - thought_signature: None, - announced: false, - emitted_args_len: 0, - }; - self.tools.push(slot); - self.tools.len() - 1 - } - }; - claimed_slots.push(pos); - // stray late fragments may carry the id/name the opener lacked; the done item wins - if !frag_id.is_empty() { - self.tools[pos].upstream_call_id = frag_id.to_string(); - } - if !frag_name.is_empty() && self.tools[pos].name.is_empty() { - self.tools[pos].name = frag_name.to_string(); - } - if thought_signature.is_some() { - self.tools[pos].thought_signature = thought_signature; - } - if !args.is_empty() { - self.tools[pos].args.push_str(args); - } - self.announce_tool_if_ready(pos, &mut out); - self.emit_pending_tool_arguments(pos, &mut out); - } - } - if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { - self.finish_reason = Some(reason.to_string()); - } - out - } - - /// Close open items in index order and emit the appropriate terminal Responses event. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_created(&mut out); - self.close_reasoning(&mut out); - if let Some(m) = &self.message { - out.push_str(&ev( - "response.output_text.done", - json!({ "type": "response.output_text.done", "item_id": m.id, "output_index": m.index, - "content_index": 0, "text": m.acc }), - )); - out.push_str(&ev( - "response.content_part.done", - json!({ "type": "response.content_part.done", "item_id": m.id, "output_index": m.index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": m.acc } }), - )); - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": m.index, - "item": resp_message_item(&m.id, &m.acc) }), - )); - } - for pos in 0..self.tools.len() { - self.announce_tool_if_ready(pos, &mut out); - } - // A slot whose name never arrived is model garbage the client cannot execute — and a - // nameless function_call echoed into the next request is rejected upstream. Skip it. - for slot in self - .tools - .iter() - .filter(|slot| slot.announced && !slot.name.is_empty()) - { - out.push_str(&self.close_tool_events(slot)); - } - let mut items: Vec<(usize, Value)> = vec![]; - if let Some(r) = &self.reasoning { - items.push((r.index, resp_reasoning_item(&r.id, &r.acc))); - } - if let Some(m) = &self.message { - items.push((m.index, resp_message_item(&m.id, &m.acc))); - } - for slot in self - .tools - .iter() - .filter(|slot| slot.announced && !slot.name.is_empty()) - { - items.push(( - slot.index, - self.tool_context.response_tool_item_with_reasoning( - &slot.id, - "completed", - &slot.call_id, - &slot.name, - normalized_tool_arguments(&slot.args), - self.reasoning - .as_ref() - .map(|reasoning| reasoning.acc.as_str()), - ), - )); - } - items.sort_by_key(|(i, _)| *i); - let output: Vec = items.into_iter().map(|(_, v)| v).collect(); - if let Some(reason) = incomplete_reason(self.finish_reason.as_deref()) { - self.failed = true; - out.push_str(&resp_incomplete( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - reason, - )); - } else { - out.push_str(&resp_completed( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - )); - } - out - } - - /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls - /// `complete` directly; an EOF without either signal is a truncated stream. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.finish_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before [DONE] or a finish reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - let mut out = String::new(); - self.ensure_created(&mut out); - let id = self.rid(); - out.push_str(&resp_failed(&id, message)); - self.stopped = true; - self.failed = true; - out - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -/// Stateful Anthropic-Messages-stream → OpenAI-Responses-stream transcoder (Codex client, -/// Anthropic upstream). Anthropic blocks map 1:1 onto Responses output items: text → -/// message/output_text, tool_use → function_call (input_json_delta fragments accumulate into the -/// arguments string), thinking → reasoning summary. Upstream `error` events surface as -/// `response.failed` so Codex aborts cleanly instead of timing out. -pub struct AnthropicToResponses { - client_model: String, - tool_context: CodexToolContext, - // Construction-time fallback. An upstream id may replace it only until response.created is - // emitted; afterward this id is immutable so every event in the response agrees. - resp_id: String, - created: bool, - next_index: usize, - blocks: Vec, - input_tokens: i64, - cached_tokens: i64, - output_tokens: i64, - stop_reason: Option, - stopped: bool, - failed: bool, -} - -struct ABlock { - a_index: u64, - index: usize, - id: String, - kind: AKind, - open: bool, -} - -enum AKind { - Text { - acc: String, - }, - Tool { - call_id: String, - name: String, - args: String, - start_args: String, - }, - Think { - acc: String, - }, -} - -fn ablock_tool_arguments(args: &str, start_args: &str) -> String { - if !args.trim().is_empty() { - args.to_string() - } else if !start_args.trim().is_empty() { - start_args.to_string() - } else { - "{}".to_string() - } -} - -/// The closing event sequence for one finished block (its `*.done` events + `output_item.done`). -fn close_ablock_events( - b: &ABlock, - tool_context: &CodexToolContext, - reasoning: Option<&str>, -) -> String { - let mut out = String::new(); - match &b.kind { - AKind::Text { acc } => { - out.push_str(&ev( - "response.output_text.done", - json!({ "type": "response.output_text.done", "item_id": b.id, "output_index": b.index, - "content_index": 0, "text": acc }), - )); - out.push_str(&ev( - "response.content_part.done", - json!({ "type": "response.content_part.done", "item_id": b.id, "output_index": b.index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": acc } }), - )); - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_message_item(&b.id, acc) }), - )); - } - AKind::Tool { - call_id, - name, - args, - start_args, - } => { - let arguments = ablock_tool_arguments(args, start_args); - let item = tool_context.response_tool_item_with_reasoning( - &b.id, - "completed", - call_id, - name, - &arguments, - reasoning, - ); - match tool_context.kind_for_chat_name(name) { - CodexToolKind::Custom => { - let input = custom_tool_input_from_chat_arguments(&arguments); - if !input.is_empty() { - out.push_str(&ev( - "response.custom_tool_call_input.delta", - json!({ "type": "response.custom_tool_call_input.delta", "item_id": b.id, - "call_id": call_id, "output_index": b.index, "delta": input }), - )); - } - out.push_str(&ev( - "response.custom_tool_call_input.done", - json!({ "type": "response.custom_tool_call_input.done", "item_id": b.id, - "call_id": call_id, "output_index": b.index, "input": input }), - )); - } - CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { - out.push_str(&ev( - "response.function_call_arguments.done", - json!({ "type": "response.function_call_arguments.done", "item_id": b.id, - "output_index": b.index, "arguments": arguments }), - )); - } - } - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, - "item": item }), - )); - } - AKind::Think { acc } => { - out.push_str(&ev( - "response.output_item.done", - json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_reasoning_item(&b.id, acc) }), - )); - } - } - out -} - -fn ablock_item(b: &ABlock, tool_context: &CodexToolContext, reasoning: Option<&str>) -> Value { - match &b.kind { - AKind::Text { acc } => resp_message_item(&b.id, acc), - AKind::Tool { - call_id, - name, - args, - start_args, - } => tool_context.response_tool_item_with_reasoning( - &b.id, - "completed", - call_id, - name, - &ablock_tool_arguments(args, start_args), - reasoning, - ), - AKind::Think { acc } => resp_reasoning_item(&b.id, acc), - } -} - -impl AnthropicToResponses { - pub fn new(client_model: &str) -> Self { - Self::new_with_context(client_model, CodexToolContext::default()) - } - - pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { - Self { - client_model: client_model.to_string(), - tool_context, - resp_id: super::uid("resp_ccbud"), - created: false, - next_index: 0, - blocks: vec![], - input_tokens: 0, - cached_tokens: 0, - output_tokens: 0, - stop_reason: None, - stopped: false, - failed: false, - } - } - - fn rid(&self) -> String { - self.resp_id.clone() - } - - fn reasoning_text(&self) -> Option { - let text = self - .blocks - .iter() - .filter_map(|block| match &block.kind { - AKind::Think { acc } if !acc.trim().is_empty() => Some(acc.as_str()), - _ => None, - }) - .collect::>() - .join("\n\n"); - (!text.is_empty()).then_some(text) - } - - fn ensure_created(&mut self, out: &mut String) { - if self.created { - return; - } - self.created = true; - let id = self.rid(); - out.push_str(&ev( - "response.created", - json!({ "type": "response.created", - "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), - )); - } - - /// Feed one raw upstream SSE line. Anthropic streams interleave `event:` and `data:` lines; - /// the data JSON's `type` mirrors the event name, so data lines alone drive the state machine. - pub fn push(&mut self, line: &str) -> String { - let mut out = String::new(); - if self.stopped { - return out; - } - let t = line.trim(); - let payload = match t.strip_prefix("data:") { - Some(p) => p.trim(), - None => return out, - }; - if payload.is_empty() { - return out; - } - let evt: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return out, - }; - match evt.get("type").and_then(|v| v.as_str()) { - Some("message_start") => { - if let Some(m) = evt.get("message") { - if !self.created { - if let Some(id) = m - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - self.resp_id = format!("resp_{}", id); - } - } - if let Some(u) = m.get("usage") { - // Responses-style input_tokens includes cached reads; Anthropic reports - // them separately. - let base = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - let cr = u - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let cc = u - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - self.input_tokens = base + cr + cc; - self.cached_tokens = cr; - } - } - self.ensure_created(&mut out); - } - Some("content_block_start") => { - self.ensure_created(&mut out); - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let cb = evt.get("content_block").cloned().unwrap_or(Value::Null); - let index = self.next_index; - match cb.get("type").and_then(|v| v.as_str()) { - Some("text") => { - self.next_index += 1; - let id = response_scoped_item_id("msg", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), - )); - out.push_str(&ev( - "response.content_part.added", - json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, - "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Text { acc: String::new() }, - open: true, - }); - } - Some("tool_use") => { - self.next_index += 1; - let call_id = response_scoped_call_id(&self.rid(), index); - let name = cb - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let id = self - .tool_context - .response_item_id(&name, &self.rid(), index); - let start_args = cb - .get("input") - .filter(|value| { - value.as_object().is_some_and(|object| !object.is_empty()) - }) - .map(Value::to_string) - .unwrap_or_default(); - let reasoning = self.reasoning_text(); - let item = self.tool_context.response_tool_item_with_reasoning( - &id, - "in_progress", - &call_id, - &name, - "", - reasoning.as_deref(), - ); - let mut item = item; - if item.get("type").and_then(Value::as_str) == Some("function_call") { - item["arguments"] = json!(""); - } - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": item }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Tool { - call_id, - name, - args: String::new(), - start_args, - }, - open: true, - }); - } - Some("thinking") => { - self.next_index += 1; - let id = response_scoped_item_id("rs", &self.rid(), index); - out.push_str(&ev( - "response.output_item.added", - json!({ "type": "response.output_item.added", "output_index": index, - "item": { "type": "reasoning", "id": id, "summary": [] } }), - )); - self.blocks.push(ABlock { - a_index, - index, - id, - kind: AKind::Think { acc: String::new() }, - open: true, - }); - } - // redacted_thinking / server_tool_use / … have no Responses equivalent; their - // deltas find no block below and drop. - _ => {} - } - } - Some("content_block_delta") => { - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let delta = evt.get("delta").cloned().unwrap_or(Value::Null); - if let Some(b) = self - .blocks - .iter_mut() - .find(|b| b.a_index == a_index && b.open) - { - match (&mut b.kind, delta.get("type").and_then(|v| v.as_str())) { - (AKind::Text { acc }, Some("text_delta")) => { - if let Some(txt) = delta - .get("text") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - acc.push_str(txt); - out.push_str(&ev( - "response.output_text.delta", - json!({ "type": "response.output_text.delta", "item_id": b.id, - "output_index": b.index, "content_index": 0, "delta": txt }), - )); - } - } - (AKind::Tool { name, args, .. }, Some("input_json_delta")) => { - if let Some(pj) = delta - .get("partial_json") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - args.push_str(pj); - if self.tool_context.kind_for_chat_name(name) - != CodexToolKind::Custom - { - out.push_str(&ev( - "response.function_call_arguments.delta", - json!({ "type": "response.function_call_arguments.delta", "item_id": b.id, - "output_index": b.index, "delta": pj }), - )); - } - } - } - (AKind::Think { acc }, Some("thinking_delta")) => { - if let Some(th) = delta - .get("thinking") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - acc.push_str(th); - out.push_str(&ev( - "response.reasoning_summary_text.delta", - json!({ "type": "response.reasoning_summary_text.delta", "item_id": b.id, - "output_index": b.index, "summary_index": 0, "delta": th }), - )); - } - } - _ => {} // signature_delta etc. - } - } - } - Some("content_block_stop") => { - let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); - let reasoning = self.reasoning_text(); - if let Some(b) = self - .blocks - .iter_mut() - .find(|b| b.a_index == a_index && b.open) - { - b.open = false; - out.push_str(&close_ablock_events( - b, - &self.tool_context, - reasoning.as_deref(), - )); - } - } - Some("message_delta") => { - if let Some(reason) = evt - .get("delta") - .and_then(|delta| delta.get("stop_reason")) - .and_then(Value::as_str) - { - self.stop_reason = Some(reason.to_string()); - } - if let Some(o) = evt - .get("usage") - .and_then(|u| u.get("output_tokens")) - .and_then(|v| v.as_i64()) - { - self.output_tokens = o; - } - } - Some("message_stop") => { - out.push_str(&self.complete()); - } - Some("error") => { - let msg = evt - .get("error") - .and_then(|e| e.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or("upstream error"); - out.push_str(&self.fail(msg)); - } - _ => {} // ping etc. - } - out - } - - /// Close any still-open blocks and emit the appropriate terminal Responses event. - fn complete(&mut self) -> String { - if self.stopped { - return String::new(); - } - self.stopped = true; - let mut out = String::new(); - self.ensure_created(&mut out); - let reasoning = self.reasoning_text(); - self.blocks.sort_by_key(|b| b.index); - for b in &mut self.blocks { - if b.open { - b.open = false; - out.push_str(&close_ablock_events( - b, - &self.tool_context, - reasoning.as_deref(), - )); - } - } - let output: Vec = self - .blocks - .iter() - .map(|block| ablock_item(block, &self.tool_context, reasoning.as_deref())) - .collect(); - if let Some(reason) = incomplete_reason(self.stop_reason.as_deref()) { - self.failed = true; - out.push_str(&resp_incomplete( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - reason, - )); - } else { - out.push_str(&resp_completed( - &self.rid(), - &self.client_model, - output, - self.input_tokens, - self.cached_tokens, - self.output_tokens, - )); - } - out - } - - /// Finalize a clean upstream EOF only after Anthropic reported a stop reason. A normal - /// `message_stop` calls `complete` directly; an EOF before both signals is truncated. - pub fn finish(&mut self) -> String { - if self.stopped { - return String::new(); - } - if self.stop_reason.is_some() { - self.complete() - } else { - self.fail("upstream stream ended before message_stop or a stop reason") - } - } - - fn fail(&mut self, message: &str) -> String { - if self.stopped { - return String::new(); - } - let mut out = String::new(); - self.ensure_created(&mut out); - let id = self.rid(); - out.push_str(&resp_failed(&id, message)); - self.stopped = true; - self.failed = true; - out - } - - pub fn input_tokens(&self) -> i64 { - self.input_tokens - } - pub fn output_tokens(&self) -> i64 { - self.output_tokens - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn transcodes_text_and_split_tool_call() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - // role primer, then text, then a tool call split across two chunks, then finish + usage - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), - ); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-stream-abc\"}}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9}}")); - out.push_str(&tc.push("data: [DONE]")); - - // ordered events present (serde_json sorts object keys, so assert on substrings, not key order) - assert!(out.contains("event: message_start")); - assert!( - out.find("event: message_start").unwrap() - < out.find("event: content_block_start").unwrap() - ); - assert!( - out.find("event: content_block_start").unwrap() - < out.find("event: message_delta").unwrap() - ); - assert!( - out.find("event: message_delta").unwrap() < out.find("event: message_stop").unwrap() - ); - // text block: a text content_block_start + its two text deltas - assert!(out.contains(r#""type":"text""#)); - assert!(out.contains("text_delta") && out.contains(r#""text":"Let me ""#)); - assert!(out.contains(r#""text":"check.""#)); - // tool block: tool_use start carries id+name; args reassembled across fragments - assert!(out.contains(r#""type":"tool_use""#)); - assert!(out.contains(r#""id":"call_1""#) && out.contains(r#""name":"read_file""#)); - assert!(out.contains("input_json_delta") && out.contains(r#""partial_json":"{\"pa""#)); - assert!(out.contains(r#""partial_json":"th\":\"a.txt\"}""#)); - // closes both blocks (index 0 text, index 1 tool), tool_use stop, usage, terminal stop - assert!(out.contains(r#""index":0,"type":"content_block_stop""#)); - assert!(out.contains(r#""index":1,"type":"content_block_stop""#)); - assert!(out.contains(r#""stop_reason":"tool_use""#)); - assert!(out.contains(r#""output_tokens":9"#)); - assert!(out.contains("event: message_stop")); - assert_eq!(tc.input_tokens(), 20); - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].call_id, "call_1"); - assert_eq!(captured[0].arguments, r#"{"path":"a.txt"}"#); - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-stream-abc") - ); - } - - #[test] - fn keeps_no_index_parallel_calls_with_the_same_id_distinct() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-same-id\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"}}]}}]}\n")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", - )); - out.push_str(&tc.push("data: [DONE]\n")); - - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 2); - assert_eq!(captured[0].arguments, r#"{"query":"same"}"#); - assert_eq!(captured[1].arguments, r#"{"query":"same"}"#); - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-same-id") - ); - assert!(captured[1].thought_signature.is_none()); - assert!(out.contains(r#""index":0,"type":"content_block_start""#)); - assert!(out.contains(r#""index":1,"type":"content_block_start""#)); - } - - #[test] - fn plain_text_only() { - let mut tc = ChatToAnthropic::new("claude-x"); - let mut out = String::new(); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}"), - ); - out.push_str(&tc.push("data: [DONE]")); - assert!(out.contains("text_delta") && out.contains(r#""text":"hello""#)); - assert!(out.contains(r#""stop_reason":"end_turn""#)); - assert!(out.contains("event: message_stop")); - } - - // The gateway's abort guard relies on done() flipping as soon as push() emits the terminal - // client event — that is the moment Responses clients (Codex) hang up, before upstream EOF. - #[test] - fn done_flips_on_terminal_event_before_eof() { - let mut tc = Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - tc.push("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":3}}}\n"); - tc.push("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n"); - tc.push("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n"); - assert!(!tc.done()); - let out = tc.push("data: {\"type\":\"message_stop\"}\n"); - assert!(out.contains("response.completed")); - assert!(tc.done()); - - let mut tc = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"); - assert!(!tc.done()); - let out = tc.push("data: [DONE]"); - assert!(out.contains("event: message_stop")); - assert!(tc.done()); - } - - fn response_for_event(output: &str, event: &str) -> Value { - let event_line = format!("event: {}", event); - let frame = output - .split("\n\n") - .find(|frame| frame.lines().next() == Some(event_line.as_str())) - .unwrap_or_else(|| panic!("missing {event} event in {output}")); - let data = frame - .lines() - .find_map(|line| line.strip_prefix("data: ")) - .unwrap(); - serde_json::from_str::(data).unwrap()["response"].clone() - } - - fn response_id_for_event(output: &str, event: &str) -> String { - response_for_event(output, event)["id"] - .as_str() - .unwrap() - .to_string() - } - - #[test] - fn chat_to_responses_response_ids_are_unique_and_stable() { - let mut first = ChatToResponses::new("alias-x"); - let mut second = ChatToResponses::new("alias-x"); - let first_fallback = first.resp_id.clone(); - let second_fallback = second.resp_id.clone(); - assert!(first_fallback.starts_with("resp_ccbud_")); - assert!(second_fallback.starts_with("resp_ccbud_")); - assert_ne!(first_fallback, second_fallback); - - let mut first_out = - first.push(r#"data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}"#); - first_out.push_str(&first.push( - r#"data: {"id":"chatcmpl-too-late","choices":[{"index":0,"delta":{"content":"hi"}}]}"#, - )); - first_out.push_str(&first.push("data: [DONE]")); - assert_eq!(first.resp_id, first_fallback); - assert_eq!( - response_id_for_event(&first_out, "response.created"), - first_fallback - ); - assert_eq!( - response_id_for_event(&first_out, "response.completed"), - first_fallback - ); - - let second_out = second.push("data: [DONE]"); - assert_eq!( - response_id_for_event(&second_out, "response.created"), - second_fallback - ); - assert_eq!( - response_id_for_event(&second_out, "response.completed"), - second_fallback - ); - - let mut upstream = ChatToResponses::new("alias-x"); - let upstream_fallback = upstream.resp_id.clone(); - let mut upstream_out = upstream.push( - r#"data: {"id":"chatcmpl-early","choices":[{"index":0,"delta":{"role":"assistant"}}]}"#, - ); - upstream_out.push_str(&upstream.push("data: [DONE]")); - assert_ne!(upstream.resp_id, upstream_fallback); - assert_eq!(upstream.resp_id, "resp_chatcmpl-early"); - assert_eq!( - response_id_for_event(&upstream_out, "response.created"), - "resp_chatcmpl-early" - ); - assert_eq!( - response_id_for_event(&upstream_out, "response.completed"), - "resp_chatcmpl-early" - ); - } - - #[test] - fn anthropic_to_responses_response_ids_are_unique_and_stable() { - let mut first = AnthropicToResponses::new("alias-x"); - let mut second = AnthropicToResponses::new("alias-x"); - let first_fallback = first.resp_id.clone(); - let second_fallback = second.resp_id.clone(); - assert!(first_fallback.starts_with("resp_ccbud_")); - assert!(second_fallback.starts_with("resp_ccbud_")); - assert_ne!(first_fallback, second_fallback); - - let mut first_out = first.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ); - first_out.push_str(&first.push( - r#"data: {"type":"message_start","message":{"id":"msg_too_late","usage":{"input_tokens":1}}}"#, - )); - first_out.push_str(&first.push(r#"data: {"type":"message_stop"}"#)); - assert_eq!(first.resp_id, first_fallback); - assert_eq!( - response_id_for_event(&first_out, "response.created"), - first_fallback - ); - assert_eq!( - response_id_for_event(&first_out, "response.completed"), - first_fallback - ); - - let second_out = second.push(r#"data: {"type":"message_stop"}"#); - assert_eq!( - response_id_for_event(&second_out, "response.created"), - second_fallback - ); - assert_eq!( - response_id_for_event(&second_out, "response.completed"), - second_fallback - ); - - let mut upstream = AnthropicToResponses::new("alias-x"); - let upstream_fallback = upstream.resp_id.clone(); - let mut upstream_out = upstream.push( - r#"data: {"type":"message_start","message":{"id":"msg_early","usage":{"input_tokens":1}}}"#, - ); - upstream_out.push_str(&upstream.push(r#"data: {"type":"message_stop"}"#)); - assert_ne!(upstream.resp_id, upstream_fallback); - assert_eq!(upstream.resp_id, "resp_msg_early"); - assert_eq!( - response_id_for_event(&upstream_out, "response.created"), - "resp_msg_early" - ); - assert_eq!( - response_id_for_event(&upstream_out, "response.completed"), - "resp_msg_early" - ); - } - - #[test] - fn streaming_response_item_ids_are_scoped_to_the_response() { - let chat = |upstream_id: &str| { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = tc.push(&format!( - r#"data: {{"id":"{upstream_id}","choices":[{{"index":0,"delta":{{"reasoning_content":"think","content":"answer"}}}}]}}"# - )); - out.push_str(&tc.push("data: [DONE]")); - response_for_event(&out, "response.completed")["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("id").and_then(Value::as_str)) - .map(ToString::to_string) - .collect::>() - }; - let first_chat = chat("chatcmpl-first"); - let second_chat = chat("chatcmpl-second"); - assert_eq!(first_chat.len(), 2); - assert!(first_chat.iter().all(|id| id.contains("chatcmpl-first"))); - assert!(second_chat.iter().all(|id| id.contains("chatcmpl-second"))); - assert!(first_chat.iter().all(|id| !second_chat.contains(id))); - - let anthropic = |message_id: &str| { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = tc.push(&format!( - r#"data: {{"type":"message_start","message":{{"id":"{message_id}","usage":{{"input_tokens":1}}}}}}"# - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think"}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - response_for_event(&out, "response.completed")["output"] - .as_array() - .unwrap() - .iter() - .filter_map(|item| item.get("id").and_then(Value::as_str)) - .map(ToString::to_string) - .collect::>() - }; - let first_anthropic = anthropic("msg-first"); - let second_anthropic = anthropic("msg-second"); - assert_eq!(first_anthropic.len(), 2); - assert!(first_anthropic.iter().all(|id| id.contains("msg-first"))); - assert!(second_anthropic.iter().all(|id| id.contains("msg-second"))); - assert!(first_anthropic - .iter() - .all(|id| !second_anthropic.contains(id))); - } - - #[test] - fn chat_to_responses_text_and_split_tool_call() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}")); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), - ); - out.push_str( - &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), - ); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"co\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"mmand\\\":[\\\"ls\\\"]}\"}}]}}]}")); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9,\"prompt_tokens_details\":{\"cached_tokens\":7}}}")); - out.push_str(&tc.push("data: [DONE]")); - - // ordered: created → text deltas → item done events → completed - let created = out.find(r#""type":"response.created""#).unwrap(); - let first_delta = out.find(r#""type":"response.output_text.delta""#).unwrap(); - let item_done = out.find(r#""type":"response.output_item.done""#).unwrap(); - let completed = out.find(r#""type":"response.completed""#).unwrap(); - assert!(created < first_delta && first_delta < item_done && item_done < completed); - // token-by-token text deltas - assert!(out.contains(r#""delta":"Let me ""#)); - assert!(out.contains(r#""delta":"check.""#)); - // Codex materializes items from output_item.done: full text + reassembled arguments - assert!(out.contains(r#""text":"Let me check.""#)); - let client_call_id = response_scoped_call_id("resp_chatcmpl-1", 1); - assert!( - out.contains(&format!(r#""call_id":"{}""#, client_call_id)) - && out.contains(r#""name":"shell""#) - ); - assert!(out.contains(r#""arguments":"{\"command\":[\"ls\"]}""#)); - // completed carries id + usage, incl. the prompt cache detail Codex reports - assert!(out.contains(r#""id":"resp_chatcmpl-1""#)); - assert!(out.contains(r#""input_tokens":20"#) && out.contains(r#""output_tokens":9"#)); - assert!(out.contains(r#""cached_tokens":7"#)); - assert_eq!(tc.input_tokens(), 20); - assert_eq!(tc.output_tokens(), 9); - // captured for the gateway's signature cache, keyed by the call_id Codex echoes back - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].call_id, client_call_id); - assert_eq!(captured[0].arguments, r#"{"command":["ls"]}"#); - // finish is idempotent — [DONE] already closed the stream - assert_eq!(tc.finish(), ""); - } - - // Gemini's OpenAI-compatible stream omits `index` and can repeat ids across parallel calls; - // before the fix every no-index fragment collapsed into slot 0 (one garbled call), so Codex - // never received usable tool calls from a Gemini chat upstream. - #[test] - fn chat_to_responses_keeps_no_index_parallel_calls_distinct() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"a\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-parallel\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"b\\\"}\"}}]}}]}\n")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", - )); - out.push_str(&tc.push("data: [DONE]\n")); - - // two distinct function_call items, each with its own arguments - assert!(out.contains(r#""output_index":0"#) && out.contains(r#""output_index":1"#)); - assert!(out.contains(r#""arguments":"{\"query\":\"a\"}""#)); - assert!(out.contains(r#""arguments":"{\"query\":\"b\"}""#)); - let captured = tc.captured_tool_calls(); - assert_eq!(captured.len(), 2); - assert_ne!(captured[0].call_id, captured[1].call_id); - assert_eq!( - captured[0].call_id, - response_scoped_call_id("resp_chatcmpl-2", 0) - ); - assert_eq!( - captured[1].call_id, - response_scoped_call_id("resp_chatcmpl-2", 1) - ); - assert!(out.contains(&format!(r#""call_id":"{}""#, captured[0].call_id))); - assert!(out.contains(&format!(r#""call_id":"{}""#, captured[1].call_id))); - assert_eq!(captured[0].arguments, r#"{"query":"a"}"#); - assert_eq!(captured[1].arguments, r#"{"query":"b"}"#); - // the Gemini thought signature is captured for the session cache (restore next turn) - assert_eq!( - captured[0].thought_signature.as_deref(), - Some("sig-parallel") - ); - assert!(captured[1].thought_signature.is_none()); - } - - // Some models emit tool-call fragments that never carry a function name; forwarding them - // gives Codex an unexecutable call whose echo the upstream then rejects — drop them instead. - #[test] - fn chat_to_responses_skips_nameless_tool_calls() { - let mut tc = ChatToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{}\"}}]}}]}")); - out.push_str(&tc.push( - "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", - )); - out.push_str(&tc.push("data: [DONE]")); - assert!(!out.contains("response.function_call_arguments.done")); - assert!( - out.contains(r#""output":[]"#), - "completed output stays empty: {}", - out - ); - assert!(out.contains(r#""type":"response.completed""#)); - assert!(tc.captured_tool_calls().is_empty()); - } - - #[test] - fn anthropic_to_responses_text_tool_and_thinking() { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push(r#"data: {"type":"message_start","message":{"id":"msg_9","usage":{"input_tokens":30,"cache_read_input_tokens":12,"cache_creation_input_tokens":0}}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Run"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ning."}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell","input":{}}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}"#)); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); - out.push_str(&tc.push(r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":11}}"#)); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - - let created = out.find(r#""type":"response.created""#).unwrap(); - let completed = out.find(r#""type":"response.completed""#).unwrap(); - assert!(created < completed); - // thinking → reasoning summary deltas + item - assert!( - out.contains(r#""type":"response.reasoning_summary_text.delta""#) - && out.contains(r#""delta":"hmm""#) - ); - assert!(out.contains(r#""type":"summary_text""#)); - // text streams as deltas and closes with the full text - assert!(out.contains(r#""delta":"Run""#) && out.contains(r#""delta":"ning.""#)); - assert!(out.contains(r#""text":"Running.""#)); - // tool_use → function_call item with the reassembled arguments string - assert!( - out.contains(&format!( - r#""call_id":"{}""#, - response_scoped_call_id("resp_msg_9", 2) - )) && out.contains(r#""name":"shell""#) - ); - assert!(out.contains(r#""arguments":"{\"q\":\"x\"}""#)); - // usage: cached reads fold into input_tokens, detail carries them - assert!(out.contains(r#""input_tokens":42"#)); - assert!(out.contains(r#""cached_tokens":12"#)); - assert!(out.contains(r#""output_tokens":11"#)); - assert!(out.contains(r#""id":"resp_msg_9""#)); - assert_eq!(tc.input_tokens(), 42); - assert_eq!(tc.output_tokens(), 11); - // message_stop already completed the stream - assert_eq!(tc.finish(), ""); - } - - #[test] - fn anthropic_to_responses_error_is_terminal() { - let mut tc = AnthropicToResponses::new("alias-x"); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":5}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - )); - out.push_str(&tc.finish()); - assert!(out.contains(r#""type":"response.failed""#) && out.contains("Overloaded")); - // failed is terminal: no completed after it - assert!(!out.contains(r#""type":"response.completed""#)); - } - - #[test] - fn chat_error_events_are_terminal_for_translated_clients() { - let mut responses = - Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut responses_out = - responses.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); - responses_out.push_str( - &responses - .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), - ); - responses_out.push_str(&responses.finish()); - assert!(responses_out.contains(r#""type":"response.failed""#)); - assert!(responses_out.contains("upstream exploded")); - assert!(!responses_out.contains(r#""type":"response.completed""#)); - assert!(responses.done()); - assert!(!responses.succeeded()); - - let mut anthropic = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - let mut anthropic_out = - anthropic.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); - anthropic_out.push_str( - &anthropic - .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), - ); - anthropic_out.push_str(&anthropic.finish()); - assert!(anthropic_out.contains("event: error")); - assert!(anthropic_out.contains("upstream exploded")); - assert!(!anthropic_out.contains("event: message_stop")); - assert!(anthropic.done()); - assert!(!anthropic.succeeded()); - } - - #[test] - fn transport_failure_cannot_be_finalized_as_success() { - for (provider, client) in [ - (Wire::OpenAiChat, Wire::Anthropic), - (Wire::OpenAiChat, Wire::OpenAiResponses), - (Wire::Anthropic, Wire::OpenAiResponses), - ] { - let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); - let mut out = tc.fail("upstream stream transport error"); - out.push_str(&tc.finish()); - assert!(tc.done()); - assert!(!tc.succeeded()); - assert!(out.contains("upstream stream transport error")); - assert!(!out.contains("response.completed")); - assert!(!out.contains("event: message_stop")); - } - } - - #[test] - fn premature_clean_eof_fails_but_reported_stop_reasons_can_finalize() { - for (provider, client) in [ - (Wire::OpenAiChat, Wire::Anthropic), - (Wire::OpenAiChat, Wire::OpenAiResponses), - (Wire::Anthropic, Wire::OpenAiResponses), - ] { - let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); - tc.push(match provider { - Wire::Anthropic => { - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"# - } - _ => r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#, - }); - let out = tc.finish(); - assert!(tc.done()); - assert!(!tc.succeeded()); - assert!(out.contains("upstream stream ended before")); - assert!(!out.contains("response.completed")); - assert!(!out.contains("event: message_stop")); - } - - let mut chat_responses = - Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - chat_responses.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, - ); - let out = chat_responses.finish(); - assert!(out.contains("response.completed")); - assert!(chat_responses.succeeded()); - - let mut chat_anthropic = - Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); - chat_anthropic.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, - ); - let out = chat_anthropic.finish(); - assert!(out.contains("event: message_stop")); - assert!(chat_anthropic.succeeded()); - - let mut anthropic = - Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - anthropic.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}"#, - ); - let out = anthropic.finish(); - assert!(out.contains("response.completed")); - assert!(anthropic.succeeded()); - } - - #[test] - fn max_token_truncation_emits_incomplete_instead_of_completed() { - let mut chat = Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut chat_out = chat.push( - r#"data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":"length"}]}"#, - ); - chat_out.push_str(&chat.push("data: [DONE]")); - assert!(chat_out.contains("response.incomplete")); - assert!(chat_out.contains(r#""reason":"max_output_tokens""#)); - assert!(!chat_out.contains("response.completed")); - assert!(chat.done()); - assert!(!chat.succeeded()); - - let mut anthropic = - Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); - let mut anthropic_out = anthropic.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ); - anthropic_out.push_str(&anthropic.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}"#, - )); - anthropic_out.push_str(&anthropic.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8}}"#, - )); - anthropic_out.push_str(&anthropic.push(r#"data: {"type":"message_stop"}"#)); - assert!(anthropic_out.contains("response.incomplete")); - assert!(anthropic_out.contains(r#""reason":"max_output_tokens""#)); - assert!(!anthropic_out.contains("response.completed")); - assert!(anthropic.done()); - assert!(!anthropic.succeeded()); - } - - fn extended_tool_context() -> CodexToolContext { - CodexToolContext::from_request(&json!({ - "tools": [ - { "type": "custom", "name": "apply_patch", "description": "Apply a patch" }, - { "type": "namespace", "name": "multi_agent_v1", "tools": [ - { "type": "function", "name": "spawn_agent", "description": "Spawn", - "parameters": { "type": "object", "properties": { - "task_name": { "type": "string" } - }, "required": ["task_name"] } } - ] }, - { "type": "tool_search", "execution": "client", - "description": "Search deferred tools.", - "parameters": { "type": "object", "properties": { - "query": { "type": "string" } - }, "required": ["query"] } } - ] - })) - } - - #[test] - fn chat_stream_restores_custom_and_tool_search_calls() { - let mut tc = ChatToResponses::new_with_context("alias-x", extended_tool_context()); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"id":"chatcmpl-tools","choices":[{"index":0,"delta":{"reasoning_content":"choose tools"}}]}"#, - )); - out.push_str(&tc.push( - r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_patch","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin"}},{"index":1,"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\"}"}}]}}]}"#, - )); - out.push_str(&tc.push( - r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Patch\"}"}}]},"finish_reason":"tool_calls"}]}"#, - )); - out.push_str(&tc.push("data: [DONE]")); - - assert!(out.contains("event: response.custom_tool_call_input.delta")); - assert!(out.contains("event: response.custom_tool_call_input.done")); - assert!(out.contains(r#""type":"custom_tool_call""#)); - assert!(out.contains(r#""input":"*** Begin Patch""#)); - assert!(out.contains(r#""type":"tool_search_call""#)); - assert!(out.contains(r#""arguments":{"query":"browser"}"#)); - assert!(out.contains(r#""reasoning_content":"choose tools""#)); - assert!(out.contains(r#""type":"response.completed""#)); - } - - #[test] - fn anthropic_stream_restores_namespace_and_custom_calls() { - let mut tc = AnthropicToResponses::new_with_context("alias-x", extended_tool_context()); - let mut out = String::new(); - out.push_str(&tc.push( - r#"data: {"type":"message_start","message":{"id":"msg_tools","usage":{"input_tokens":3}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"delegate"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_spawn","name":"multi_agent_v1__spawn_agent","input":{}}}"#, - )); - out.push_str(&tc.push( - r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"task_name\":\"audit\"}"}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); - out.push_str(&tc.push( - r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_patch","name":"apply_patch","input":{"input":"*** Begin Patch"}}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); - out.push_str(&tc.push( - r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#, - )); - out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); - - assert!(out.contains(r#""type":"function_call""#)); - assert!(out.contains(r#""name":"spawn_agent""#)); - assert!(out.contains(r#""namespace":"multi_agent_v1""#)); - assert!(out.contains(r#""reasoning_content":"delegate""#)); - assert!(out.contains("event: response.custom_tool_call_input.delta")); - assert!(out.contains("event: response.custom_tool_call_input.done")); - assert!(out.contains(r#""type":"custom_tool_call""#)); - assert!(out.contains(r#""input":"*** Begin Patch""#)); - assert!(out.contains(r#""type":"response.completed""#)); - } -} diff --git a/src-tauri/src/protocol/stream/ablock.rs b/src-tauri/src/protocol/stream/ablock.rs new file mode 100644 index 0000000..178384d --- /dev/null +++ b/src-tauri/src/protocol/stream/ablock.rs @@ -0,0 +1,119 @@ +// Closing an Anthropic content block into its finished Responses item: argument reconciliation, +// the per-kind `*.done` event sequence, and the item payload itself. + +use super::anthropic_responses::{ABlock, AKind}; +use super::common::ev; +use super::resp_items::{resp_message_item, resp_reasoning_item}; +use super::super::openai_responses::{ + custom_tool_input_from_chat_arguments, CodexToolContext, CodexToolKind, +}; +use serde_json::{json, Value}; + +pub(super) fn ablock_tool_arguments(args: &str, start_args: &str) -> String { + if !args.trim().is_empty() { + args.to_string() + } else if !start_args.trim().is_empty() { + start_args.to_string() + } else { + "{}".to_string() + } +} + +/// The closing event sequence for one finished block (its `*.done` events + `output_item.done`). +pub(super) fn close_ablock_events( + b: &ABlock, + tool_context: &CodexToolContext, + reasoning: Option<&str>, +) -> String { + let mut out = String::new(); + match &b.kind { + AKind::Text { acc } => { + out.push_str(&ev( + "response.output_text.done", + json!({ "type": "response.output_text.done", "item_id": b.id, "output_index": b.index, + "content_index": 0, "text": acc }), + )); + out.push_str(&ev( + "response.content_part.done", + json!({ "type": "response.content_part.done", "item_id": b.id, "output_index": b.index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": acc } }), + )); + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_message_item(&b.id, acc) }), + )); + } + AKind::Tool { + call_id, + name, + args, + start_args, + } => { + let arguments = ablock_tool_arguments(args, start_args); + let item = tool_context.response_tool_item_with_reasoning( + &b.id, + "completed", + call_id, + name, + &arguments, + reasoning, + ); + match tool_context.kind_for_chat_name(name) { + CodexToolKind::Custom => { + let input = custom_tool_input_from_chat_arguments(&arguments); + if !input.is_empty() { + out.push_str(&ev( + "response.custom_tool_call_input.delta", + json!({ "type": "response.custom_tool_call_input.delta", "item_id": b.id, + "call_id": call_id, "output_index": b.index, "delta": input }), + )); + } + out.push_str(&ev( + "response.custom_tool_call_input.done", + json!({ "type": "response.custom_tool_call_input.done", "item_id": b.id, + "call_id": call_id, "output_index": b.index, "input": input }), + )); + } + CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { + out.push_str(&ev( + "response.function_call_arguments.done", + json!({ "type": "response.function_call_arguments.done", "item_id": b.id, + "output_index": b.index, "arguments": arguments }), + )); + } + } + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, + "item": item }), + )); + } + AKind::Think { acc } => { + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": b.index, "item": resp_reasoning_item(&b.id, acc) }), + )); + } + } + out +} + +pub(super) fn ablock_item(b: &ABlock, tool_context: &CodexToolContext, reasoning: Option<&str>) -> Value { + match &b.kind { + AKind::Text { acc } => resp_message_item(&b.id, acc), + AKind::Tool { + call_id, + name, + args, + start_args, + } => tool_context.response_tool_item_with_reasoning( + &b.id, + "completed", + call_id, + name, + &ablock_tool_arguments(args, start_args), + reasoning, + ), + AKind::Think { acc } => resp_reasoning_item(&b.id, acc), + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses.rs b/src-tauri/src/protocol/stream/anthropic_responses.rs new file mode 100644 index 0000000..fe3ae57 --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses.rs @@ -0,0 +1,104 @@ +// Anthropic Messages stream → OpenAI Responses stream: transcoder state and construction (Codex +// client, Anthropic upstream). The event loop lives in anthropic_responses_push.rs. + +use super::common::ev; +use super::super::openai_responses::CodexToolContext; +use serde_json::json; + +/// Stateful Anthropic-Messages-stream → OpenAI-Responses-stream transcoder (Codex client, +/// Anthropic upstream). Anthropic blocks map 1:1 onto Responses output items: text → +/// message/output_text, tool_use → function_call (input_json_delta fragments accumulate into the +/// arguments string), thinking → reasoning summary. Upstream `error` events surface as +/// `response.failed` so Codex aborts cleanly instead of timing out. +pub struct AnthropicToResponses { + pub(super) client_model: String, + pub(super) tool_context: CodexToolContext, + // Construction-time fallback. An upstream id may replace it only until response.created is + // emitted; afterward this id is immutable so every event in the response agrees. + pub(super) resp_id: String, + pub(super) created: bool, + pub(super) next_index: usize, + pub(super) blocks: Vec, + pub(super) input_tokens: i64, + pub(super) cached_tokens: i64, + pub(super) output_tokens: i64, + pub(super) stop_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct ABlock { + pub(super) a_index: u64, + pub(super) index: usize, + pub(super) id: String, + pub(super) kind: AKind, + pub(super) open: bool, +} + +pub(super) enum AKind { + Text { + acc: String, + }, + Tool { + call_id: String, + name: String, + args: String, + start_args: String, + }, + Think { + acc: String, + }, +} + +impl AnthropicToResponses { + pub fn new(client_model: &str) -> Self { + Self::new_with_context(client_model, CodexToolContext::default()) + } + + pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { + Self { + client_model: client_model.to_string(), + tool_context, + resp_id: super::super::uid("resp_ccbud"), + created: false, + next_index: 0, + blocks: vec![], + input_tokens: 0, + cached_tokens: 0, + output_tokens: 0, + stop_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn rid(&self) -> String { + self.resp_id.clone() + } + + pub(super) fn reasoning_text(&self) -> Option { + let text = self + .blocks + .iter() + .filter_map(|block| match &block.kind { + AKind::Think { acc } if !acc.trim().is_empty() => Some(acc.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + (!text.is_empty()).then_some(text) + } + + pub(super) fn ensure_created(&mut self, out: &mut String) { + if self.created { + return; + } + self.created = true; + let id = self.rid(); + out.push_str(&ev( + "response.created", + json!({ "type": "response.created", + "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), + )); + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_block.rs b/src-tauri/src/protocol/stream/anthropic_responses_block.rs new file mode 100644 index 0000000..40413ce --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_block.rs @@ -0,0 +1,108 @@ +// The `content_block_start` arm of AnthropicToResponses::push — lifted verbatim out of that +// function so no single file exceeds the module's size budget; called exactly once, from `push`. + +use super::anthropic_responses::{ABlock, AKind, AnthropicToResponses}; +use super::common::ev; +use super::resp_items::response_scoped_item_id; +use super::super::openai_responses::response_scoped_call_id; +use serde_json::{json, Value}; + +impl AnthropicToResponses { + pub(super) fn push_content_block_start(&mut self, evt: &Value, mut out: &mut String) { + self.ensure_created(&mut out); + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let cb = evt.get("content_block").cloned().unwrap_or(Value::Null); + let index = self.next_index; + match cb.get("type").and_then(|v| v.as_str()) { + Some("text") => { + self.next_index += 1; + let id = response_scoped_item_id("msg", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), + )); + out.push_str(&ev( + "response.content_part.added", + json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Text { acc: String::new() }, + open: true, + }); + } + Some("tool_use") => { + self.next_index += 1; + let call_id = response_scoped_call_id(&self.rid(), index); + let name = cb + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let id = self + .tool_context + .response_item_id(&name, &self.rid(), index); + let start_args = cb + .get("input") + .filter(|value| { + value.as_object().is_some_and(|object| !object.is_empty()) + }) + .map(Value::to_string) + .unwrap_or_default(); + let reasoning = self.reasoning_text(); + let item = self.tool_context.response_tool_item_with_reasoning( + &id, + "in_progress", + &call_id, + &name, + "", + reasoning.as_deref(), + ); + let mut item = item; + if item.get("type").and_then(Value::as_str) == Some("function_call") { + item["arguments"] = json!(""); + } + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": item }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Tool { + call_id, + name, + args: String::new(), + start_args, + }, + open: true, + }); + } + Some("thinking") => { + self.next_index += 1; + let id = response_scoped_item_id("rs", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "reasoning", "id": id, "summary": [] } }), + )); + self.blocks.push(ABlock { + a_index, + index, + id, + kind: AKind::Think { acc: String::new() }, + open: true, + }); + } + // redacted_thinking / server_tool_use / … have no Responses equivalent; their + // deltas find no block below and drop. + _ => {} + } + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_finish.rs b/src-tauri/src/protocol/stream/anthropic_responses_finish.rs new file mode 100644 index 0000000..aefc03c --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_finish.rs @@ -0,0 +1,91 @@ +// Terminal handling for AnthropicToResponses: closing still-open blocks and emitting the +// completed / incomplete / failed Responses event. + +use super::ablock::{ablock_item, close_ablock_events}; +use super::anthropic_responses::AnthropicToResponses; +use super::resp_items::{incomplete_reason, resp_completed, resp_failed, resp_incomplete}; +use serde_json::Value; + +impl AnthropicToResponses { + /// Close any still-open blocks and emit the appropriate terminal Responses event. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_created(&mut out); + let reasoning = self.reasoning_text(); + self.blocks.sort_by_key(|b| b.index); + for b in &mut self.blocks { + if b.open { + b.open = false; + out.push_str(&close_ablock_events( + b, + &self.tool_context, + reasoning.as_deref(), + )); + } + } + let output: Vec = self + .blocks + .iter() + .map(|block| ablock_item(block, &self.tool_context, reasoning.as_deref())) + .collect(); + if let Some(reason) = incomplete_reason(self.stop_reason.as_deref()) { + self.failed = true; + out.push_str(&resp_incomplete( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + reason, + )); + } else { + out.push_str(&resp_completed( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + )); + } + out + } + + /// Finalize a clean upstream EOF only after Anthropic reported a stop reason. A normal + /// `message_stop` calls `complete` directly; an EOF before both signals is truncated. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.stop_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before message_stop or a stop reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + let mut out = String::new(); + self.ensure_created(&mut out); + let id = self.rid(); + out.push_str(&resp_failed(&id, message)); + self.stopped = true; + self.failed = true; + out + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/anthropic_responses_push.rs b/src-tauri/src/protocol/stream/anthropic_responses_push.rs new file mode 100644 index 0000000..4dbd336 --- /dev/null +++ b/src-tauri/src/protocol/stream/anthropic_responses_push.rs @@ -0,0 +1,167 @@ +// The Anthropic-event loop of AnthropicToResponses. The `content_block_start` arm lives in +// anthropic_responses_block.rs. + +use super::ablock::close_ablock_events; +use super::anthropic_responses::{AKind, AnthropicToResponses}; +use super::common::ev; +use super::super::openai_responses::CodexToolKind; +use serde_json::{json, Value}; + +impl AnthropicToResponses { + /// Feed one raw upstream SSE line. Anthropic streams interleave `event:` and `data:` lines; + /// the data JSON's `type` mirrors the event name, so data lines alone drive the state machine. + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, + }; + if payload.is_empty() { + return out; + } + let evt: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + match evt.get("type").and_then(|v| v.as_str()) { + Some("message_start") => { + if let Some(m) = evt.get("message") { + if !self.created { + if let Some(id) = m + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.resp_id = format!("resp_{}", id); + } + } + if let Some(u) = m.get("usage") { + // Responses-style input_tokens includes cached reads; Anthropic reports + // them separately. + let base = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let cr = u + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let cc = u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + self.input_tokens = base + cr + cc; + self.cached_tokens = cr; + } + } + self.ensure_created(&mut out); + } + Some("content_block_start") => self.push_content_block_start(&evt, &mut out), + Some("content_block_delta") => { + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let delta = evt.get("delta").cloned().unwrap_or(Value::Null); + if let Some(b) = self + .blocks + .iter_mut() + .find(|b| b.a_index == a_index && b.open) + { + match (&mut b.kind, delta.get("type").and_then(|v| v.as_str())) { + (AKind::Text { acc }, Some("text_delta")) => { + if let Some(txt) = delta + .get("text") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + acc.push_str(txt); + out.push_str(&ev( + "response.output_text.delta", + json!({ "type": "response.output_text.delta", "item_id": b.id, + "output_index": b.index, "content_index": 0, "delta": txt }), + )); + } + } + (AKind::Tool { name, args, .. }, Some("input_json_delta")) => { + if let Some(pj) = delta + .get("partial_json") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + args.push_str(pj); + if self.tool_context.kind_for_chat_name(name) + != CodexToolKind::Custom + { + out.push_str(&ev( + "response.function_call_arguments.delta", + json!({ "type": "response.function_call_arguments.delta", "item_id": b.id, + "output_index": b.index, "delta": pj }), + )); + } + } + } + (AKind::Think { acc }, Some("thinking_delta")) => { + if let Some(th) = delta + .get("thinking") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + acc.push_str(th); + out.push_str(&ev( + "response.reasoning_summary_text.delta", + json!({ "type": "response.reasoning_summary_text.delta", "item_id": b.id, + "output_index": b.index, "summary_index": 0, "delta": th }), + )); + } + } + _ => {} // signature_delta etc. + } + } + } + Some("content_block_stop") => { + let a_index = evt.get("index").and_then(|v| v.as_u64()).unwrap_or(0); + let reasoning = self.reasoning_text(); + if let Some(b) = self + .blocks + .iter_mut() + .find(|b| b.a_index == a_index && b.open) + { + b.open = false; + out.push_str(&close_ablock_events( + b, + &self.tool_context, + reasoning.as_deref(), + )); + } + } + Some("message_delta") => { + if let Some(reason) = evt + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(Value::as_str) + { + self.stop_reason = Some(reason.to_string()); + } + if let Some(o) = evt + .get("usage") + .and_then(|u| u.get("output_tokens")) + .and_then(|v| v.as_i64()) + { + self.output_tokens = o; + } + } + Some("message_stop") => { + out.push_str(&self.complete()); + } + Some("error") => { + let msg = evt + .get("error") + .and_then(|e| e.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("upstream error"); + out.push_str(&self.fail(msg)); + } + _ => {} // ping etc. + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_anthropic.rs b/src-tauri/src/protocol/stream/chat_anthropic.rs new file mode 100644 index 0000000..7e4bb92 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_anthropic.rs @@ -0,0 +1,167 @@ +// OpenAI Chat stream → Anthropic Messages stream: transcoder state and the block/lifecycle +// bookkeeping around the event loop in chat_anthropic_push.rs. + +use super::common::{ev, map_stop, CapturedToolCall}; +use serde_json::{json, Value}; + +/// Stateful OpenAI-Chat-stream → Anthropic-stream transcoder. Feed each raw upstream SSE line to +/// `push`; call `finish` at end. Anthropic requires an ordered `message_start`, then content blocks +/// (each `content_block_start`/`_delta`/`_stop`), then `message_delta` + `message_stop`. We open a +/// text block on the first text delta and one tool_use block per OpenAI tool_call index, assigning +/// Anthropic block indices in first-appearance order. +pub struct ChatToAnthropic { + pub(super) client_model: String, + pub(super) started: bool, + // message id sent in message_start — from the upstream chunk id when it has one, else a + // generated unique id. Clients persist this id; it must never repeat across turns (usage + // analytics de-dupes assistant messages by id). + pub(super) msg_id: Option, + pub(super) next_index: usize, + // text block + pub(super) text_index: Option, + // openai tool_call index → (anthropic block index, open?) + pub(super) tools: Vec, + pub(super) input_tokens: i64, + pub(super) output_tokens: i64, + pub(super) finish_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct ToolSlot { + pub(super) oa_index: u64, + pub(super) an_index: usize, + pub(super) open: bool, + pub(super) id: String, + pub(super) name: String, + pub(super) thought_signature: Option, + pub(super) arguments: String, +} + +impl ChatToAnthropic { + pub fn new(client_model: &str) -> Self { + Self { + client_model: client_model.to_string(), + started: false, + msg_id: None, + next_index: 0, + text_index: None, + tools: vec![], + input_tokens: 0, + output_tokens: 0, + finish_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn ensure_started(&mut self, out: &mut String) { + if self.started { + return; + } + self.started = true; + let id = self + .msg_id + .get_or_insert_with(|| super::super::uid("msg_ccbud")) + .clone(); + out.push_str(&ev( + "message_start", + json!({ "type": "message_start", "message": { + "id": id, "type": "message", "role": "assistant", "model": self.client_model, + "content": [], "stop_reason": Value::Null, "stop_sequence": Value::Null, + "usage": { "input_tokens": self.input_tokens.max(0), "output_tokens": 0 }, + }}), + )); + } + + pub(super) fn open_text(&mut self, out: &mut String) -> usize { + if let Some(i) = self.text_index { + return i; + } + let idx = self.next_index; + self.next_index += 1; + self.text_index = Some(idx); + out.push_str(&ev("content_block_start", json!({ "type": "content_block_start", "index": idx, "content_block": { "type": "text", "text": "" } }))); + idx + } + + pub(super) fn captured_tool_calls(&self) -> Vec { + self.tools + .iter() + .map(|slot| CapturedToolCall { + call_id: slot.id.clone(), + name: slot.name.clone(), + arguments: slot.arguments.clone(), + thought_signature: slot.thought_signature.clone(), + }) + .collect() + } + + /// Close any open blocks and emit message_delta + message_stop. Idempotent. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_started(&mut out); + // close blocks in ascending Anthropic index order + let mut closes: Vec = vec![]; + if let Some(i) = self.text_index { + closes.push(i); + } + for s in &self.tools { + if s.open { + closes.push(s.an_index); + } + } + closes.sort_unstable(); + for i in closes { + out.push_str(&ev( + "content_block_stop", + json!({ "type": "content_block_stop", "index": i }), + )); + } + let had_tool = self.tools.iter().any(|s| s.open); + out.push_str(&ev( + "message_delta", + json!({ "type": "message_delta", + "delta": { "stop_reason": map_stop(self.finish_reason.as_deref(), had_tool), "stop_sequence": Value::Null }, + "usage": { "output_tokens": self.output_tokens.max(0) } }), + )); + out.push_str(&ev("message_stop", json!({ "type": "message_stop" }))); + out + } + + /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls + /// `complete` directly; an EOF without either signal is a truncated stream. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.finish_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before [DONE] or a finish reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + self.failed = true; + ev( + "error", + json!({ "type": "error", "error": { "type": "api_error", "message": message } }), + ) + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/chat_anthropic_push.rs b/src-tauri/src/protocol/stream/chat_anthropic_push.rs new file mode 100644 index 0000000..8af39b4 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_anthropic_push.rs @@ -0,0 +1,192 @@ +// The OpenAI-Chat-chunk event loop of ChatToAnthropic: one upstream SSE line in, Anthropic +// content-block events out. + +use super::chat_anthropic::{ChatToAnthropic, ToolSlot}; +use super::common::{ev, upstream_error_message}; +use serde_json::{json, Value}; + +impl ChatToAnthropic { + /// Feed one raw upstream SSE line (e.g. "data: {...}\n" or "data: [DONE]\n"). Returns the + /// Anthropic SSE text to forward (possibly empty). + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, // ignore "event:" lines / blanks; chat SSE carries data-only + }; + if payload.is_empty() { + return out; + } + if payload == "[DONE]" { + out.push_str(&self.complete()); + return out; + } + let chunk: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + if let Some(message) = upstream_error_message(&chunk) { + return self.fail(message); + } + if self.msg_id.is_none() { + if let Some(id) = chunk + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.msg_id = Some(format!("msg_{}", id)); + } + } + // usage may ride the final chunk (stream_options.include_usage) + if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { + self.input_tokens = u + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.input_tokens); + self.output_tokens = u + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.output_tokens); + } + let choice = chunk + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()); + let choice = match choice { + Some(c) => c, + None => return out, + }; + self.ensure_started(&mut out); + let delta = choice.get("delta").cloned().unwrap_or(Value::Null); + + // text delta + if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { + if !txt.is_empty() { + let idx = self.open_text(&mut out); + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", "index": idx, "delta": { "type": "text_delta", "text": txt } }))); + } + } + + // tool_call deltas (streamed in fragments, keyed by their OpenAI index) + if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { + // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider + // repeats the same id, each array item in this delta must claim a distinct slot. + let mut claimed_slots: Vec = vec![]; + for (fallback_index, tc) in tcs.iter().enumerate() { + let explicit_index = tc.get("index").and_then(|v| v.as_u64()); + let oa_index = explicit_index.unwrap_or(fallback_index as u64); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let args = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let thought_signature = super::super::json_thought_signature(tc); + + // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. + // In that case prefer the stable call id, then the call's position in this delta. + let pos = explicit_index + .and_then(|_| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .or_else(|| { + (!id.is_empty()) + .then(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.id == id && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .flatten() + }) + .or_else(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }); + let slot_idx = match pos { + Some(i) => i, + None => { + let an_index = self.next_index; + self.next_index += 1; + self.tools.push(ToolSlot { + oa_index, + an_index, + open: false, + id: String::new(), + name: String::new(), + thought_signature: None, + arguments: String::new(), + }); + self.tools.len() - 1 + } + }; + claimed_slots.push(slot_idx); + let (an_index, should_open, open_id, open_name) = { + let slot = &mut self.tools[slot_idx]; + if !id.is_empty() { + slot.id = id.to_string(); + } + if !name.is_empty() { + slot.name = name.to_string(); + } + if thought_signature.is_some() { + slot.thought_signature = thought_signature; + } + if !args.is_empty() { + slot.arguments.push_str(args); + } + let should_open = !slot.open; + if should_open { + slot.open = true; + } + ( + slot.an_index, + should_open, + slot.id.clone(), + slot.name.clone(), + ) + }; + if should_open { + out.push_str(&ev( + "content_block_start", + json!({ "type": "content_block_start", + "index": an_index, "content_block": { "type": "tool_use", + "id": open_id, "name": open_name, "input": {} } }), + )); + } + if !args.is_empty() { + out.push_str(&ev("content_block_delta", json!({ "type": "content_block_delta", + "index": an_index, "delta": { "type": "input_json_delta", "partial_json": args } }))); + } + } + } + + if let Some(fr) = choice.get("finish_reason").and_then(|v| v.as_str()) { + self.finish_reason = Some(fr.to_string()); + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses.rs b/src-tauri/src/protocol/stream/chat_responses.rs new file mode 100644 index 0000000..c6ddbd3 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses.rs @@ -0,0 +1,97 @@ +// OpenAI Chat stream → OpenAI Responses stream: transcoder state and construction (Codex client, +// chat upstream). The event loop lives in chat_responses_push.rs. + +use super::common::CapturedToolCall; +use super::super::openai_responses::CodexToolContext; + +/// Stateful OpenAI-Chat-stream → OpenAI-Responses-stream transcoder (Codex client, chat upstream). +/// Text deltas stream through as `response.output_text.delta`; provider reasoning deltas +/// (`reasoning_content` / `reasoning`) as `response.reasoning_summary_text.delta`; tool-call +/// fragments accumulate per OpenAI index (with the same no-index Gemini slot handling as +/// ChatToAnthropic, including thought-signature capture) and surface whole in +/// `response.output_item.done` — the only place Codex materializes items from. +pub struct ChatToResponses { + pub(super) client_model: String, + pub(super) tool_context: CodexToolContext, + // Construction-time fallback. An upstream id may replace it only until response.created is + // emitted; afterward this id is immutable so every event in the response agrees. + pub(super) resp_id: String, + pub(super) created: bool, + pub(super) next_index: usize, + pub(super) reasoning: Option, + pub(super) reasoning_open: bool, + pub(super) message: Option, + pub(super) tools: Vec, + pub(super) input_tokens: i64, + pub(super) cached_tokens: i64, + pub(super) output_tokens: i64, + pub(super) finish_reason: Option, + pub(super) stopped: bool, + pub(super) failed: bool, +} + +pub(super) struct TextItemAcc { + pub(super) index: usize, + pub(super) id: String, + pub(super) acc: String, +} + +pub(super) struct RespToolAcc { + pub(super) oa_index: u64, + pub(super) index: usize, + pub(super) id: String, + pub(super) upstream_call_id: String, + pub(super) call_id: String, + pub(super) name: String, + pub(super) args: String, + pub(super) thought_signature: Option, + pub(super) announced: bool, + pub(super) emitted_args_len: usize, +} + +impl ChatToResponses { + pub fn new(client_model: &str) -> Self { + Self::new_with_context(client_model, CodexToolContext::default()) + } + + pub fn new_with_context(client_model: &str, tool_context: CodexToolContext) -> Self { + Self { + client_model: client_model.to_string(), + tool_context, + resp_id: super::super::uid("resp_ccbud"), + created: false, + next_index: 0, + reasoning: None, + reasoning_open: false, + message: None, + tools: vec![], + input_tokens: 0, + cached_tokens: 0, + output_tokens: 0, + finish_reason: None, + stopped: false, + failed: false, + } + } + + pub(super) fn rid(&self) -> String { + self.resp_id.clone() + } + + /// The turn's tool calls (with any Gemini thought signatures sniffed from the chat stream), + /// keyed by the call_id the Responses client will echo back — feeds the gateway's + /// session-scoped signature cache exactly like ChatToAnthropic. Nameless slots are excluded, + /// matching what finish() emits (and therefore what the client can echo). + pub fn captured_tool_calls(&self) -> Vec { + self.tools + .iter() + .filter(|slot| !slot.name.is_empty()) + .map(|slot| CapturedToolCall { + call_id: slot.call_id.clone(), + name: slot.name.clone(), + arguments: slot.args.clone(), + thought_signature: slot.thought_signature.clone(), + }) + .collect() + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_finish.rs b/src-tauri/src/protocol/stream/chat_responses_finish.rs new file mode 100644 index 0000000..503f6d8 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_finish.rs @@ -0,0 +1,135 @@ +// Terminal handling for ChatToResponses: closing open items and emitting the completed / +// incomplete / failed Responses event. + +use super::chat_responses::ChatToResponses; +use super::common::ev; +use super::resp_items::{ + incomplete_reason, normalized_tool_arguments, resp_completed, resp_failed, resp_incomplete, + resp_message_item, resp_reasoning_item, +}; +use serde_json::{json, Value}; + +impl ChatToResponses { + /// Close open items in index order and emit the appropriate terminal Responses event. + pub(super) fn complete(&mut self) -> String { + if self.stopped { + return String::new(); + } + self.stopped = true; + let mut out = String::new(); + self.ensure_created(&mut out); + self.close_reasoning(&mut out); + if let Some(m) = &self.message { + out.push_str(&ev( + "response.output_text.done", + json!({ "type": "response.output_text.done", "item_id": m.id, "output_index": m.index, + "content_index": 0, "text": m.acc }), + )); + out.push_str(&ev( + "response.content_part.done", + json!({ "type": "response.content_part.done", "item_id": m.id, "output_index": m.index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": m.acc } }), + )); + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": m.index, + "item": resp_message_item(&m.id, &m.acc) }), + )); + } + for pos in 0..self.tools.len() { + self.announce_tool_if_ready(pos, &mut out); + } + // A slot whose name never arrived is model garbage the client cannot execute — and a + // nameless function_call echoed into the next request is rejected upstream. Skip it. + for slot in self + .tools + .iter() + .filter(|slot| slot.announced && !slot.name.is_empty()) + { + out.push_str(&self.close_tool_events(slot)); + } + let mut items: Vec<(usize, Value)> = vec![]; + if let Some(r) = &self.reasoning { + items.push((r.index, resp_reasoning_item(&r.id, &r.acc))); + } + if let Some(m) = &self.message { + items.push((m.index, resp_message_item(&m.id, &m.acc))); + } + for slot in self + .tools + .iter() + .filter(|slot| slot.announced && !slot.name.is_empty()) + { + items.push(( + slot.index, + self.tool_context.response_tool_item_with_reasoning( + &slot.id, + "completed", + &slot.call_id, + &slot.name, + normalized_tool_arguments(&slot.args), + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ), + )); + } + items.sort_by_key(|(i, _)| *i); + let output: Vec = items.into_iter().map(|(_, v)| v).collect(); + if let Some(reason) = incomplete_reason(self.finish_reason.as_deref()) { + self.failed = true; + out.push_str(&resp_incomplete( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + reason, + )); + } else { + out.push_str(&resp_completed( + &self.rid(), + &self.client_model, + output, + self.input_tokens, + self.cached_tokens, + self.output_tokens, + )); + } + out + } + + /// Finalize a clean upstream EOF only when a Chat finish reason was observed. `[DONE]` calls + /// `complete` directly; an EOF without either signal is a truncated stream. + pub fn finish(&mut self) -> String { + if self.stopped { + return String::new(); + } + if self.finish_reason.is_some() { + self.complete() + } else { + self.fail("upstream stream ended before [DONE] or a finish reason") + } + } + + pub(super) fn fail(&mut self, message: &str) -> String { + if self.stopped { + return String::new(); + } + let mut out = String::new(); + self.ensure_created(&mut out); + let id = self.rid(); + out.push_str(&resp_failed(&id, message)); + self.stopped = true; + self.failed = true; + out + } + + pub fn input_tokens(&self) -> i64 { + self.input_tokens + } + pub fn output_tokens(&self) -> i64 { + self.output_tokens + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_items.rs b/src-tauri/src/protocol/stream/chat_responses_items.rs new file mode 100644 index 0000000..3e221b8 --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_items.rs @@ -0,0 +1,135 @@ +// Responses item bookkeeping for ChatToResponses: opening the response, closing the reasoning +// item, and announcing / streaming / closing each tool call item. + +use super::chat_responses::{ChatToResponses, RespToolAcc}; +use super::common::ev; +use super::resp_items::{ + normalized_tool_arguments, resp_in_progress_tool_item, resp_reasoning_item, +}; +use super::super::openai_responses::{custom_tool_input_from_chat_arguments, CodexToolKind}; +use serde_json::json; + +impl ChatToResponses { + pub(super) fn ensure_created(&mut self, out: &mut String) { + if self.created { + return; + } + self.created = true; + let id = self.rid(); + out.push_str(&ev( + "response.created", + json!({ "type": "response.created", + "response": { "id": id, "object": "response", "status": "in_progress", "model": self.client_model } }), + )); + } + + pub(super) fn close_reasoning(&mut self, out: &mut String) { + if !self.reasoning_open { + return; + } + self.reasoning_open = false; + if let Some(r) = &self.reasoning { + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": r.index, + "item": resp_reasoning_item(&r.id, &r.acc) }), + )); + } + } + + pub(super) fn announce_tool_if_ready(&mut self, pos: usize, out: &mut String) { + let Some(slot) = self.tools.get(pos) else { + return; + }; + if slot.announced || slot.name.is_empty() { + return; + } + + let id = self + .tool_context + .response_item_id(&slot.name, &self.rid(), slot.index); + let item = resp_in_progress_tool_item( + &self.tool_context, + &id, + &slot.call_id, + &slot.name, + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ); + let index = slot.index; + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, "item": item }), + )); + + let slot = &mut self.tools[pos]; + slot.id = id; + slot.announced = true; + self.emit_pending_tool_arguments(pos, out); + } + + pub(super) fn emit_pending_tool_arguments(&mut self, pos: usize, out: &mut String) { + let Some(slot) = self.tools.get_mut(pos) else { + return; + }; + if !slot.announced + || slot.name.is_empty() + || self.tool_context.kind_for_chat_name(&slot.name) == CodexToolKind::Custom + || slot.emitted_args_len >= slot.args.len() + { + return; + } + let delta = slot.args[slot.emitted_args_len..].to_string(); + slot.emitted_args_len = slot.args.len(); + out.push_str(&ev( + "response.function_call_arguments.delta", + json!({ "type": "response.function_call_arguments.delta", "item_id": slot.id, + "output_index": slot.index, "delta": delta }), + )); + } + + pub(super) fn close_tool_events(&self, slot: &RespToolAcc) -> String { + let mut out = String::new(); + let arguments = normalized_tool_arguments(&slot.args); + let item = self.tool_context.response_tool_item_with_reasoning( + &slot.id, + "completed", + &slot.call_id, + &slot.name, + arguments, + self.reasoning + .as_ref() + .map(|reasoning| reasoning.acc.as_str()), + ); + match self.tool_context.kind_for_chat_name(&slot.name) { + CodexToolKind::Custom => { + let input = custom_tool_input_from_chat_arguments(arguments); + if !input.is_empty() { + out.push_str(&ev( + "response.custom_tool_call_input.delta", + json!({ "type": "response.custom_tool_call_input.delta", "item_id": slot.id, + "call_id": slot.call_id, "output_index": slot.index, "delta": input }), + )); + } + out.push_str(&ev( + "response.custom_tool_call_input.done", + json!({ "type": "response.custom_tool_call_input.done", "item_id": slot.id, + "call_id": slot.call_id, "output_index": slot.index, "input": input }), + )); + } + CodexToolKind::Function | CodexToolKind::Namespace | CodexToolKind::ToolSearch => { + out.push_str(&ev( + "response.function_call_arguments.done", + json!({ "type": "response.function_call_arguments.done", "item_id": slot.id, + "output_index": slot.index, "arguments": arguments }), + )); + } + } + out.push_str(&ev( + "response.output_item.done", + json!({ "type": "response.output_item.done", "output_index": slot.index, "item": item }), + )); + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_push.rs b/src-tauri/src/protocol/stream/chat_responses_push.rs new file mode 100644 index 0000000..bfdf87b --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_push.rs @@ -0,0 +1,145 @@ +// The OpenAI-Chat-chunk event loop of ChatToResponses: usage, reasoning deltas and text deltas. +// Tool-call fragment handling lives in chat_responses_tools.rs. + +use super::chat_responses::{ChatToResponses, TextItemAcc}; +use super::common::{ev, upstream_error_message}; +use super::resp_items::response_scoped_item_id; +use serde_json::{json, Value}; + +impl ChatToResponses { + /// Feed one raw upstream SSE line ("data: {...}" or "data: [DONE]"). Returns Responses SSE + /// text to forward (possibly empty). + pub fn push(&mut self, line: &str) -> String { + let mut out = String::new(); + if self.stopped { + return out; + } + let t = line.trim(); + let payload = match t.strip_prefix("data:") { + Some(p) => p.trim(), + None => return out, // chat SSE is data-only; ignore blanks/event: lines + }; + if payload.is_empty() { + return out; + } + if payload == "[DONE]" { + out.push_str(&self.complete()); + return out; + } + let chunk: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return out, + }; + if let Some(message) = upstream_error_message(&chunk) { + return self.fail(message); + } + if !self.created { + if let Some(id) = chunk + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.resp_id = format!("resp_{}", id); + } + } + // usage rides the final chunk (stream_options.include_usage) + if let Some(u) = chunk.get("usage").filter(|u| !u.is_null()) { + self.input_tokens = u + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.input_tokens); + self.output_tokens = u + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(self.output_tokens); + if let Some(c) = u + .pointer("/prompt_tokens_details/cached_tokens") + .and_then(|v| v.as_i64()) + { + self.cached_tokens = c; + } + } + let choice = match chunk + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + { + Some(c) => c, + None => return out, + }; + self.ensure_created(&mut out); + let delta = choice.get("delta").cloned().unwrap_or(Value::Null); + + // provider reasoning stream (DeepSeek/GLM-style `reasoning_content`, or `reasoning`) + let think = delta + .get("reasoning_content") + .and_then(|v| v.as_str()) + .or_else(|| delta.get("reasoning").and_then(|v| v.as_str())) + .unwrap_or(""); + if !think.is_empty() { + if self.reasoning.is_none() { + let index = self.next_index; + self.next_index += 1; + let id = response_scoped_item_id("rs", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "reasoning", "id": id, "summary": [] } }), + )); + self.reasoning = Some(TextItemAcc { + index, + id, + acc: String::new(), + }); + self.reasoning_open = true; + } + let r = self.reasoning.as_mut().unwrap(); + r.acc.push_str(think); + out.push_str(&ev( + "response.reasoning_summary_text.delta", + json!({ "type": "response.reasoning_summary_text.delta", "item_id": r.id, + "output_index": r.index, "summary_index": 0, "delta": think }), + )); + } + + // text delta + if let Some(txt) = delta.get("content").and_then(|v| v.as_str()) { + if !txt.is_empty() { + self.close_reasoning(&mut out); + if self.message.is_none() { + let index = self.next_index; + self.next_index += 1; + let id = response_scoped_item_id("msg", &self.rid(), index); + out.push_str(&ev( + "response.output_item.added", + json!({ "type": "response.output_item.added", "output_index": index, + "item": { "type": "message", "id": id, "status": "in_progress", "role": "assistant", "content": [] } }), + )); + out.push_str(&ev( + "response.content_part.added", + json!({ "type": "response.content_part.added", "item_id": id, "output_index": index, + "content_index": 0, "part": { "type": "output_text", "annotations": [], "text": "" } }), + )); + self.message = Some(TextItemAcc { + index, + id, + acc: String::new(), + }); + } + let m = self.message.as_mut().unwrap(); + m.acc.push_str(txt); + out.push_str(&ev( + "response.output_text.delta", + json!({ "type": "response.output_text.delta", "item_id": m.id, "output_index": m.index, + "content_index": 0, "delta": txt }), + )); + } + } + + self.push_tool_call_deltas(&delta, &mut out); + if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) { + self.finish_reason = Some(reason.to_string()); + } + out + } +} diff --git a/src-tauri/src/protocol/stream/chat_responses_tools.rs b/src-tauri/src/protocol/stream/chat_responses_tools.rs new file mode 100644 index 0000000..3fcf93e --- /dev/null +++ b/src-tauri/src/protocol/stream/chat_responses_tools.rs @@ -0,0 +1,111 @@ +// Tool-call fragment handling for ChatToResponses::push — lifted verbatim out of that function so +// no single file exceeds the module's size budget; called exactly once, from `push`. + +use super::chat_responses::{ChatToResponses, RespToolAcc}; +use super::super::openai_responses::response_scoped_call_id; +use serde_json::Value; + +impl ChatToResponses { + pub(super) fn push_tool_call_deltas(&mut self, delta: &Value, mut out: &mut String) { + // tool_call deltas (streamed in fragments, keyed by their OpenAI index) + if let Some(tcs) = delta.get("tool_calls").and_then(|v| v.as_array()) { + if !tcs.is_empty() { + self.close_reasoning(&mut out); + } + // A no-index Gemini chunk can contain multiple parallel calls. Even if a provider + // repeats the same id, each array item in this delta must claim a distinct slot + // (mirrors ChatToAnthropic). + let mut claimed_slots: Vec = vec![]; + for (fallback_index, tc) in tcs.iter().enumerate() { + let explicit_index = tc.get("index").and_then(|v| v.as_u64()); + let oa_index = explicit_index.unwrap_or(fallback_index as u64); + let frag_id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let frag_name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let args = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let thought_signature = super::super::json_thought_signature(tc); + + // Standard OpenAI chunks carry `index`; Gemini-compatible streams may omit it. + // In that case prefer the stable call id, then the call's position in this delta. + let pos = explicit_index + .and_then(|_| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .or_else(|| { + (!frag_id.is_empty()) + .then(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.upstream_call_id == frag_id + && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }) + .flatten() + }) + .or_else(|| { + self.tools + .iter() + .enumerate() + .find(|(index, slot)| { + slot.oa_index == oa_index && !claimed_slots.contains(index) + }) + .map(|(index, _)| index) + }); + let pos = match pos { + Some(p) => p, + None => { + let index = self.next_index; + self.next_index += 1; + let call_id = response_scoped_call_id(&self.rid(), index); + let slot = RespToolAcc { + oa_index, + index, + id: String::new(), + upstream_call_id: frag_id.to_string(), + call_id, + name: frag_name.to_string(), + args: String::new(), + thought_signature: None, + announced: false, + emitted_args_len: 0, + }; + self.tools.push(slot); + self.tools.len() - 1 + } + }; + claimed_slots.push(pos); + // stray late fragments may carry the id/name the opener lacked; the done item wins + if !frag_id.is_empty() { + self.tools[pos].upstream_call_id = frag_id.to_string(); + } + if !frag_name.is_empty() && self.tools[pos].name.is_empty() { + self.tools[pos].name = frag_name.to_string(); + } + if thought_signature.is_some() { + self.tools[pos].thought_signature = thought_signature; + } + if !args.is_empty() { + self.tools[pos].args.push_str(args); + } + self.announce_tool_if_ready(pos, &mut out); + self.emit_pending_tool_arguments(pos, &mut out); + } + } + } +} diff --git a/src-tauri/src/protocol/stream/common.rs b/src-tauri/src/protocol/stream/common.rs new file mode 100644 index 0000000..f5e9404 --- /dev/null +++ b/src-tauri/src/protocol/stream/common.rs @@ -0,0 +1,42 @@ +// Pieces shared by every transcoder: the captured-tool-call record the gateway reads back, SSE +// event framing, upstream error detection, and the OpenAI→Anthropic stop-reason mapping. + +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapturedToolCall { + pub call_id: String, + pub name: String, + pub arguments: String, + pub thought_signature: Option, +} + +pub(super) fn ev(event: &str, data: Value) -> String { + format!( + "event: {}\ndata: {}\n\n", + event, + serde_json::to_string(&data).unwrap_or_default() + ) +} + +pub(super) fn upstream_error_message(event: &Value) -> Option<&str> { + let error = event.get("error").filter(|value| !value.is_null()); + let is_error = error.is_some() || event.get("type").and_then(Value::as_str) == Some("error"); + if !is_error { + return None; + } + error + .and_then(|value| value.get("message").and_then(Value::as_str)) + .or_else(|| error.and_then(Value::as_str)) + .or_else(|| event.get("message").and_then(Value::as_str)) + .or(Some("upstream error")) +} + +pub(super) fn map_stop(finish: Option<&str>, had_tool: bool) -> &'static str { + match finish { + Some("length") => "max_tokens", + Some("tool_calls") | Some("function_call") => "tool_use", + _ if had_tool => "tool_use", + _ => "end_turn", + } +} diff --git a/src-tauri/src/protocol/stream/mod.rs b/src-tauri/src/protocol/stream/mod.rs new file mode 100644 index 0000000..48eb331 --- /dev/null +++ b/src-tauri/src/protocol/stream/mod.rs @@ -0,0 +1,45 @@ +// Incremental SSE transcoders (P2). Consume an upstream provider's streaming events line-by-line +// and emit the client protocol's SSE events as they arrive — true token-by-token streaming, not the +// buffer-then-synthesize first cut. Wired pairs (see `Transcoder`): +// - OpenAI Chat `chat.completion.chunk` → Anthropic Messages events (Claude Code client) +// - OpenAI Chat `chat.completion.chunk` → OpenAI Responses events (Codex client) +// - Anthropic Messages events → OpenAI Responses events (Codex client) + +mod ablock; +mod anthropic_responses; +mod anthropic_responses_block; +mod anthropic_responses_finish; +mod anthropic_responses_push; +mod chat_anthropic; +mod chat_anthropic_push; +mod chat_responses; +mod chat_responses_finish; +mod chat_responses_items; +mod chat_responses_push; +mod chat_responses_tools; +mod common; +mod resp_items; +mod transcoder; +#[cfg(test)] +mod tests_anthropic_responses; +#[cfg(test)] +mod tests_chat_anthropic; +#[cfg(test)] +mod tests_chat_responses; +#[cfg(test)] +mod tests_errors; +#[cfg(test)] +mod tests_extended; +#[cfg(test)] +mod tests_ids; + +// The three concrete transcoders stay reachable at their original +// crate::protocol::stream:: paths; gateway.rs drives them through `Transcoder`. +#[allow(unused_imports)] +pub use anthropic_responses::AnthropicToResponses; +#[allow(unused_imports)] +pub use chat_anthropic::ChatToAnthropic; +#[allow(unused_imports)] +pub use chat_responses::ChatToResponses; +pub use common::CapturedToolCall; +pub use transcoder::Transcoder; diff --git a/src-tauri/src/protocol/stream/resp_items.rs b/src-tauri/src/protocol/stream/resp_items.rs new file mode 100644 index 0000000..0df492d --- /dev/null +++ b/src-tauri/src/protocol/stream/resp_items.rs @@ -0,0 +1,122 @@ +// ---- shared Responses-side item builders (final `output_item.done` / `completed` payloads) ---- + +use super::common::ev; +use super::super::openai_responses::CodexToolContext; +use serde_json::{json, Value}; + +pub(super) fn resp_message_item(id: &str, text: &str) -> Value { + json!({ "type": "message", "id": id, "status": "completed", "role": "assistant", + "content": [{ "type": "output_text", "annotations": [], "text": text }] }) +} + +pub(super) fn resp_function_call_item(id: &str, call_id: &str, name: &str, args: &str) -> Value { + json!({ "type": "function_call", "id": id, "status": "completed", "call_id": call_id, + "name": name, "arguments": if args.is_empty() { "{}" } else { args } }) +} + +pub(super) fn resp_in_progress_tool_item( + context: &CodexToolContext, + id: &str, + call_id: &str, + name: &str, + reasoning: Option<&str>, +) -> Value { + let mut item = + context.response_tool_item_with_reasoning(id, "in_progress", call_id, name, "", reasoning); + if item.get("type").and_then(Value::as_str) == Some("function_call") { + item["arguments"] = json!(""); + } + item +} + +pub(super) fn normalized_tool_arguments(arguments: &str) -> &str { + if arguments.trim().is_empty() { + "{}" + } else { + arguments + } +} + +pub(super) fn resp_reasoning_item(id: &str, text: &str) -> Value { + json!({ "type": "reasoning", "id": id, "summary": [{ "type": "summary_text", "text": text }] }) +} + +pub(super) fn response_scoped_item_id(prefix: &str, response_id: &str, index: usize) -> String { + format!( + "{}_{}_{}", + prefix, + response_id.trim_start_matches("resp_"), + index + ) +} + +/// The terminal `response.completed` event. Codex parses `response.id` + `response.usage` from it +/// and treats a stream that closes without it as an error, so every Responses-emitting transcoder +/// must end with this exactly once. +pub(super) fn resp_completed( + id: &str, + model: &str, + output: Vec, + input: i64, + cached: i64, + output_tokens: i64, +) -> String { + ev( + "response.completed", + json!({ "type": "response.completed", "response": { + "id": id, "object": "response", "status": "completed", "model": model, + "output": output, + "usage": { + "input_tokens": input.max(0), + "input_tokens_details": { "cached_tokens": cached.max(0) }, + "output_tokens": output_tokens.max(0), + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": (input + output_tokens).max(0), + } } }), + ) +} + +pub(super) fn resp_incomplete( + id: &str, + model: &str, + output: Vec, + input: i64, + cached: i64, + output_tokens: i64, + reason: &str, +) -> String { + ev( + "response.incomplete", + json!({ "type": "response.incomplete", "response": { + "id": id, "object": "response", "status": "incomplete", "model": model, + "output": output, + "incomplete_details": { "reason": reason }, + "usage": { + "input_tokens": input.max(0), + "input_tokens_details": { "cached_tokens": cached.max(0) }, + "output_tokens": output_tokens.max(0), + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": (input + output_tokens).max(0), + } } }), + ) +} + +pub(super) fn incomplete_reason(stop_reason: Option<&str>) -> Option<&'static str> { + match stop_reason { + Some("length" | "max_tokens" | "model_context_window_exceeded") => { + Some("max_output_tokens") + } + Some("content_filter") => Some("content_filter"), + _ => None, + } +} + +pub(super) fn resp_failed(id: &str, message: &str) -> String { + ev( + "response.failed", + json!({ "type": "response.failed", "response": { + "id": id, "object": "response", "status": "failed", + "error": { "code": "upstream_error", "message": message } + } }), + ) +} diff --git a/src-tauri/src/protocol/stream/tests_anthropic_responses.rs b/src-tauri/src/protocol/stream/tests_anthropic_responses.rs new file mode 100644 index 0000000..1d672ae --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_anthropic_responses.rs @@ -0,0 +1,68 @@ +use super::*; +use super::super::openai_responses::response_scoped_call_id; + +#[test] +fn anthropic_to_responses_text_tool_and_thinking() { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push(r#"data: {"type":"message_start","message":{"id":"msg_9","usage":{"input_tokens":30,"cache_read_input_tokens":12,"cache_creation_input_tokens":0}}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Run"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ning."}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_1","name":"shell","input":{}}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}"#)); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); + out.push_str(&tc.push(r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":11}}"#)); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + + let created = out.find(r#""type":"response.created""#).unwrap(); + let completed = out.find(r#""type":"response.completed""#).unwrap(); + assert!(created < completed); + // thinking → reasoning summary deltas + item + assert!( + out.contains(r#""type":"response.reasoning_summary_text.delta""#) + && out.contains(r#""delta":"hmm""#) + ); + assert!(out.contains(r#""type":"summary_text""#)); + // text streams as deltas and closes with the full text + assert!(out.contains(r#""delta":"Run""#) && out.contains(r#""delta":"ning.""#)); + assert!(out.contains(r#""text":"Running.""#)); + // tool_use → function_call item with the reassembled arguments string + assert!( + out.contains(&format!( + r#""call_id":"{}""#, + response_scoped_call_id("resp_msg_9", 2) + )) && out.contains(r#""name":"shell""#) + ); + assert!(out.contains(r#""arguments":"{\"q\":\"x\"}""#)); + // usage: cached reads fold into input_tokens, detail carries them + assert!(out.contains(r#""input_tokens":42"#)); + assert!(out.contains(r#""cached_tokens":12"#)); + assert!(out.contains(r#""output_tokens":11"#)); + assert!(out.contains(r#""id":"resp_msg_9""#)); + assert_eq!(tc.input_tokens(), 42); + assert_eq!(tc.output_tokens(), 11); + // message_stop already completed the stream + assert_eq!(tc.finish(), ""); +} + +#[test] +fn anthropic_to_responses_error_is_terminal() { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":5}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, + )); + out.push_str(&tc.finish()); + assert!(out.contains(r#""type":"response.failed""#) && out.contains("Overloaded")); + // failed is terminal: no completed after it + assert!(!out.contains(r#""type":"response.completed""#)); +} diff --git a/src-tauri/src/protocol/stream/tests_chat_anthropic.rs b/src-tauri/src/protocol/stream/tests_chat_anthropic.rs new file mode 100644 index 0000000..10e9db3 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_chat_anthropic.rs @@ -0,0 +1,120 @@ +use super::*; +use super::super::Wire; + +#[test] +fn transcodes_text_and_split_tool_call() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + // role primer, then text, then a tool call split across two chunks, then finish + usage + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), + ); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"pa\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-stream-abc\"}}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9}}")); + out.push_str(&tc.push("data: [DONE]")); + + // ordered events present (serde_json sorts object keys, so assert on substrings, not key order) + assert!(out.contains("event: message_start")); + assert!( + out.find("event: message_start").unwrap() + < out.find("event: content_block_start").unwrap() + ); + assert!( + out.find("event: content_block_start").unwrap() + < out.find("event: message_delta").unwrap() + ); + assert!( + out.find("event: message_delta").unwrap() < out.find("event: message_stop").unwrap() + ); + // text block: a text content_block_start + its two text deltas + assert!(out.contains(r#""type":"text""#)); + assert!(out.contains("text_delta") && out.contains(r#""text":"Let me ""#)); + assert!(out.contains(r#""text":"check.""#)); + // tool block: tool_use start carries id+name; args reassembled across fragments + assert!(out.contains(r#""type":"tool_use""#)); + assert!(out.contains(r#""id":"call_1""#) && out.contains(r#""name":"read_file""#)); + assert!(out.contains("input_json_delta") && out.contains(r#""partial_json":"{\"pa""#)); + assert!(out.contains(r#""partial_json":"th\":\"a.txt\"}""#)); + // closes both blocks (index 0 text, index 1 tool), tool_use stop, usage, terminal stop + assert!(out.contains(r#""index":0,"type":"content_block_stop""#)); + assert!(out.contains(r#""index":1,"type":"content_block_stop""#)); + assert!(out.contains(r#""stop_reason":"tool_use""#)); + assert!(out.contains(r#""output_tokens":9"#)); + assert!(out.contains("event: message_stop")); + assert_eq!(tc.input_tokens(), 20); + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].call_id, "call_1"); + assert_eq!(captured[0].arguments, r#"{"path":"a.txt"}"#); + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-stream-abc") + ); +} + +#[test] +fn keeps_no_index_parallel_calls_with_the_same_id_distinct() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-same-id\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"same\\\"}\"}}]}}]}\n")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", + )); + out.push_str(&tc.push("data: [DONE]\n")); + + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 2); + assert_eq!(captured[0].arguments, r#"{"query":"same"}"#); + assert_eq!(captured[1].arguments, r#"{"query":"same"}"#); + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-same-id") + ); + assert!(captured[1].thought_signature.is_none()); + assert!(out.contains(r#""index":0,"type":"content_block_start""#)); + assert!(out.contains(r#""index":1,"type":"content_block_start""#)); +} + +#[test] +fn plain_text_only() { + let mut tc = ChatToAnthropic::new("claude-x"); + let mut out = String::new(); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}"), + ); + out.push_str(&tc.push("data: [DONE]")); + assert!(out.contains("text_delta") && out.contains(r#""text":"hello""#)); + assert!(out.contains(r#""stop_reason":"end_turn""#)); + assert!(out.contains("event: message_stop")); +} + +// The gateway's abort guard relies on done() flipping as soon as push() emits the terminal +// client event — that is the moment Responses clients (Codex) hang up, before upstream EOF. +#[test] +fn done_flips_on_terminal_event_before_eof() { + let mut tc = Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + tc.push("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":3}}}\n"); + tc.push("data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n"); + tc.push("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n"); + assert!(!tc.done()); + let out = tc.push("data: {\"type\":\"message_stop\"}\n"); + assert!(out.contains("response.completed")); + assert!(tc.done()); + + let mut tc = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"}}]}"); + assert!(!tc.done()); + let out = tc.push("data: [DONE]"); + assert!(out.contains("event: message_stop")); + assert!(tc.done()); +} diff --git a/src-tauri/src/protocol/stream/tests_chat_responses.rs b/src-tauri/src/protocol/stream/tests_chat_responses.rs new file mode 100644 index 0000000..7a4a4e3 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_chat_responses.rs @@ -0,0 +1,111 @@ +use super::*; +use super::super::openai_responses::response_scoped_call_id; + +#[test] +fn chat_to_responses_text_and_split_tool_call() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}")); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Let me \"}}]}"), + ); + out.push_str( + &tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"check.\"}}]}"), + ); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{\\\"co\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"mmand\\\":[\\\"ls\\\"]}\"}}]}}]}")); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":9,\"prompt_tokens_details\":{\"cached_tokens\":7}}}")); + out.push_str(&tc.push("data: [DONE]")); + + // ordered: created → text deltas → item done events → completed + let created = out.find(r#""type":"response.created""#).unwrap(); + let first_delta = out.find(r#""type":"response.output_text.delta""#).unwrap(); + let item_done = out.find(r#""type":"response.output_item.done""#).unwrap(); + let completed = out.find(r#""type":"response.completed""#).unwrap(); + assert!(created < first_delta && first_delta < item_done && item_done < completed); + // token-by-token text deltas + assert!(out.contains(r#""delta":"Let me ""#)); + assert!(out.contains(r#""delta":"check.""#)); + // Codex materializes items from output_item.done: full text + reassembled arguments + assert!(out.contains(r#""text":"Let me check.""#)); + let client_call_id = response_scoped_call_id("resp_chatcmpl-1", 1); + assert!( + out.contains(&format!(r#""call_id":"{}""#, client_call_id)) + && out.contains(r#""name":"shell""#) + ); + assert!(out.contains(r#""arguments":"{\"command\":[\"ls\"]}""#)); + // completed carries id + usage, incl. the prompt cache detail Codex reports + assert!(out.contains(r#""id":"resp_chatcmpl-1""#)); + assert!(out.contains(r#""input_tokens":20"#) && out.contains(r#""output_tokens":9"#)); + assert!(out.contains(r#""cached_tokens":7"#)); + assert_eq!(tc.input_tokens(), 20); + assert_eq!(tc.output_tokens(), 9); + // captured for the gateway's signature cache, keyed by the call_id Codex echoes back + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].call_id, client_call_id); + assert_eq!(captured[0].arguments, r#"{"command":["ls"]}"#); + // finish is idempotent — [DONE] already closed the stream + assert_eq!(tc.finish(), ""); +} + +// Gemini's OpenAI-compatible stream omits `index` and can repeat ids across parallel calls; +// before the fix every no-index fragment collapsed into slot 0 (one garbled call), so Codex +// never received usable tool calls from a Gemini chat upstream. +#[test] +fn chat_to_responses_keeps_no_index_parallel_calls_distinct() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"id\":\"chatcmpl-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"a\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"sig-parallel\"}}},{\"id\":\"same-call\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"b\\\"}\"}}]}}]}\n")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n", + )); + out.push_str(&tc.push("data: [DONE]\n")); + + // two distinct function_call items, each with its own arguments + assert!(out.contains(r#""output_index":0"#) && out.contains(r#""output_index":1"#)); + assert!(out.contains(r#""arguments":"{\"query\":\"a\"}""#)); + assert!(out.contains(r#""arguments":"{\"query\":\"b\"}""#)); + let captured = tc.captured_tool_calls(); + assert_eq!(captured.len(), 2); + assert_ne!(captured[0].call_id, captured[1].call_id); + assert_eq!( + captured[0].call_id, + response_scoped_call_id("resp_chatcmpl-2", 0) + ); + assert_eq!( + captured[1].call_id, + response_scoped_call_id("resp_chatcmpl-2", 1) + ); + assert!(out.contains(&format!(r#""call_id":"{}""#, captured[0].call_id))); + assert!(out.contains(&format!(r#""call_id":"{}""#, captured[1].call_id))); + assert_eq!(captured[0].arguments, r#"{"query":"a"}"#); + assert_eq!(captured[1].arguments, r#"{"query":"b"}"#); + // the Gemini thought signature is captured for the session cache (restore next turn) + assert_eq!( + captured[0].thought_signature.as_deref(), + Some("sig-parallel") + ); + assert!(captured[1].thought_signature.is_none()); +} + +// Some models emit tool-call fragments that never carry a function name; forwarding them +// gives Codex an unexecutable call whose echo the upstream then rejects — drop them instead. +#[test] +fn chat_to_responses_skips_nameless_tool_calls() { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = String::new(); + out.push_str(&tc.push("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{}\"}}]}}]}")); + out.push_str(&tc.push( + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", + )); + out.push_str(&tc.push("data: [DONE]")); + assert!(!out.contains("response.function_call_arguments.done")); + assert!( + out.contains(r#""output":[]"#), + "completed output stays empty: {}", + out + ); + assert!(out.contains(r#""type":"response.completed""#)); + assert!(tc.captured_tool_calls().is_empty()); +} diff --git a/src-tauri/src/protocol/stream/tests_errors.rs b/src-tauri/src/protocol/stream/tests_errors.rs new file mode 100644 index 0000000..e834f27 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_errors.rs @@ -0,0 +1,134 @@ +use super::*; +use super::super::Wire; + +#[test] +fn chat_error_events_are_terminal_for_translated_clients() { + let mut responses = + Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut responses_out = + responses.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); + responses_out.push_str( + &responses + .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), + ); + responses_out.push_str(&responses.finish()); + assert!(responses_out.contains(r#""type":"response.failed""#)); + assert!(responses_out.contains("upstream exploded")); + assert!(!responses_out.contains(r#""type":"response.completed""#)); + assert!(responses.done()); + assert!(!responses.succeeded()); + + let mut anthropic = Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + let mut anthropic_out = + anthropic.push(r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#); + anthropic_out.push_str( + &anthropic + .push(r#"data: {"error":{"type":"server_error","message":"upstream exploded"}}"#), + ); + anthropic_out.push_str(&anthropic.finish()); + assert!(anthropic_out.contains("event: error")); + assert!(anthropic_out.contains("upstream exploded")); + assert!(!anthropic_out.contains("event: message_stop")); + assert!(anthropic.done()); + assert!(!anthropic.succeeded()); +} + +#[test] +fn transport_failure_cannot_be_finalized_as_success() { + for (provider, client) in [ + (Wire::OpenAiChat, Wire::Anthropic), + (Wire::OpenAiChat, Wire::OpenAiResponses), + (Wire::Anthropic, Wire::OpenAiResponses), + ] { + let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); + let mut out = tc.fail("upstream stream transport error"); + out.push_str(&tc.finish()); + assert!(tc.done()); + assert!(!tc.succeeded()); + assert!(out.contains("upstream stream transport error")); + assert!(!out.contains("response.completed")); + assert!(!out.contains("event: message_stop")); + } +} + +#[test] +fn premature_clean_eof_fails_but_reported_stop_reasons_can_finalize() { + for (provider, client) in [ + (Wire::OpenAiChat, Wire::Anthropic), + (Wire::OpenAiChat, Wire::OpenAiResponses), + (Wire::Anthropic, Wire::OpenAiResponses), + ] { + let mut tc = Transcoder::new(provider, client, "alias-x").unwrap(); + tc.push(match provider { + Wire::Anthropic => { + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"# + } + _ => r#"data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}"#, + }); + let out = tc.finish(); + assert!(tc.done()); + assert!(!tc.succeeded()); + assert!(out.contains("upstream stream ended before")); + assert!(!out.contains("response.completed")); + assert!(!out.contains("event: message_stop")); + } + + let mut chat_responses = + Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + chat_responses.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, + ); + let out = chat_responses.finish(); + assert!(out.contains("response.completed")); + assert!(chat_responses.succeeded()); + + let mut chat_anthropic = + Transcoder::new(Wire::OpenAiChat, Wire::Anthropic, "claude-x").unwrap(); + chat_anthropic.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"done"},"finish_reason":"stop"}]}"#, + ); + let out = chat_anthropic.finish(); + assert!(out.contains("event: message_stop")); + assert!(chat_anthropic.succeeded()); + + let mut anthropic = + Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + anthropic.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}"#, + ); + let out = anthropic.finish(); + assert!(out.contains("response.completed")); + assert!(anthropic.succeeded()); +} + +#[test] +fn max_token_truncation_emits_incomplete_instead_of_completed() { + let mut chat = Transcoder::new(Wire::OpenAiChat, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut chat_out = chat.push( + r#"data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":"length"}]}"#, + ); + chat_out.push_str(&chat.push("data: [DONE]")); + assert!(chat_out.contains("response.incomplete")); + assert!(chat_out.contains(r#""reason":"max_output_tokens""#)); + assert!(!chat_out.contains("response.completed")); + assert!(chat.done()); + assert!(!chat.succeeded()); + + let mut anthropic = + Transcoder::new(Wire::Anthropic, Wire::OpenAiResponses, "alias-x").unwrap(); + let mut anthropic_out = anthropic.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ); + anthropic_out.push_str(&anthropic.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}"#, + )); + anthropic_out.push_str(&anthropic.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":8}}"#, + )); + anthropic_out.push_str(&anthropic.push(r#"data: {"type":"message_stop"}"#)); + assert!(anthropic_out.contains("response.incomplete")); + assert!(anthropic_out.contains(r#""reason":"max_output_tokens""#)); + assert!(!anthropic_out.contains("response.completed")); + assert!(anthropic.done()); + assert!(!anthropic.succeeded()); +} diff --git a/src-tauri/src/protocol/stream/tests_extended.rs b/src-tauri/src/protocol/stream/tests_extended.rs new file mode 100644 index 0000000..d208b6e --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_extended.rs @@ -0,0 +1,88 @@ +use super::*; +use super::super::openai_responses::CodexToolContext; +use serde_json::json; + +fn extended_tool_context() -> CodexToolContext { + CodexToolContext::from_request(&json!({ + "tools": [ + { "type": "custom", "name": "apply_patch", "description": "Apply a patch" }, + { "type": "namespace", "name": "multi_agent_v1", "tools": [ + { "type": "function", "name": "spawn_agent", "description": "Spawn", + "parameters": { "type": "object", "properties": { + "task_name": { "type": "string" } + }, "required": ["task_name"] } } + ] }, + { "type": "tool_search", "execution": "client", + "description": "Search deferred tools.", + "parameters": { "type": "object", "properties": { + "query": { "type": "string" } + }, "required": ["query"] } } + ] + })) +} + +#[test] +fn chat_stream_restores_custom_and_tool_search_calls() { + let mut tc = ChatToResponses::new_with_context("alias-x", extended_tool_context()); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"id":"chatcmpl-tools","choices":[{"index":0,"delta":{"reasoning_content":"choose tools"}}]}"#, + )); + out.push_str(&tc.push( + r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_patch","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin"}},{"index":1,"id":"call_search","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"browser\"}"}}]}}]}"#, + )); + out.push_str(&tc.push( + r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Patch\"}"}}]},"finish_reason":"tool_calls"}]}"#, + )); + out.push_str(&tc.push("data: [DONE]")); + + assert!(out.contains("event: response.custom_tool_call_input.delta")); + assert!(out.contains("event: response.custom_tool_call_input.done")); + assert!(out.contains(r#""type":"custom_tool_call""#)); + assert!(out.contains(r#""input":"*** Begin Patch""#)); + assert!(out.contains(r#""type":"tool_search_call""#)); + assert!(out.contains(r#""arguments":{"query":"browser"}"#)); + assert!(out.contains(r#""reasoning_content":"choose tools""#)); + assert!(out.contains(r#""type":"response.completed""#)); +} + +#[test] +fn anthropic_stream_restores_namespace_and_custom_calls() { + let mut tc = AnthropicToResponses::new_with_context("alias-x", extended_tool_context()); + let mut out = String::new(); + out.push_str(&tc.push( + r#"data: {"type":"message_start","message":{"id":"msg_tools","usage":{"input_tokens":3}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"delegate"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":0}"#)); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_spawn","name":"multi_agent_v1__spawn_agent","input":{}}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"task_name\":\"audit\"}"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":1}"#)); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_patch","name":"apply_patch","input":{"input":"*** Begin Patch"}}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"content_block_stop","index":2}"#)); + out.push_str(&tc.push( + r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + + assert!(out.contains(r#""type":"function_call""#)); + assert!(out.contains(r#""name":"spawn_agent""#)); + assert!(out.contains(r#""namespace":"multi_agent_v1""#)); + assert!(out.contains(r#""reasoning_content":"delegate""#)); + assert!(out.contains("event: response.custom_tool_call_input.delta")); + assert!(out.contains("event: response.custom_tool_call_input.done")); + assert!(out.contains(r#""type":"custom_tool_call""#)); + assert!(out.contains(r#""input":"*** Begin Patch""#)); + assert!(out.contains(r#""type":"response.completed""#)); +} diff --git a/src-tauri/src/protocol/stream/tests_ids.rs b/src-tauri/src/protocol/stream/tests_ids.rs new file mode 100644 index 0000000..cbdf6a9 --- /dev/null +++ b/src-tauri/src/protocol/stream/tests_ids.rs @@ -0,0 +1,190 @@ +use super::*; +use serde_json::Value; + +fn response_for_event(output: &str, event: &str) -> Value { + let event_line = format!("event: {}", event); + let frame = output + .split("\n\n") + .find(|frame| frame.lines().next() == Some(event_line.as_str())) + .unwrap_or_else(|| panic!("missing {event} event in {output}")); + let data = frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + serde_json::from_str::(data).unwrap()["response"].clone() +} + +fn response_id_for_event(output: &str, event: &str) -> String { + response_for_event(output, event)["id"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn chat_to_responses_response_ids_are_unique_and_stable() { + let mut first = ChatToResponses::new("alias-x"); + let mut second = ChatToResponses::new("alias-x"); + let first_fallback = first.resp_id.clone(); + let second_fallback = second.resp_id.clone(); + assert!(first_fallback.starts_with("resp_ccbud_")); + assert!(second_fallback.starts_with("resp_ccbud_")); + assert_ne!(first_fallback, second_fallback); + + let mut first_out = + first.push(r#"data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}"#); + first_out.push_str(&first.push( + r#"data: {"id":"chatcmpl-too-late","choices":[{"index":0,"delta":{"content":"hi"}}]}"#, + )); + first_out.push_str(&first.push("data: [DONE]")); + assert_eq!(first.resp_id, first_fallback); + assert_eq!( + response_id_for_event(&first_out, "response.created"), + first_fallback + ); + assert_eq!( + response_id_for_event(&first_out, "response.completed"), + first_fallback + ); + + let second_out = second.push("data: [DONE]"); + assert_eq!( + response_id_for_event(&second_out, "response.created"), + second_fallback + ); + assert_eq!( + response_id_for_event(&second_out, "response.completed"), + second_fallback + ); + + let mut upstream = ChatToResponses::new("alias-x"); + let upstream_fallback = upstream.resp_id.clone(); + let mut upstream_out = upstream.push( + r#"data: {"id":"chatcmpl-early","choices":[{"index":0,"delta":{"role":"assistant"}}]}"#, + ); + upstream_out.push_str(&upstream.push("data: [DONE]")); + assert_ne!(upstream.resp_id, upstream_fallback); + assert_eq!(upstream.resp_id, "resp_chatcmpl-early"); + assert_eq!( + response_id_for_event(&upstream_out, "response.created"), + "resp_chatcmpl-early" + ); + assert_eq!( + response_id_for_event(&upstream_out, "response.completed"), + "resp_chatcmpl-early" + ); +} + +#[test] +fn anthropic_to_responses_response_ids_are_unique_and_stable() { + let mut first = AnthropicToResponses::new("alias-x"); + let mut second = AnthropicToResponses::new("alias-x"); + let first_fallback = first.resp_id.clone(); + let second_fallback = second.resp_id.clone(); + assert!(first_fallback.starts_with("resp_ccbud_")); + assert!(second_fallback.starts_with("resp_ccbud_")); + assert_ne!(first_fallback, second_fallback); + + let mut first_out = first.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ); + first_out.push_str(&first.push( + r#"data: {"type":"message_start","message":{"id":"msg_too_late","usage":{"input_tokens":1}}}"#, + )); + first_out.push_str(&first.push(r#"data: {"type":"message_stop"}"#)); + assert_eq!(first.resp_id, first_fallback); + assert_eq!( + response_id_for_event(&first_out, "response.created"), + first_fallback + ); + assert_eq!( + response_id_for_event(&first_out, "response.completed"), + first_fallback + ); + + let second_out = second.push(r#"data: {"type":"message_stop"}"#); + assert_eq!( + response_id_for_event(&second_out, "response.created"), + second_fallback + ); + assert_eq!( + response_id_for_event(&second_out, "response.completed"), + second_fallback + ); + + let mut upstream = AnthropicToResponses::new("alias-x"); + let upstream_fallback = upstream.resp_id.clone(); + let mut upstream_out = upstream.push( + r#"data: {"type":"message_start","message":{"id":"msg_early","usage":{"input_tokens":1}}}"#, + ); + upstream_out.push_str(&upstream.push(r#"data: {"type":"message_stop"}"#)); + assert_ne!(upstream.resp_id, upstream_fallback); + assert_eq!(upstream.resp_id, "resp_msg_early"); + assert_eq!( + response_id_for_event(&upstream_out, "response.created"), + "resp_msg_early" + ); + assert_eq!( + response_id_for_event(&upstream_out, "response.completed"), + "resp_msg_early" + ); +} + +#[test] +fn streaming_response_item_ids_are_scoped_to_the_response() { + let chat = |upstream_id: &str| { + let mut tc = ChatToResponses::new("alias-x"); + let mut out = tc.push(&format!( + r#"data: {{"id":"{upstream_id}","choices":[{{"index":0,"delta":{{"reasoning_content":"think","content":"answer"}}}}]}}"# + )); + out.push_str(&tc.push("data: [DONE]")); + response_for_event(&out, "response.completed")["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(ToString::to_string) + .collect::>() + }; + let first_chat = chat("chatcmpl-first"); + let second_chat = chat("chatcmpl-second"); + assert_eq!(first_chat.len(), 2); + assert!(first_chat.iter().all(|id| id.contains("chatcmpl-first"))); + assert!(second_chat.iter().all(|id| id.contains("chatcmpl-second"))); + assert!(first_chat.iter().all(|id| !second_chat.contains(id))); + + let anthropic = |message_id: &str| { + let mut tc = AnthropicToResponses::new("alias-x"); + let mut out = tc.push(&format!( + r#"data: {{"type":"message_start","message":{{"id":"{message_id}","usage":{{"input_tokens":1}}}}}}"# + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think"}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}"#, + )); + out.push_str(&tc.push( + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}}"#, + )); + out.push_str(&tc.push(r#"data: {"type":"message_stop"}"#)); + response_for_event(&out, "response.completed")["output"] + .as_array() + .unwrap() + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(ToString::to_string) + .collect::>() + }; + let first_anthropic = anthropic("msg-first"); + let second_anthropic = anthropic("msg-second"); + assert_eq!(first_anthropic.len(), 2); + assert!(first_anthropic.iter().all(|id| id.contains("msg-first"))); + assert!(second_anthropic.iter().all(|id| id.contains("msg-second"))); + assert!(first_anthropic + .iter() + .all(|id| !second_anthropic.contains(id))); +} diff --git a/src-tauri/src/protocol/stream/transcoder.rs b/src-tauri/src/protocol/stream/transcoder.rs new file mode 100644 index 0000000..d3548c3 --- /dev/null +++ b/src-tauri/src/protocol/stream/transcoder.rs @@ -0,0 +1,123 @@ +// The (provider → client) transcoder dispatcher gateway.rs holds, regardless of the wired pair. + +use super::anthropic_responses::AnthropicToResponses; +use super::chat_anthropic::ChatToAnthropic; +use super::chat_responses::ChatToResponses; +use super::common::CapturedToolCall; +use super::super::openai_responses::CodexToolContext; +use super::super::Wire; + + +/// Dispatcher over the wired (provider → client) incremental transcoders, so gateway.rs holds one +/// value regardless of the pair. `supports` is the single source of truth behind +/// `protocol::can_transcode_stream`. +pub enum Transcoder { + ChatToAnthropic(ChatToAnthropic), + ChatToResponses(ChatToResponses), + AnthropicToResponses(AnthropicToResponses), +} + +impl Transcoder { + pub fn supports(provider: Wire, client: Wire) -> bool { + matches!( + (provider, client), + (Wire::OpenAiChat, Wire::Anthropic) + | (Wire::OpenAiChat, Wire::OpenAiResponses) + | (Wire::Anthropic, Wire::OpenAiResponses) + ) + } + + pub fn new(provider: Wire, client: Wire, client_model: &str) -> Option { + Self::new_with_context(provider, client, client_model, CodexToolContext::default()) + } + + pub fn new_with_context( + provider: Wire, + client: Wire, + client_model: &str, + tool_context: CodexToolContext, + ) -> Option { + match (provider, client) { + (Wire::OpenAiChat, Wire::Anthropic) => { + Some(Self::ChatToAnthropic(ChatToAnthropic::new(client_model))) + } + (Wire::OpenAiChat, Wire::OpenAiResponses) => Some(Self::ChatToResponses( + ChatToResponses::new_with_context(client_model, tool_context), + )), + (Wire::Anthropic, Wire::OpenAiResponses) => Some(Self::AnthropicToResponses( + AnthropicToResponses::new_with_context(client_model, tool_context), + )), + _ => None, + } + } + + pub fn push(&mut self, line: &str) -> String { + match self { + Self::ChatToAnthropic(t) => t.push(line), + Self::ChatToResponses(t) => t.push(line), + Self::AnthropicToResponses(t) => t.push(line), + } + } + + pub fn finish(&mut self) -> String { + match self { + Self::ChatToAnthropic(t) => t.finish(), + Self::ChatToResponses(t) => t.finish(), + Self::AnthropicToResponses(t) => t.finish(), + } + } + + /// Terminate a translated stream without allowing EOF finalization to synthesize success. + pub fn fail(&mut self, message: &str) -> String { + match self { + Self::ChatToAnthropic(t) => t.fail(message), + Self::ChatToResponses(t) => t.fail(message), + Self::AnthropicToResponses(t) => t.fail(message), + } + } + + pub fn input_tokens(&self) -> i64 { + match self { + Self::ChatToAnthropic(t) => t.input_tokens(), + Self::ChatToResponses(t) => t.input_tokens(), + Self::AnthropicToResponses(t) => t.input_tokens(), + } + } + + pub fn output_tokens(&self) -> i64 { + match self { + Self::ChatToAnthropic(t) => t.output_tokens(), + Self::ChatToResponses(t) => t.output_tokens(), + Self::AnthropicToResponses(t) => t.output_tokens(), + } + } + + pub fn captured_tool_calls(&self) -> Vec { + match self { + Self::ChatToAnthropic(t) => t.captured_tool_calls(), + Self::ChatToResponses(t) => t.captured_tool_calls(), + _ => vec![], + } + } + + /// True once the terminal client event (`message_stop` / `response.completed` / + /// `response.incomplete` / `response.failed`) has been emitted: the turn is semantically + /// complete even though the upstream socket may not have hit EOF yet — Responses clients + /// (Codex) hang up exactly at this point, so the gateway must not treat that disconnect as an + /// abort. + pub fn done(&self) -> bool { + match self { + Self::ChatToAnthropic(t) => t.stopped, + Self::ChatToResponses(t) => t.stopped, + Self::AnthropicToResponses(t) => t.stopped, + } + } + + pub fn succeeded(&self) -> bool { + match self { + Self::ChatToAnthropic(t) => t.stopped && !t.failed, + Self::ChatToResponses(t) => t.stopped && !t.failed, + Self::AnthropicToResponses(t) => t.stopped && !t.failed, + } + } +} diff --git a/src-tauri/src/protocol/tests.rs b/src-tauri/src/protocol/tests.rs new file mode 100644 index 0000000..20d6020 --- /dev/null +++ b/src-tauri/src/protocol/tests.rs @@ -0,0 +1,153 @@ +use super::*; +use llm_connector::types::ChatRequest; +use serde_json::{json, Value}; + +#[test] +fn upstream_urls_respect_the_configured_base() { + let cases = [ + (Wire::Anthropic, "/messages"), + (Wire::OpenAiChat, "/chat/completions"), + (Wire::OpenAiResponses, "/responses"), + ]; + for (wire, endpoint) in cases { + for base in [ + "https://example.com", + "https://example.com/v1", + "https://example.com/v4", + "https://generativelanguage.googleapis.com/v1beta/openai", + ] { + assert_eq!(wire.upstream_url(base), format!("{}{}", base, endpoint)); + } + } +} + +#[test] +fn v1_fallback_is_only_offered_for_unversioned_bases() { + assert_eq!( + Wire::OpenAiChat.v1_fallback_url("https://example.com/api"), + Some("https://example.com/api/v1/chat/completions".to_string()) + ); + for base in [ + "https://example.com/v1", + "https://example.com/v4/", + "https://example.com/v1beta", + "https://example.com/V2alpha", + "https://generativelanguage.googleapis.com/v1beta/openai", + ] { + assert_eq!(Wire::OpenAiChat.v1_fallback_url(base), None, "{base}"); + } +} + +#[test] +fn canonical_request_endpoints_exclude_auxiliary_routes() { + assert_eq!(Wire::from_request_endpoint("/v1/messages"), Some(Wire::Anthropic)); + assert_eq!(Wire::from_request_endpoint("/v1/chat/completions"), Some(Wire::OpenAiChat)); + assert_eq!(Wire::from_request_endpoint("/v1/responses"), Some(Wire::OpenAiResponses)); + assert_eq!( + Wire::from_request_endpoint("/v1/responses/compact"), + Some(Wire::OpenAiResponses) + ); + assert_eq!( + Wire::OpenAiResponses.upstream_url_for_request( + "https://example.com/v1", + "/v1/responses/compact", + ), + "https://example.com/v1/responses/compact" + ); + assert_eq!(Wire::from_request_endpoint("/v1/messages/count_tokens"), None); + assert_eq!(Wire::from_request_endpoint("/v1/models"), None); +} + +#[test] +fn v1_fallback_statuses_exclude_non_path_errors() { + for status in [400, 404, 405] { + assert!(should_try_v1_fallback(status)); + } + for status in [401, 403, 413, 415, 422, 429, 500] { + assert!(!should_try_v1_fallback(status)); + } +} + +// Kimi/Moonshot and DeepSeek thinking models 400 on assistant tool-call history missing +// `reasoning_content`: real reasoning must survive the Responses→chat bridge, and turns whose +// reasoning didn't survive get the placeholder. +#[test] +fn chat_bodies_backfill_tool_call_reasoning() { + let codex = json!({ + "model": "m", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "go" }] }, + { "type": "function_call", "call_id": "c1", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c1", "output": "ok" }, + { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "real thoughts" }] }, + { "type": "function_call", "call_id": "c2", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "c2", "output": "ok" } + ] + }); + let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); + let body = encode_upstream_request(Wire::OpenAiChat, &ir, "kimi-k2-thinking", true).unwrap(); + let assistants: Vec<_> = body["messages"].as_array().unwrap().iter() + .filter(|m| m["role"] == "assistant").collect(); + assert_eq!(assistants.len(), 2); + // step 1 lost its reasoning → placeholder; step 2's bridged reasoning is preserved + assert_eq!(assistants[0]["reasoning_content"], "tool call"); + assert_eq!(assistants[1]["reasoning_content"], "real thoughts"); +} + +#[test] +fn glm_chat_uses_native_thinking_switch() { + let codex = json!({ + "model": "gpt-5.4", + "input": [{ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "inspect" }] + }], + "reasoning": { "effort": "ultra" } + }); + let ir = decode_client_request(Wire::OpenAiResponses, &codex).unwrap(); + let body = encode_upstream_request(Wire::OpenAiChat, &ir, "glm-5.2", true).unwrap(); + assert_eq!(body["thinking"]["type"], "enabled"); + assert!(body.get("reasoning_effort").is_none()); +} + +#[test] +fn gemini_thought_signature_maps_between_openai_wire_and_ir() { + let signature = "sig-regression-abc"; + let upstream_response = json!({ + "id": "chatcmpl-gemini", "object": "chat.completion", "created": 1, + "model": "google/gemini-3-flash-preview", + "choices": [{ "index": 0, "finish_reason": "tool_calls", "message": { + "role": "assistant", "content": Value::Null, + "tool_calls": [{ + "id": "default_api:Bash", "type": "function", + "function": { "name": "default_api:Bash", "arguments": "{\"command\":\"pwd\"}" }, + "extra_content": { "google": { "thought_signature": signature } } + }] + }}], + "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 } + }); + + let ir = decode_upstream_response(Wire::OpenAiChat, &upstream_response.to_string()).unwrap(); + let call = &ir.choices[0].message.tool_calls.as_ref().unwrap()[0]; + assert_eq!(tool_call_thought_signature(call).as_deref(), Some(signature)); + assert_eq!(json_thought_signature(&json!({ + "thought_signature": "", "function": { "thought_signature": signature } + })).as_deref(), Some(signature)); + + let mut message = llm_connector::types::Message::new( + llm_connector::types::Role::Assistant, + vec![], + ); + message.tool_calls = Some(vec![call.clone()]); + let next_ir = ChatRequest::new("gemini").with_messages(vec![message]); + let outgoing = encode_upstream_request( + Wire::OpenAiChat, &next_ir, "google/gemini-3-flash-preview", false, + ).unwrap(); + let assistant = outgoing["messages"].as_array().unwrap().iter() + .find(|message| message["role"] == "assistant").unwrap(); + let outgoing_call = &assistant["tool_calls"][0]; + assert_eq!(outgoing_call["extra_content"]["google"]["thought_signature"], signature); + assert!(outgoing_call.get("thought_signature").is_none()); + assert!(outgoing_call["function"].get("thought_signature").is_none()); +} diff --git a/src-tauri/src/protocol/wire.rs b/src-tauri/src/protocol/wire.rs new file mode 100644 index 0000000..15298d0 --- /dev/null +++ b/src-tauri/src/protocol/wire.rs @@ -0,0 +1,143 @@ +// The wire protocol a request or provider speaks, its endpoint URLs, and the `/v1` compatibility +// fallback (some providers publish an unversioned base that 404s until `/v1` is appended). + +use axum::http::Uri; + +/// A wire protocol a request or provider speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wire { + Anthropic, + OpenAiChat, + OpenAiResponses, +} + +impl Wire { + /// The provider's declared protocol (config `protocol` field). Unknown / absent → Anthropic, + /// which is today's passthrough default. + pub fn from_provider(s: Option<&str>) -> Wire { + match s { + Some("openai-chat") => Wire::OpenAiChat, + Some("openai-responses") => Wire::OpenAiResponses, + _ => Wire::Anthropic, + } + } + + /// The client's protocol, inferred from the inbound request path. Claude Code hits + /// `/v1/messages`; an OpenAI/Codex client hits `/v1/chat/completions` or `/v1/responses`. + pub fn from_request_path(uri: &Uri) -> Wire { + let p = uri.path().trim_end_matches('/'); + if p.ends_with("/responses") || p.ends_with("/responses/compact") { + Wire::OpenAiResponses + } else if p.contains("/chat/completions") { + Wire::OpenAiChat + } else { + Wire::Anthropic + } + } + + /// Short human label for exchange records / monitor UI. + pub fn label(self) -> &'static str { + match self { + Wire::Anthropic => "anthropic", + Wire::OpenAiChat => "openai-chat", + Wire::OpenAiResponses => "openai-responses", + } + } + + /// The bare endpoint appended to the provider's configured baseUrl. + pub fn endpoint_path(self) -> &'static str { + match self { + Wire::Anthropic => "/messages", + Wire::OpenAiChat => "/chat/completions", + Wire::OpenAiResponses => "/responses", + } + } + + /// Full upstream URL, treating the configured baseUrl as authoritative. + pub fn upstream_url(self, base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + format!("{}{}", base, self.endpoint_path()) + } + + /// Compatibility URL for configurations created when ccbud implicitly inserted `/v1`. + /// A versioned baseUrl (`v1`, `v4`, `v1beta`, …), or Google's `/openai` compatibility root, + /// must never receive another version segment. + pub fn v1_fallback_url(self, base_url: &str) -> Option { + let base = base_url.trim_end_matches('/'); + if base_url_has_version_suffix(base_url) + || (self == Wire::OpenAiChat && base.ends_with("/openai")) + { + return None; + } + Some(format!("{}/v1{}", base, self.endpoint_path())) + } + + /// Match only the three request endpoints that may be safely rebased onto a provider URL. + /// Models, count_tokens, HEAD, and unknown routes keep the generic passthrough path. + pub fn from_request_endpoint(path: &str) -> Option { + match path.trim_end_matches('/') { + "/messages" | "/v1/messages" => Some(Wire::Anthropic), + "/chat/completions" | "/v1/chat/completions" => Some(Wire::OpenAiChat), + "/responses" | "/v1/responses" | "/responses/compact" | "/v1/responses/compact" => { + Some(Wire::OpenAiResponses) + } + _ => None, + } + } + + pub fn request_endpoint_path(self, inbound_path: &str) -> &'static str { + if self == Wire::OpenAiResponses + && inbound_path.trim_end_matches('/').ends_with("/responses/compact") + { + "/responses/compact" + } else { + self.endpoint_path() + } + } + + pub fn upstream_url_for_request(self, base_url: &str, inbound_path: &str) -> String { + let base = base_url.trim_end_matches('/'); + format!("{}{}", base, self.request_endpoint_path(inbound_path)) + } + + pub fn v1_fallback_url_for_request( + self, + base_url: &str, + inbound_path: &str, + ) -> Option { + let base = base_url.trim_end_matches('/'); + if base_url_has_version_suffix(base_url) + || (self == Wire::OpenAiChat && base.ends_with("/openai")) + { + return None; + } + Some(format!("{}/v1{}", base, self.request_endpoint_path(inbound_path))) + } +} + +fn base_url_has_version_suffix(base_url: &str) -> bool { + let clean = base_url + .split(['?', '#']) + .next() + .unwrap_or(base_url) + .trim_end_matches('/'); + let after_authority = clean + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(clean); + let Some((_, path)) = after_authority.split_once('/') else { + return false; + }; + let Some(segment) = path.rsplit('/').find(|segment| !segment.is_empty()) else { + return false; + }; + let mut chars = segment.chars(); + matches!(chars.next(), Some('v' | 'V')) + && matches!(chars.next(), Some(c) if c.is_ascii_digit()) +} + +/// Statuses commonly used by upstreams for an unrecognized or unsupported endpoint path. +/// Authentication, validation, payload-size, and rate-limit errors intentionally do not qualify. +pub fn should_try_v1_fallback(status: u16) -> bool { + matches!(status, 400 | 404 | 405) +} diff --git a/src-tauri/src/qoder.rs b/src-tauri/src/qoder.rs deleted file mode 100644 index 27955d7..0000000 --- a/src-tauri/src/qoder.rs +++ /dev/null @@ -1,1200 +0,0 @@ -// Qoder CLI session support — Qoder writes Claude-like transcripts into its own trees -// (`~/.qoder/projects//.jsonl` and the same layout under `~/.qoderwork`, -// subagents in `/subagents/agent-*.jsonl`). Qoder streams assistant content as atomic -// wrappers and stores title/workspace/runtime metadata inline, so this module provides the small -// normalization layer needed by the normal Claude pipeline, plus root discovery, safe reads, -// path routing, and the shared foreign-CLI sidecar. The source files belong to another tool and -// are never rewritten, which also means hard-delete refuses them (history.rs). - -#![allow(dead_code)] - -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::fs; -use std::io; -#[cfg(target_os = "macos")] -use std::io::Read; -use std::path::{Path, PathBuf}; -#[cfg(target_os = "macos")] -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex, OnceLock}; -#[cfg(target_os = "macos")] -use std::time::{Duration, Instant}; - -/// Keep the privileged helper path bounded even if the on-disk file changes while it is read. -/// This is deliberately much larger than normal transcripts, while still preventing an -/// accidental/untrusted child process from filling the app's memory with stdout. -const MAX_READ_BYTES: usize = 256 * 1024 * 1024; - -/// Hard deadline for one helper invocation — generous for a MAX_READ_BYTES read, but bounded so -/// a wedged helper can never pin a sync command thread (and with it the renderer's coalesced -/// request slot for that session) until app restart. Batches get longer since they serve many -/// files in one spawn. -#[cfg(target_os = "macos")] -const HELPER_TIMEOUT: Duration = Duration::from_secs(15); -#[cfg(target_os = "macos")] -const HELPER_BATCH_TIMEOUT: Duration = Duration::from_secs(45); - -/// Byte budget for the helper-read cache (see helper_cache) — cleared wholesale when exceeded, -/// mirroring the search cache's crude-but-safe policy. -const HELPER_CACHE_BUDGET: usize = 256 * 1024 * 1024; - -const QODER_READ_SCRIPT: &str = - "const fs=require(\"fs\");process.stdout.write(fs.readFileSync(process.argv[1]))"; - -/// Batch counterpart: one line of JSON per argv file — content as base64 (`b64`) or a per-file -/// error (`err`) that must not abort the rest of the batch. -#[cfg(target_os = "macos")] -const QODER_BATCH_READ_SCRIPT: &str = "const fs=require(\"fs\");for(const p of process.argv.slice(1)){let line;try{line=JSON.stringify({p,b64:fs.readFileSync(p).toString(\"base64\")})}catch(e){line=JSON.stringify({p,err:String(e&&e.code||e)})}process.stdout.write(line+\"\\n\")}"; - -fn home() -> PathBuf { - std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")) -} - -/// Qoder's two known data roots (both observed in the wild): each is a history-dir entry -/// candidate for the auto-add migration; browsing itself walks every configured dir's -/// `projects/` tree, so these only seed historyDirs. -pub fn default_root() -> PathBuf { - home().join(".qoder") -} - -pub fn work_root() -> PathBuf { - home().join(".qoderwork") -} - -/// A qoder install exists at `root` when its projects tree is on disk. -pub fn root_exists(root: &Path) -> bool { - root.join("projects").is_dir() -} - -fn denied(message: impl Into) -> io::Error { - io::Error::new(io::ErrorKind::PermissionDenied, message.into()) -} - -fn too_large() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Qoder data file exceeds the {} MiB read limit", - MAX_READ_BYTES / 1024 / 1024 - ), - ) -} - -/// Canonicalize a prospective helper target and prove that it is a JSON/JSONL data file in a -/// `projects` directory directly below a `.qoder` or `.qoderwork` root. Canonicalizing the root, -/// projects directory, and target separately prevents a symlink below `projects` from escaping -/// into an arbitrary part of the filesystem. -fn validated_qoder_data_path(path: &Path) -> io::Result<(PathBuf, PathBuf)> { - let projects = path - .ancestors() - .find(|ancestor| { - ancestor - .file_name() - .map(|name| name == "projects") - .unwrap_or(false) - && ancestor - .parent() - .and_then(Path::file_name) - .map(|name| name == ".qoder" || name == ".qoderwork") - .unwrap_or(false) - }) - .ok_or_else(|| denied("helper reads are limited to .qoder/.qoderwork projects trees"))?; - let root = projects - .parent() - .ok_or_else(|| denied("Qoder projects directory has no data root"))?; - - let canonical_root = fs::canonicalize(root)?; - let canonical_projects = fs::canonicalize(projects)?; - if canonical_projects.parent() != Some(canonical_root.as_path()) { - return Err(denied("Qoder projects directory escapes its data root")); - } - - let canonical_path = fs::canonicalize(path)?; - let relative = canonical_path - .strip_prefix(&canonical_projects) - .map_err(|_| denied("Qoder data path escapes its projects directory"))?; - if relative.as_os_str().is_empty() { - return Err(denied("Qoder data path must name a file below projects")); - } - - let is_data_file = matches!( - canonical_path - .extension() - .and_then(|extension| extension.to_str()), - Some("json") | Some("jsonl") - ); - if !is_data_file { - return Err(denied("Qoder helper only reads JSON and JSONL data files")); - } - - let metadata = fs::metadata(&canonical_path)?; - if !metadata.is_file() { - return Err(denied("Qoder data path is not a regular file")); - } - if metadata.len() > MAX_READ_BYTES as u64 { - return Err(too_large()); - } - - Ok((canonical_path, canonical_root)) -} - -#[cfg(target_os = "macos")] -fn helper_from_install_dir(install_dir: &Path) -> io::Result { - let canonical_dir = fs::canonicalize(install_dir)?; - if !fs::metadata(&canonical_dir)?.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "Qoder CLI helper install directory is not a directory", - )); - } - - let version_path = fs::canonicalize(canonical_dir.join("version.txt"))?; - if version_path.parent() != Some(canonical_dir.as_path()) { - return Err(denied( - "Qoder CLI version file escapes its install directory", - )); - } - let version = fs::read_to_string(version_path)?; - let version = version.trim(); - if version.is_empty() - || version.len() > 64 - || !version - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Qoder CLI version.txt contains an invalid version", - )); - } - - let helper = fs::canonicalize(canonical_dir.join(format!("qodercli-{version}")))?; - if helper.parent() != Some(canonical_dir.as_path()) || !fs::metadata(&helper)?.is_file() { - return Err(denied("Qoder CLI helper escapes its install directory")); - } - Ok(helper) -} - -#[cfg(target_os = "macos")] -fn installed_qoder_helper(current_root: &Path) -> io::Result { - let current_install = current_root.join("bin").join("qodercli"); - let home_install = default_root().join("bin").join("qodercli"); - - let mut last_error = None; - for install_dir in [¤t_install, &home_install] { - match helper_from_install_dir(install_dir) { - Ok(helper) => return Ok(helper), - Err(error) => last_error = Some(error), - } - } - Err(last_error.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "installed Qoder CLI helper was not found", - ) - })) -} - -/// The helper lives in a user-writable tree, so before executing it must prove it wasn't planted: -/// owned by the same uid as $HOME, neither it nor its directory group/world-writable, a valid -/// strict codesign signature, and a real TeamIdentifier (rejects ad-hoc-signed payloads). Same-uid -/// malware can defeat any same-uid check by definition — the goal is to stop weaker writers and -/// unsigned binaries, and to guarantee a signing-identity trail for anything that does run. -/// codesign hashes the whole (large) binary, so the verdict is memoized per (path, mtime, size). -#[cfg(target_os = "macos")] -fn verify_helper_trust(helper: &Path) -> io::Result<()> { - static VERDICTS: OnceLock>> = OnceLock::new(); - let meta = fs::metadata(helper)?; - let (mt, size) = (mtime_ms_of(&meta), meta.len()); - let cache = VERDICTS.get_or_init(|| Mutex::new(HashMap::new())); - if let Ok(guard) = cache.lock() { - if let Some((cmt, csz, ok)) = guard.get(helper) { - if *cmt == mt && *csz == size { - return if *ok { - Ok(()) - } else { - Err(denied("Qoder CLI helper previously failed trust verification")) - }; - } - } - } - let verdict = helper_trust_checks(helper, &meta); - if let Ok(mut guard) = cache.lock() { - guard.insert(helper.to_path_buf(), (mt, size, verdict.is_ok())); - } - verdict -} - -#[cfg(target_os = "macos")] -fn helper_trust_checks(helper: &Path, meta: &fs::Metadata) -> io::Result<()> { - use std::os::unix::fs::MetadataExt; - let home_uid = fs::metadata(home())?.uid(); - let dir_meta = match helper.parent() { - Some(dir) => fs::metadata(dir)?, - None => return Err(denied("Qoder CLI helper has no install directory")), - }; - for (what, m) in [("helper", meta), ("helper directory", &dir_meta)] { - if m.uid() != home_uid { - return Err(denied(format!("Qoder CLI {what} is not owned by the current user"))); - } - if m.mode() & 0o022 != 0 { - return Err(denied(format!("Qoder CLI {what} is group/world writable"))); - } - } - // codesign is Apple's own bounded tool — a plain blocking call is fine here. - let valid = Command::new("/usr/bin/codesign") - .args(["--verify", "--strict", "--"]) - .arg(helper) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status()?; - if !valid.success() { - return Err(denied("Qoder CLI helper has no valid code signature")); - } - let display = Command::new("/usr/bin/codesign") - .args(["-d", "--verbose=2", "--"]) - .arg(helper) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output()?; // codesign -d prints its details on stderr - let info = String::from_utf8_lossy(&display.stderr); - let has_team = info.lines().any(|line| { - let line = line.trim_start(); - line.starts_with("TeamIdentifier=") && line != "TeamIdentifier=not set" - }); - if !has_team { - return Err(denied("Qoder CLI helper is not signed with a developer Team ID")); - } - Ok(()) -} - -/// Poll the child for exit until `deadline`; None = still running when time ran out. -#[cfg(target_os = "macos")] -fn wait_deadline( - child: &mut std::process::Child, - deadline: Instant, -) -> io::Result> { - loop { - if let Some(status) = child.try_wait()? { - return Ok(Some(status)); - } - if Instant::now() >= deadline { - return Ok(None); - } - std::thread::sleep(Duration::from_millis(10)); - } -} - -/// Run a prepared helper command with a hard deadline. stdout drains on its own thread (bounded -/// at `stdout_cap`), stderr on another (an 8 KiB diagnostic tail, then discarded so a chatty -/// child can't deadlock on a full pipe), the child is killed at the deadline or on an oversized -/// stream, and exit is polled — a helper that closes stdout but never exits still can't pin the -/// calling thread. -#[cfg(target_os = "macos")] -fn run_helper_bounded( - mut cmd: Command, - stdout_cap: usize, - timeout: Duration, - expected_len: usize, -) -> io::Result> { - use std::sync::mpsc; - let timed_out = || io::Error::new(io::ErrorKind::TimedOut, "Qoder CLI helper timed out"); - let mut child = cmd - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let deadline = Instant::now() + timeout; - let mut stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Qoder CLI stdout was not captured"))?; - let (tx, rx) = mpsc::channel(); - // Pre-allocate for the expected payload, capped — metadata could claim MAX_READ_BYTES and an - // upfront 256 MiB allocation per read is needless; Vec growth covers honest large files. - let prealloc = expected_len.min(8 * 1024 * 1024); - let cap = stdout_cap as u64; - let reader = std::thread::spawn(move || { - let mut bytes = Vec::with_capacity(prealloc); - let result = stdout.by_ref().take(cap + 1).read_to_end(&mut bytes); - let _ = tx.send((result, bytes)); - }); - let (etx, erx) = mpsc::channel(); - let stderr_reader = child.stderr.take().map(|mut pipe| { - std::thread::spawn(move || { - let mut tail = Vec::with_capacity(1024); - let _ = pipe.by_ref().take(8192).read_to_end(&mut tail); - let _ = io::copy(&mut pipe, &mut io::sink()); - let _ = etx.send(tail); - }) - }); - let kill = |child: &mut std::process::Child| { - let _ = child.kill(); - let _ = child.wait(); - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - let (read_result, bytes) = match rx.recv_timeout(remaining) { - Ok(outcome) => outcome, - Err(_) => { - kill(&mut child); // pipe closes → reader threads unblock and exit - let _ = reader.join(); - return Err(timed_out()); - } - }; - let _ = reader.join(); - if let Err(error) = read_result { - kill(&mut child); - return Err(error); - } - if bytes.len() > stdout_cap { - kill(&mut child); - return Err(too_large()); - } - let status = match wait_deadline(&mut child, deadline)? { - Some(status) => status, - None => { - kill(&mut child); - return Err(timed_out()); - } - }; - let stderr_tail = stderr_reader - .and_then(|_| erx.recv_timeout(Duration::from_millis(200)).ok()) - .unwrap_or_default(); - if !status.success() { - let detail = String::from_utf8_lossy(&stderr_tail); - let detail = detail.trim(); - return Err(io::Error::new( - io::ErrorKind::Other, - if detail.is_empty() { - format!("Qoder CLI helper exited with status {status}") - } else { - format!("Qoder CLI helper exited with status {status}: {detail}") - }, - )); - } - Ok(bytes) -} - -#[cfg(target_os = "macos")] -fn read_with_qoder_helper(path: &Path) -> io::Result> { - let (path, root) = validated_qoder_data_path(path)?; - let helper = installed_qoder_helper(&root)?; - verify_helper_trust(&helper)?; - let expected_len = fs::metadata(&path)?.len().min(MAX_READ_BYTES as u64) as usize; - - // Qoder CLI is a Bun executable. Passing the fixed program and target as distinct argv - // entries is important: never interpolate a path into JavaScript or a shell command. - let mut cmd = Command::new(helper); - cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_READ_SCRIPT).arg(&path); - run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_TIMEOUT, expected_len) -} - -/// Read a local history file. The normal filesystem path is always attempted first; macOS may -/// fall back to Qoder's already-installed CLI only for a permission denial, and only after the -/// helper target passes the strict projects-tree validation above. -pub(crate) fn read_bytes(path: &Path) -> io::Result> { - match fs::read(path) { - Ok(bytes) if bytes.len() <= MAX_READ_BYTES => Ok(bytes), - Ok(_) => Err(too_large()), - Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { - #[cfg(target_os = "macos")] - { - // Serve repeat reads of the same file version from the helper cache — stat still - // works on content-protected files, so (mtime, size) is a valid freshness key. - let stamp = file_stamp(path).ok(); - if let Some((mt, size)) = stamp { - if let Some(hit) = helper_cache_get(path, mt, size) { - return Ok(hit); - } - } - read_with_qoder_helper(path) - .map(|bytes| { - if let Some((mt, size)) = stamp { - helper_cache_put(path, mt, size, Arc::new(bytes.clone())); - } - bytes - }) - .map_err(|helper_error| { - if matches!( - helper_error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied - ) { - // The original file read was a permission failure. A missing helper - // must not turn that into NotFound, which callers interpret as a - // moved file. - io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "Qoder data is not directly readable and its CLI helper is unavailable: {helper_error}" - ), - ) - } else { - // Size/encoding failures, timeouts, and abnormal helper exits retain - // their distinct classification so the UI reports a read failure, - // not an auth hint. - helper_error - } - }) - } - #[cfg(not(target_os = "macos"))] - { - Err(error) - } - } - Err(error) => Err(error), - } -} - -/// UTF-8 text counterpart to [`read_bytes`]. Qoder's JSON/JSONL data is defined as UTF-8, so -/// malformed data is reported rather than silently replacing bytes and corrupting records. -pub(crate) fn read_text(path: &Path) -> io::Result { - String::from_utf8(read_bytes(path)?) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) -} - -fn mtime_ms_of(meta: &fs::Metadata) -> f64 { - meta.modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) -} - -fn file_stamp(path: &Path) -> io::Result<(f64, u64)> { - let meta = fs::metadata(path)?; - Ok((mtime_ms_of(&meta), meta.len())) -} - -/// Bytes fetched through the macOS helper, memoized by (mtime, size) — the list/search/detail -/// paths and the 4s live-follow tick otherwise each pay a bun startup for the SAME file version. -/// Stat keeps working on content-protected files (discovery depends on it), so the stamp is the -/// same freshness signal the list-meta memo uses. -struct HelperCache { - map: HashMap>)>, - bytes: usize, -} - -fn helper_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HelperCache { map: HashMap::new(), bytes: 0 })) -} - -fn helper_cache_get(path: &Path, mt: f64, size: u64) -> Option> { - let cache = helper_cache().lock().ok()?; - let (cmt, csz, bytes) = cache.map.get(path)?; - (*cmt == mt && *csz == size).then(|| bytes.as_ref().clone()) -} - -fn helper_cache_put(path: &Path, mt: f64, size: u64, bytes: Arc>) { - if let Ok(mut cache) = helper_cache().lock() { - if cache.bytes + bytes.len() > HELPER_CACHE_BUDGET { - cache.map.clear(); - cache.bytes = 0; - } - let len = bytes.len(); - if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, size, bytes)) { - cache.bytes = cache.bytes.saturating_sub(old.len()); - } - cache.bytes += len; - } -} - -/// Minimal standard-alphabet base64 decoder for the batch helper's output (node/bun emit padded -/// base64 without line breaks; stray CR/LF are tolerated anyway). -fn b64_decode(s: &str) -> Option> { - fn val(b: u8) -> Option { - match b { - b'A'..=b'Z' => Some((b - b'A') as u32), - b'a'..=b'z' => Some((b - b'a' + 26) as u32), - b'0'..=b'9' => Some((b - b'0' + 52) as u32), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - } - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len() / 4 * 3); - let mut chunk = [0u32; 4]; - let mut n = 0usize; - let mut pad = 0usize; - for &b in bytes { - if b == b'\r' || b == b'\n' { - continue; - } - if b == b'=' { - pad += 1; - chunk[n] = 0; - } else { - if pad > 0 { - return None; // data after padding - } - chunk[n] = val(b)?; - } - n += 1; - if n == 4 { - let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; - out.push((v >> 16) as u8); - if pad < 2 { - out.push((v >> 8) as u8); - } - if pad < 1 { - out.push(v as u8); - } - n = 0; - if pad > 0 { - break; - } - } - } - (n == 0).then_some(out) -} - -/// Warm the helper cache for many qoder files with ONE helper invocation per data root — the -/// list, search, usage, and subagent scans otherwise pay one bun startup per file on a protected -/// macOS install (the measured stall is seconds for a first refresh). Directly-readable and -/// fresh-cached files are skipped; per-file failures fall back to the on-demand single read. -/// No-op off macOS. -pub(crate) fn prefetch(paths: &[PathBuf]) { - #[cfg(target_os = "macos")] - prefetch_macos(paths); - #[cfg(not(target_os = "macos"))] - let _ = paths; -} - -#[cfg(target_os = "macos")] -fn prefetch_macos(paths: &[PathBuf]) { - // (canonical helper target, original cache key, stamp) - let mut by_root: HashMap> = HashMap::new(); - for path in paths { - let Ok((mt, size)) = file_stamp(path) else { continue }; - if size > MAX_READ_BYTES as u64 || helper_cache_get(path, mt, size).is_some() { - continue; - } - match fs::File::open(path) { - Ok(_) => continue, // direct reads work — the ordinary path is cheap - Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} - Err(_) => continue, - } - let Ok((canonical, root)) = validated_qoder_data_path(path) else { continue }; - by_root.entry(root).or_default().push((canonical, path.clone(), mt, size)); - } - for (root, files) in by_root { - let Ok(helper) = installed_qoder_helper(&root) else { continue }; - if verify_helper_trust(&helper).is_err() { - continue; - } - // Small argv chunks keep each spawn's total output within the shared byte cap and far - // below ARG_MAX; a lost chunk (timeout/oversize) degrades to per-file reads, not failure. - for chunk in files.chunks(32) { - let mut cmd = Command::new(&helper); - cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_BATCH_READ_SCRIPT); - for (canonical, _, _, _) in chunk { - cmd.arg(canonical); - } - let Ok(out) = run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_BATCH_TIMEOUT, 0) else { - continue; - }; - let by_canonical: HashMap<&Path, (&PathBuf, f64, u64)> = chunk - .iter() - .map(|(canonical, original, mt, size)| (canonical.as_path(), (original, *mt, *size))) - .collect(); - for line in out.split(|b| *b == b'\n') { - let Ok(row) = serde_json::from_slice::(line) else { continue }; - let Some(p) = row.get("p").and_then(Value::as_str) else { continue }; - let Some(&(original, mt, size)) = by_canonical.get(Path::new(p)) else { continue }; - let Some(bytes) = row.get("b64").and_then(Value::as_str).and_then(b64_decode) else { - continue; // per-file err rows fall back to the single-read path on demand - }; - helper_cache_put(original, mt, size, Arc::new(bytes)); - } - } - } -} - -/// Container-shape test for routing: a .jsonl anywhere under a `.qoder/projects/` or -/// `.qoderwork/projects/` tree (main sessions AND `/subagents/agent-*.jsonl`). -pub fn looks_qoder_path(file: &Path) -> bool { - if file.extension().and_then(|e| e.to_str()) != Some("jsonl") { - return false; - } - let mut child: Option<&std::ffi::OsStr> = None; - for anc in file.ancestors().skip(1) { - let name = match anc.file_name() { - Some(n) => n, - None => break, - }; - if (name == ".qoder" || name == ".qoderwork") - && child.map(|c| c == "projects").unwrap_or(false) - { - return true; - } - child = Some(name); - } - false -} - -/// The session uuid (its file stem) — sidecar key and renderer id both build on it. -fn session_stem(file: &Path) -> String { - file.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string() -} - -fn sidecar_key(file: &Path) -> String { - format!("qoder:{}", session_stem(file)) -} - -fn trimmed_string(value: Option<&Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -fn latest_inline_string(records: &[Value], record_type: &str, field: &str) -> Option { - records.iter().rev().find_map(|record| { - (record.get("type").and_then(Value::as_str) == Some(record_type)) - .then(|| trimmed_string(record.get(field))) - .flatten() - }) -} - -fn text_content(value: &Value) -> Option { - if let Some(text) = value.as_str() { - let text = text.trim(); - return (!text.is_empty()).then(|| text.to_owned()); - } - if let Some(blocks) = value.as_array() { - let parts: Vec<&str> = blocks - .iter() - .filter_map(|block| { - if let Some(text) = block.as_str() { - return Some(text); - } - let kind = block.get("type").and_then(Value::as_str).unwrap_or(""); - matches!(kind, "text" | "input_text") - .then(|| block.get("text").and_then(Value::as_str)) - .flatten() - }) - .map(str::trim) - .filter(|part| !part.is_empty()) - .collect(); - if !parts.is_empty() { - return Some(parts.join("\n")); - } - } - value - .get("text") - .and_then(Value::as_str) - .map(str::trim) - .filter(|text| !text.is_empty()) - .map(str::to_owned) -} - -fn summary_from(records: &[Value]) -> Option { - records.iter().rev().find_map(|record| { - if record.get("type").and_then(Value::as_str) != Some("summary") { - return None; - } - trimmed_string(record.get("summary")) - .or_else(|| record.get("content").and_then(text_content)) - .or_else(|| { - record - .get("message") - .and_then(|message| message.get("content")) - .and_then(text_content) - }) - }) -} - -fn first_user_text_from(records: &[Value]) -> Option { - records - .iter() - .find_map(|record| match record.get("type").and_then(Value::as_str) { - Some("user") - if record.get("isMeta").and_then(Value::as_bool) != Some(true) - && record.get("isCompactSummary").and_then(Value::as_bool) != Some(true) => - { - record - .get("message") - .and_then(|message| message.get("content")) - .and_then(text_content) - } - Some("attachment") - if record - .get("attachment") - .and_then(|attachment| attachment.get("type")) - .and_then(Value::as_str) - == Some("queued_command") => - { - trimmed_string( - record - .get("attachment") - .and_then(|attachment| attachment.get("prompt")), - ) - } - _ => None, - }) -} - -/// Qoder's inline title, in the same precedence used by its own conversation list. Repeated -/// metadata records are append-only updates, so the last non-empty value wins within each tier. -pub(crate) fn session_title_from(records: &[Value]) -> Option { - latest_inline_string(records, "custom-title", "customTitle") - .or_else(|| latest_inline_string(records, "ai-title", "aiTitle")) - .or_else(|| latest_inline_string(records, "last-prompt", "lastPrompt")) - .or_else(|| summary_from(records)) - .or_else(|| first_user_text_from(records)) -} - -/// Primary workspace from Qoder's latest inline `workspace-directories` record. -pub(crate) fn working_dir_from(records: &[Value]) -> Option { - records.iter().rev().find_map(|record| { - if record.get("type").and_then(Value::as_str) != Some("workspace-directories") { - return None; - } - record - .get("directories") - .and_then(Value::as_array) - .and_then(|directories| { - directories - .iter() - .find_map(|value| trimmed_string(Some(value))) - }) - }) -} - -/// Effective model from Qoder's latest inline `runtime-config` update. -pub(crate) fn model_from(records: &[Value]) -> Option { - latest_inline_string(records, "runtime-config", "model") -} - -fn has_value(value: &Value) -> bool { - match value { - Value::Null => false, - Value::String(value) => !value.trim().is_empty(), - Value::Array(value) => !value.is_empty(), - Value::Object(value) => !value.is_empty(), - Value::Bool(_) | Value::Number(_) => true, - } -} - -fn without_redacted_thinking(record: &Value) -> Value { - let mut record = record.clone(); - if let Some(content) = record - .get_mut("message") - .and_then(|message| message.get_mut("content")) - .and_then(Value::as_array_mut) - { - content - .retain(|block| block.get("type").and_then(Value::as_str) != Some("redacted_thinking")); - } - record -} - -fn merge_assistant_wrapper(target: &mut Value, wrapper: &Value) { - let Some(source_message) = wrapper.get("message").and_then(Value::as_object) else { - return; - }; - let Some(target_message) = target.get_mut("message").and_then(Value::as_object_mut) else { - return; - }; - - if let Some(source_content) = source_message.get("content").and_then(Value::as_array) { - match target_message.get_mut("content") { - Some(Value::Array(target_content)) => { - target_content.extend(source_content.iter().cloned()) - } - Some(Value::Null) | None => { - target_message.insert("content".to_string(), Value::Array(source_content.clone())); - } - Some(_) => {} - } - } - - for field in ["model", "usage", "stop_reason"] { - if let Some(value) = source_message.get(field).filter(|value| has_value(value)) { - target_message.insert(field.to_string(), value.clone()); - } - } -} - -fn queued_command_as_user(record: &Value) -> Option { - let attachment = record.get("attachment")?; - if attachment.get("type").and_then(Value::as_str) != Some("queued_command") { - return None; - } - let prompt = attachment - .get("prompt") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - let mut normalized = record.clone(); - let object = normalized.as_object_mut()?; - object.insert("type".to_string(), Value::String("user".to_string())); - object.insert( - "message".to_string(), - json!({ "role": "user", "content": prompt }), - ); - object.remove("attachment"); - Some(normalized) -} - -/// Content sniff for qoder transcripts that lost their container path (import copies, bundle -/// zips): the inline metadata / queued-command record types are qoder-only vocabulary that no -/// Claude Code or Codex transcript produces. -pub(crate) fn looks_qoder_records(records: &[Value]) -> bool { - records.iter().any(|record| match record.get("type").and_then(Value::as_str) { - Some("agent-setting") | Some("ai-title") | Some("custom-title") | Some("last-prompt") - | Some("workspace-directories") | Some("runtime-config") => true, - Some("attachment") => { - record - .get("attachment") - .and_then(|attachment| attachment.get("type")) - .and_then(Value::as_str) - == Some("queued_command") - } - _ => false, - }) -} - -/// Convert Qoder's append-only wire records into the Claude-like records expected by the shared -/// history shaper. Atomic assistant wrappers with the same `message.id` collapse at their first -/// position, queued prompts become user messages, and opaque duplicate thinking blocks are -/// discarded in favor of the corresponding ordinary `thinking` block. -pub(crate) fn normalize_records(records: &[Value]) -> Vec { - let mut normalized = Vec::with_capacity(records.len()); - let mut assistant_by_message_id: HashMap = HashMap::new(); - - for record in records { - if let Some(user) = queued_command_as_user(record) { - normalized.push(user); - continue; - } - if record.get("type").and_then(Value::as_str) != Some("assistant") { - normalized.push(record.clone()); - continue; - } - - let wrapper = without_redacted_thinking(record); - let message_id = wrapper - .get("message") - .and_then(|message| message.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty()) - .map(str::to_owned); - if let Some(message_id) = message_id { - if let Some(index) = assistant_by_message_id.get(&message_id).copied() { - merge_assistant_wrapper(&mut normalized[index], &wrapper); - } else { - assistant_by_message_id.insert(message_id, normalized.len()); - normalized.push(wrapper); - } - } else { - normalized.push(wrapper); - } - } - - normalized -} - -/// (custom title, tags, deleted) from the shared agent sidecar (~/.ccbud/agent-meta.json). -pub fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) -} - -pub fn is_deleted(file: &Path) -> bool { - sidecar_meta(file).2 -} - -pub fn set_meta(file: &str, patch: &Value) -> Value { - let key = sidecar_key(Path::new(file)); - if key == "qoder:" { - return json!({ "ok": false, "reason": "empty" }); - } - crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_dir(name: &str) -> PathBuf { - std::env::temp_dir().join(format!("ccbud-qoder-{name}-{}", std::process::id())) - } - - #[test] - fn detects_qoder_paths() { - assert!(looks_qoder_path(Path::new( - "/h/.qoder/projects/-Users-a-p/1111-uuid.jsonl" - ))); - assert!(looks_qoder_path(Path::new( - "/h/.qoderwork/projects/-Users-a-p/1111-uuid.jsonl" - ))); - // subagent transcripts under the session's own dir route too (search scans them) - assert!(looks_qoder_path(Path::new( - "/h/.qoder/projects/-enc/1111-uuid/subagents/agent-x.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/.claude/projects/-enc/1111-uuid.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/.qoder/projects/-enc/session-state.json" - ))); - // projects/ must be DIRECTLY under the qoder root - assert!(!looks_qoder_path(Path::new( - "/h/.qoder/sessions/-enc/1111-uuid.jsonl" - ))); - assert!(!looks_qoder_path(Path::new( - "/h/qoder/projects/-enc/1111-uuid.jsonl" - ))); - } - - #[test] - fn extracts_inline_title_workspace_and_runtime_metadata() { - let records = vec![ - json!({ "type": "user", "isMeta": true, "message": { "content": "hidden setup" } }), - json!({ "type": "user", "message": { "content": [{ "type": "tool_result", "content": "not a title" }] } }), - json!({ "type": "user", "message": { "content": " First real prompt " } }), - json!({ "type": "summary", "summary": " Summary fallback " }), - json!({ "type": "last-prompt", "lastPrompt": " Older prompt " }), - json!({ "type": "last-prompt", "lastPrompt": " Latest prompt " }), - json!({ "type": "ai-title", "aiTitle": " Generated title " }), - json!({ "type": "custom-title", "customTitle": " " }), - json!({ "type": "custom-title", "customTitle": " Chosen title " }), - json!({ "type": "workspace-directories", "directories": ["/old/workspace"] }), - json!({ "type": "workspace-directories", "directories": [" ", "/work/project", "/work/secondary"] }), - json!({ "type": "runtime-config", "model": "basic" }), - json!({ "type": "runtime-config", "model": " " }), - json!({ "type": "runtime-config", "model": "ultimate" }), - ]; - - assert_eq!( - session_title_from(&records).as_deref(), - Some("Chosen title") - ); - assert_eq!(working_dir_from(&records).as_deref(), Some("/work/project")); - assert_eq!(model_from(&records).as_deref(), Some("ultimate")); - - assert_eq!( - session_title_from(&records[..8]).as_deref(), - Some("Generated title") - ); - assert_eq!( - session_title_from(&records[..6]).as_deref(), - Some("Latest prompt") - ); - assert_eq!( - session_title_from(&records[..4]).as_deref(), - Some("Summary fallback") - ); - assert_eq!( - session_title_from(&records[..3]).as_deref(), - Some("First real prompt") - ); - } - - #[test] - fn normalizes_atomic_assistant_wrappers_and_queued_commands() { - let records = vec![ - json!({ "type": "runtime-config", "model": "ultimate" }), - json!({ - "type": "assistant", "uuid": "wrapper-1", "timestamp": "2026-01-01T00:00:00Z", - "message": { - "id": "message-1", "role": "assistant", "model": "draft", - "content": [ - { "type": "thinking", "thinking": "plan" }, - { "type": "redacted_thinking", "data": "opaque duplicate" } - ], - "usage": { "input_tokens": 1 }, "stop_reason": null - } - }), - json!({ - "type": "assistant", "uuid": "wrapper-2", "timestamp": "2026-01-01T00:00:01Z", - "message": { - "id": "message-1", "role": "assistant", "model": "ultimate", - "content": [{ "type": "text", "text": "checking" }], - "usage": null, "stop_reason": "tool_use" - } - }), - json!({ - "type": "assistant", "uuid": "wrapper-3", "timestamp": "2026-01-01T00:00:02Z", - "message": { - "id": "message-1", "role": "assistant", "model": " ", - "content": [{ "type": "tool_use", "id": "tool-1", "name": "Read", "input": { "file_path": "/work/file" } }], - "usage": { "input_tokens": 7, "output_tokens": 3 }, "stop_reason": null - } - }), - json!({ - "type": "attachment", "uuid": "queued-1", "cwd": "/work/project", - "attachment": { "type": "queued_command", "prompt": "follow up", "commandMode": "agent" } - }), - json!({ - "type": "assistant", "uuid": "wrapper-without-id", - "message": { "role": "assistant", "content": [ - { "type": "redacted_thinking", "data": "drop me" }, - { "type": "text", "text": "kept" } - ] } - }), - ]; - - let normalized = normalize_records(&records); - assert_eq!(normalized.len(), 4); - assert_eq!(normalized[0]["type"], "runtime-config"); - - let assistant = &normalized[1]; - assert_eq!(assistant["uuid"], "wrapper-1"); - assert_eq!(assistant["timestamp"], "2026-01-01T00:00:00Z"); - assert_eq!(assistant["message"]["model"], "ultimate"); - assert_eq!( - assistant["message"]["usage"], - json!({ "input_tokens": 7, "output_tokens": 3 }) - ); - assert_eq!(assistant["message"]["stop_reason"], "tool_use"); - assert_eq!( - assistant["message"]["content"] - .as_array() - .unwrap() - .iter() - .map(|block| block["type"].as_str().unwrap()) - .collect::>(), - vec!["thinking", "text", "tool_use"] - ); - - let queued = &normalized[2]; - assert_eq!(queued["type"], "user"); - assert_eq!(queued["uuid"], "queued-1"); - assert_eq!(queued["cwd"], "/work/project"); - assert_eq!( - queued["message"], - json!({ "role": "user", "content": "follow up" }) - ); - assert!(queued.get("attachment").is_none()); - - assert_eq!( - normalized[3]["message"]["content"], - json!([{ "type": "text", "text": "kept" }]) - ); - // The caller's parsed records remain untouched. - assert_eq!( - records[1]["message"]["content"].as_array().unwrap().len(), - 2 - ); - } - - #[test] - fn decodes_batch_helper_base64() { - assert_eq!(b64_decode("").unwrap(), b""); - assert_eq!(b64_decode("aGVsbG8=").unwrap(), b"hello"); - assert_eq!(b64_decode("aGVsbG8h").unwrap(), b"hello!"); - assert_eq!(b64_decode("aA==").unwrap(), b"h"); - assert_eq!(b64_decode("5Lit5paH").unwrap(), "中文".as_bytes()); - assert!(b64_decode("not base64!").is_none()); - assert!(b64_decode("aGVsbG8").is_none()); // truncated group - } - - #[test] - fn helper_cache_serves_only_fresh_stamps() { - let path = test_dir("cache").join("t.jsonl"); - assert!(helper_cache_get(&path, 1.0, 10).is_none()); - helper_cache_put(&path, 1.0, 10, Arc::new(b"v1".to_vec())); - assert_eq!(helper_cache_get(&path, 1.0, 10).unwrap(), b"v1"); - // a changed mtime or size means a new file version — the stale entry must not serve - assert!(helper_cache_get(&path, 2.0, 10).is_none()); - assert!(helper_cache_get(&path, 1.0, 11).is_none()); - helper_cache_put(&path, 2.0, 10, Arc::new(b"v2".to_vec())); - assert_eq!(helper_cache_get(&path, 2.0, 10).unwrap(), b"v2"); - } - - #[test] - fn sniffs_qoder_records_by_inline_vocabulary() { - assert!(looks_qoder_records(&[json!({ "type": "ai-title", "aiTitle": "t" })])); - assert!(looks_qoder_records(&[ - json!({ "type": "user", "message": { "content": "hi" } }), - json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "p" } }), - ])); - // plain Claude / Codex shapes must not sniff as qoder - assert!(!looks_qoder_records(&[ - json!({ "type": "user", "message": { "content": "hi" }, "cwd": "/x" }), - json!({ "type": "assistant", "message": { "role": "assistant", "content": [] } }), - json!({ "type": "attachment", "attachment": { "type": "file" } }), - json!({ "type": "session_meta", "payload": {} }), - ])); - } - - #[test] - fn ordinary_reads_do_not_require_a_qoder_path() { - let dir = test_dir("ordinary-read"); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join("ordinary.txt"); - fs::write(&file, "local UTF-8 文本").unwrap(); - - assert_eq!(read_bytes(&file).unwrap(), "local UTF-8 文本".as_bytes()); - assert_eq!(read_text(&file).unwrap(), "local UTF-8 文本"); - - fs::write(&file, [0xff, 0xfe]).unwrap(); - assert_eq!( - read_text(&file).unwrap_err().kind(), - io::ErrorKind::InvalidData - ); - let _ = fs::remove_dir_all(&dir); - } - - #[test] - fn helper_target_is_limited_to_canonical_qoder_project_data() { - let dir = test_dir("path-validation"); - let _ = fs::remove_dir_all(&dir); - let projects = dir.join(".qoder").join("projects"); - let session = projects.join("-encoded-cwd").join("session-id"); - fs::create_dir_all(&session).unwrap(); - - let transcript = projects.join("-encoded-cwd").join("session-id.jsonl"); - let state = session.join("state.json"); - let metadata = session.join("agent-worker.meta.json"); - fs::write(&transcript, "{}\n").unwrap(); - fs::write(&state, "{}").unwrap(); - fs::write(&metadata, "{}").unwrap(); - - for file in [&transcript, &state, &metadata] { - let (validated, root) = validated_qoder_data_path(file).unwrap(); - assert_eq!(validated, fs::canonicalize(file).unwrap()); - assert_eq!(root, fs::canonicalize(dir.join(".qoder")).unwrap()); - } - - let arbitrary = session.join("secret.txt"); - fs::write(&arbitrary, "not helper-readable").unwrap(); - assert_eq!( - validated_qoder_data_path(&arbitrary).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - - let outside = dir.join("outside.jsonl"); - fs::write(&outside, "{}\n").unwrap(); - assert_eq!( - validated_qoder_data_path(&outside).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let escaped = session.join("escaped.jsonl"); - symlink(&outside, &escaped).unwrap(); - assert_eq!( - validated_qoder_data_path(&escaped).unwrap_err().kind(), - io::ErrorKind::PermissionDenied - ); - } - - let _ = fs::remove_dir_all(&dir); - } -} diff --git a/src-tauri/src/qoder/exec.rs b/src-tauri/src/qoder/exec.rs new file mode 100644 index 0000000..602f4d7 --- /dev/null +++ b/src-tauri/src/qoder/exec.rs @@ -0,0 +1,116 @@ +// Bounded execution of one helper invocation (hard deadline, capped stdout, drained stderr). +// macOS-only (declared `#[cfg(target_os = "macos")] mod exec;`), moved verbatim from qoder.rs. + +use std::io; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use super::guard::too_large; + +/// Poll the child for exit until `deadline`; None = still running when time ran out. +#[cfg(target_os = "macos")] +fn wait_deadline( + child: &mut std::process::Child, + deadline: Instant, +) -> io::Result> { + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Run a prepared helper command with a hard deadline. stdout drains on its own thread (bounded +/// at `stdout_cap`), stderr on another (an 8 KiB diagnostic tail, then discarded so a chatty +/// child can't deadlock on a full pipe), the child is killed at the deadline or on an oversized +/// stream, and exit is polled — a helper that closes stdout but never exits still can't pin the +/// calling thread. +#[cfg(target_os = "macos")] +pub(super) fn run_helper_bounded( + mut cmd: Command, + stdout_cap: usize, + timeout: Duration, + expected_len: usize, +) -> io::Result> { + use std::sync::mpsc; + let timed_out = || io::Error::new(io::ErrorKind::TimedOut, "Qoder CLI helper timed out"); + let mut child = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let deadline = Instant::now() + timeout; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Qoder CLI stdout was not captured"))?; + let (tx, rx) = mpsc::channel(); + // Pre-allocate for the expected payload, capped — metadata could claim MAX_READ_BYTES and an + // upfront 256 MiB allocation per read is needless; Vec growth covers honest large files. + let prealloc = expected_len.min(8 * 1024 * 1024); + let cap = stdout_cap as u64; + let reader = std::thread::spawn(move || { + let mut bytes = Vec::with_capacity(prealloc); + let result = stdout.by_ref().take(cap + 1).read_to_end(&mut bytes); + let _ = tx.send((result, bytes)); + }); + let (etx, erx) = mpsc::channel(); + let stderr_reader = child.stderr.take().map(|mut pipe| { + std::thread::spawn(move || { + let mut tail = Vec::with_capacity(1024); + let _ = pipe.by_ref().take(8192).read_to_end(&mut tail); + let _ = io::copy(&mut pipe, &mut io::sink()); + let _ = etx.send(tail); + }) + }); + let kill = |child: &mut std::process::Child| { + let _ = child.kill(); + let _ = child.wait(); + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let (read_result, bytes) = match rx.recv_timeout(remaining) { + Ok(outcome) => outcome, + Err(_) => { + kill(&mut child); // pipe closes → reader threads unblock and exit + let _ = reader.join(); + return Err(timed_out()); + } + }; + let _ = reader.join(); + if let Err(error) = read_result { + kill(&mut child); + return Err(error); + } + if bytes.len() > stdout_cap { + kill(&mut child); + return Err(too_large()); + } + let status = match wait_deadline(&mut child, deadline)? { + Some(status) => status, + None => { + kill(&mut child); + return Err(timed_out()); + } + }; + let stderr_tail = stderr_reader + .and_then(|_| erx.recv_timeout(Duration::from_millis(200)).ok()) + .unwrap_or_default(); + if !status.success() { + let detail = String::from_utf8_lossy(&stderr_tail); + let detail = detail.trim(); + return Err(io::Error::new( + io::ErrorKind::Other, + if detail.is_empty() { + format!("Qoder CLI helper exited with status {status}") + } else { + format!("Qoder CLI helper exited with status {status}: {detail}") + }, + )); + } + Ok(bytes) +} diff --git a/src-tauri/src/qoder/guard.rs b/src-tauri/src/qoder/guard.rs new file mode 100644 index 0000000..5512212 --- /dev/null +++ b/src-tauri/src/qoder/guard.rs @@ -0,0 +1,80 @@ +// Error shapes and the strict projects-tree validation every privileged helper read must pass. +// Moved verbatim from qoder.rs. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use super::limits::MAX_READ_BYTES; + +pub(super) fn denied(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message.into()) +} + +pub(super) fn too_large() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Qoder data file exceeds the {} MiB read limit", + MAX_READ_BYTES / 1024 / 1024 + ), + ) +} + +/// Canonicalize a prospective helper target and prove that it is a JSON/JSONL data file in a +/// `projects` directory directly below a `.qoder` or `.qoderwork` root. Canonicalizing the root, +/// projects directory, and target separately prevents a symlink below `projects` from escaping +/// into an arbitrary part of the filesystem. +pub(super) fn validated_qoder_data_path(path: &Path) -> io::Result<(PathBuf, PathBuf)> { + let projects = path + .ancestors() + .find(|ancestor| { + ancestor + .file_name() + .map(|name| name == "projects") + .unwrap_or(false) + && ancestor + .parent() + .and_then(Path::file_name) + .map(|name| name == ".qoder" || name == ".qoderwork") + .unwrap_or(false) + }) + .ok_or_else(|| denied("helper reads are limited to .qoder/.qoderwork projects trees"))?; + let root = projects + .parent() + .ok_or_else(|| denied("Qoder projects directory has no data root"))?; + + let canonical_root = fs::canonicalize(root)?; + let canonical_projects = fs::canonicalize(projects)?; + if canonical_projects.parent() != Some(canonical_root.as_path()) { + return Err(denied("Qoder projects directory escapes its data root")); + } + + let canonical_path = fs::canonicalize(path)?; + let relative = canonical_path + .strip_prefix(&canonical_projects) + .map_err(|_| denied("Qoder data path escapes its projects directory"))?; + if relative.as_os_str().is_empty() { + return Err(denied("Qoder data path must name a file below projects")); + } + + let is_data_file = matches!( + canonical_path + .extension() + .and_then(|extension| extension.to_str()), + Some("json") | Some("jsonl") + ); + if !is_data_file { + return Err(denied("Qoder helper only reads JSON and JSONL data files")); + } + + let metadata = fs::metadata(&canonical_path)?; + if !metadata.is_file() { + return Err(denied("Qoder data path is not a regular file")); + } + if metadata.len() > MAX_READ_BYTES as u64 { + return Err(too_large()); + } + + Ok((canonical_path, canonical_root)) +} diff --git a/src-tauri/src/qoder/helper.rs b/src-tauri/src/qoder/helper.rs new file mode 100644 index 0000000..a93e797 --- /dev/null +++ b/src-tauri/src/qoder/helper.rs @@ -0,0 +1,145 @@ +// Locating Qoder's installed CLI binary and proving it is trustworthy before executing it. +// macOS-only (declared `#[cfg(target_os = "macos")] mod helper;`), moved verbatim from qoder.rs. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; + +use super::guard::denied; +use super::read::mtime_ms_of; +use super::roots::{default_root, home}; + +#[cfg(target_os = "macos")] +fn helper_from_install_dir(install_dir: &Path) -> io::Result { + let canonical_dir = fs::canonicalize(install_dir)?; + if !fs::metadata(&canonical_dir)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Qoder CLI helper install directory is not a directory", + )); + } + + let version_path = fs::canonicalize(canonical_dir.join("version.txt"))?; + if version_path.parent() != Some(canonical_dir.as_path()) { + return Err(denied( + "Qoder CLI version file escapes its install directory", + )); + } + let version = fs::read_to_string(version_path)?; + let version = version.trim(); + if version.is_empty() + || version.len() > 64 + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Qoder CLI version.txt contains an invalid version", + )); + } + + let helper = fs::canonicalize(canonical_dir.join(format!("qodercli-{version}")))?; + if helper.parent() != Some(canonical_dir.as_path()) || !fs::metadata(&helper)?.is_file() { + return Err(denied("Qoder CLI helper escapes its install directory")); + } + Ok(helper) +} + +#[cfg(target_os = "macos")] +pub(super) fn installed_qoder_helper(current_root: &Path) -> io::Result { + let current_install = current_root.join("bin").join("qodercli"); + let home_install = default_root().join("bin").join("qodercli"); + + let mut last_error = None; + for install_dir in [¤t_install, &home_install] { + match helper_from_install_dir(install_dir) { + Ok(helper) => return Ok(helper), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "installed Qoder CLI helper was not found", + ) + })) +} + +/// The helper lives in a user-writable tree, so before executing it must prove it wasn't planted: +/// owned by the same uid as $HOME, neither it nor its directory group/world-writable, a valid +/// strict codesign signature, and a real TeamIdentifier (rejects ad-hoc-signed payloads). Same-uid +/// malware can defeat any same-uid check by definition — the goal is to stop weaker writers and +/// unsigned binaries, and to guarantee a signing-identity trail for anything that does run. +/// codesign hashes the whole (large) binary, so the verdict is memoized per (path, mtime, size). +#[cfg(target_os = "macos")] +pub(super) fn verify_helper_trust(helper: &Path) -> io::Result<()> { + static VERDICTS: OnceLock>> = OnceLock::new(); + let meta = fs::metadata(helper)?; + let (mt, size) = (mtime_ms_of(&meta), meta.len()); + let cache = VERDICTS.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(guard) = cache.lock() { + if let Some((cmt, csz, ok)) = guard.get(helper) { + if *cmt == mt && *csz == size { + return if *ok { + Ok(()) + } else { + Err(denied("Qoder CLI helper previously failed trust verification")) + }; + } + } + } + let verdict = helper_trust_checks(helper, &meta); + if let Ok(mut guard) = cache.lock() { + guard.insert(helper.to_path_buf(), (mt, size, verdict.is_ok())); + } + verdict +} + +#[cfg(target_os = "macos")] +fn helper_trust_checks(helper: &Path, meta: &fs::Metadata) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + let home_uid = fs::metadata(home())?.uid(); + let dir_meta = match helper.parent() { + Some(dir) => fs::metadata(dir)?, + None => return Err(denied("Qoder CLI helper has no install directory")), + }; + for (what, m) in [("helper", meta), ("helper directory", &dir_meta)] { + if m.uid() != home_uid { + return Err(denied(format!("Qoder CLI {what} is not owned by the current user"))); + } + if m.mode() & 0o022 != 0 { + return Err(denied(format!("Qoder CLI {what} is group/world writable"))); + } + } + // codesign is Apple's own bounded tool — a plain blocking call is fine here. + let valid = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !valid.success() { + return Err(denied("Qoder CLI helper has no valid code signature")); + } + let display = Command::new("/usr/bin/codesign") + .args(["-d", "--verbose=2", "--"]) + .arg(helper) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output()?; // codesign -d prints its details on stderr + let info = String::from_utf8_lossy(&display.stderr); + let has_team = info.lines().any(|line| { + let line = line.trim_start(); + line.starts_with("TeamIdentifier=") && line != "TeamIdentifier=not set" + }); + if !has_team { + return Err(denied("Qoder CLI helper is not signed with a developer Team ID")); + } + Ok(()) +} diff --git a/src-tauri/src/qoder/limits.rs b/src-tauri/src/qoder/limits.rs new file mode 100644 index 0000000..402d76b --- /dev/null +++ b/src-tauri/src/qoder/limits.rs @@ -0,0 +1,31 @@ +// Read-size, timeout and cache budgets for the Qoder CLI helper path, plus the two tiny +// JavaScript programs it is handed. Moved verbatim from qoder.rs. + +#[cfg(target_os = "macos")] +use std::time::Duration; + +/// Keep the privileged helper path bounded even if the on-disk file changes while it is read. +/// This is deliberately much larger than normal transcripts, while still preventing an +/// accidental/untrusted child process from filling the app's memory with stdout. +pub(super) const MAX_READ_BYTES: usize = 256 * 1024 * 1024; + +/// Hard deadline for one helper invocation — generous for a MAX_READ_BYTES read, but bounded so +/// a wedged helper can never pin a sync command thread (and with it the renderer's coalesced +/// request slot for that session) until app restart. Batches get longer since they serve many +/// files in one spawn. +#[cfg(target_os = "macos")] +pub(super) const HELPER_TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(target_os = "macos")] +pub(super) const HELPER_BATCH_TIMEOUT: Duration = Duration::from_secs(45); + +/// Byte budget for the helper-read cache (see helper_cache) — cleared wholesale when exceeded, +/// mirroring the search cache's crude-but-safe policy. +pub(super) const HELPER_CACHE_BUDGET: usize = 256 * 1024 * 1024; + +pub(super) const QODER_READ_SCRIPT: &str = + "const fs=require(\"fs\");process.stdout.write(fs.readFileSync(process.argv[1]))"; + +/// Batch counterpart: one line of JSON per argv file — content as base64 (`b64`) or a per-file +/// error (`err`) that must not abort the rest of the batch. +#[cfg(target_os = "macos")] +pub(super) const QODER_BATCH_READ_SCRIPT: &str = "const fs=require(\"fs\");for(const p of process.argv.slice(1)){let line;try{line=JSON.stringify({p,b64:fs.readFileSync(p).toString(\"base64\")})}catch(e){line=JSON.stringify({p,err:String(e&&e.code||e)})}process.stdout.write(line+\"\\n\")}"; diff --git a/src-tauri/src/qoder/meta.rs b/src-tauri/src/qoder/meta.rs new file mode 100644 index 0000000..f21ac83 --- /dev/null +++ b/src-tauri/src/qoder/meta.rs @@ -0,0 +1,34 @@ +// Per-session identity and the shared foreign-CLI sidecar (title / tags / soft-delete). Moved +// verbatim from qoder.rs. + +use serde_json::{json, Value}; +use std::path::Path; + +/// The session uuid (its file stem) — sidecar key and renderer id both build on it. +fn session_stem(file: &Path) -> String { + file.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("qoder:{}", session_stem(file)) +} + +/// (custom title, tags, deleted) from the shared agent sidecar (~/.ccbud/agent-meta.json). +pub fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "qoder:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} diff --git a/src-tauri/src/qoder/mod.rs b/src-tauri/src/qoder/mod.rs new file mode 100644 index 0000000..ee76e79 --- /dev/null +++ b/src-tauri/src/qoder/mod.rs @@ -0,0 +1,32 @@ +// Qoder CLI session support — Qoder writes Claude-like transcripts into its own trees +// (`~/.qoder/projects//.jsonl` and the same layout under `~/.qoderwork`, +// subagents in `/subagents/agent-*.jsonl`). Qoder streams assistant content as atomic +// wrappers and stores title/workspace/runtime metadata inline, so this module provides the small +// normalization layer needed by the normal Claude pipeline, plus root discovery, safe reads, +// path routing, and the shared foreign-CLI sidecar. The source files belong to another tool and +// are never rewritten, which also means hard-delete refuses them (history.rs). + +#![allow(dead_code)] +mod guard; +mod limits; +mod meta; +mod normalize; +mod prefetch; +mod read; +mod roots; +mod titles; +#[cfg(target_os = "macos")] +mod exec; +#[cfg(target_os = "macos")] +mod helper; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_more; + +pub use meta::{is_deleted, set_meta, sidecar_meta}; +pub use roots::{default_root, looks_qoder_path, root_exists, work_root}; +pub(crate) use normalize::{looks_qoder_records, normalize_records}; +pub(crate) use prefetch::prefetch; +pub(crate) use read::{read_bytes, read_text}; +pub(crate) use titles::{model_from, session_title_from, working_dir_from}; diff --git a/src-tauri/src/qoder/normalize.rs b/src-tauri/src/qoder/normalize.rs new file mode 100644 index 0000000..ae28b0d --- /dev/null +++ b/src-tauri/src/qoder/normalize.rs @@ -0,0 +1,136 @@ +// Converting Qoder's append-only wire records into the Claude-like records the shared history +// shaper consumes, plus the content sniff for transcripts that lost their container path. Moved +// verbatim from qoder.rs. + +use serde_json::{json, Value}; +use std::collections::HashMap; + +fn has_value(value: &Value) -> bool { + match value { + Value::Null => false, + Value::String(value) => !value.trim().is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + Value::Bool(_) | Value::Number(_) => true, + } +} + +fn without_redacted_thinking(record: &Value) -> Value { + let mut record = record.clone(); + if let Some(content) = record + .get_mut("message") + .and_then(|message| message.get_mut("content")) + .and_then(Value::as_array_mut) + { + content + .retain(|block| block.get("type").and_then(Value::as_str) != Some("redacted_thinking")); + } + record +} + +fn merge_assistant_wrapper(target: &mut Value, wrapper: &Value) { + let Some(source_message) = wrapper.get("message").and_then(Value::as_object) else { + return; + }; + let Some(target_message) = target.get_mut("message").and_then(Value::as_object_mut) else { + return; + }; + + if let Some(source_content) = source_message.get("content").and_then(Value::as_array) { + match target_message.get_mut("content") { + Some(Value::Array(target_content)) => { + target_content.extend(source_content.iter().cloned()) + } + Some(Value::Null) | None => { + target_message.insert("content".to_string(), Value::Array(source_content.clone())); + } + Some(_) => {} + } + } + + for field in ["model", "usage", "stop_reason"] { + if let Some(value) = source_message.get(field).filter(|value| has_value(value)) { + target_message.insert(field.to_string(), value.clone()); + } + } +} + +fn queued_command_as_user(record: &Value) -> Option { + let attachment = record.get("attachment")?; + if attachment.get("type").and_then(Value::as_str) != Some("queued_command") { + return None; + } + let prompt = attachment + .get("prompt") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let mut normalized = record.clone(); + let object = normalized.as_object_mut()?; + object.insert("type".to_string(), Value::String("user".to_string())); + object.insert( + "message".to_string(), + json!({ "role": "user", "content": prompt }), + ); + object.remove("attachment"); + Some(normalized) +} + +/// Content sniff for qoder transcripts that lost their container path (import copies, bundle +/// zips): the inline metadata / queued-command record types are qoder-only vocabulary that no +/// Claude Code or Codex transcript produces. +pub(crate) fn looks_qoder_records(records: &[Value]) -> bool { + records.iter().any(|record| match record.get("type").and_then(Value::as_str) { + Some("agent-setting") | Some("ai-title") | Some("custom-title") | Some("last-prompt") + | Some("workspace-directories") | Some("runtime-config") => true, + Some("attachment") => { + record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") + } + _ => false, + }) +} + +/// Convert Qoder's append-only wire records into the Claude-like records expected by the shared +/// history shaper. Atomic assistant wrappers with the same `message.id` collapse at their first +/// position, queued prompts become user messages, and opaque duplicate thinking blocks are +/// discarded in favor of the corresponding ordinary `thinking` block. +pub(crate) fn normalize_records(records: &[Value]) -> Vec { + let mut normalized = Vec::with_capacity(records.len()); + let mut assistant_by_message_id: HashMap = HashMap::new(); + + for record in records { + if let Some(user) = queued_command_as_user(record) { + normalized.push(user); + continue; + } + if record.get("type").and_then(Value::as_str) != Some("assistant") { + normalized.push(record.clone()); + continue; + } + + let wrapper = without_redacted_thinking(record); + let message_id = wrapper + .get("message") + .and_then(|message| message.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_owned); + if let Some(message_id) = message_id { + if let Some(index) = assistant_by_message_id.get(&message_id).copied() { + merge_assistant_wrapper(&mut normalized[index], &wrapper); + } else { + assistant_by_message_id.insert(message_id, normalized.len()); + normalized.push(wrapper); + } + } else { + normalized.push(wrapper); + } + } + + normalized +} diff --git a/src-tauri/src/qoder/prefetch.rs b/src-tauri/src/qoder/prefetch.rs new file mode 100644 index 0000000..59b95ee --- /dev/null +++ b/src-tauri/src/qoder/prefetch.rs @@ -0,0 +1,141 @@ +// Batch helper warm-up: one helper invocation per data root instead of one per file, plus the +// small base64 decoder its output needs. Moved verbatim from qoder.rs. + +use std::path::PathBuf; + +#[cfg(target_os = "macos")] +use serde_json::Value; +#[cfg(target_os = "macos")] +use std::collections::HashMap; +#[cfg(target_os = "macos")] +use std::fs; +#[cfg(target_os = "macos")] +use std::io; +#[cfg(target_os = "macos")] +use std::path::Path; +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use std::sync::Arc; +#[cfg(target_os = "macos")] +use super::exec::run_helper_bounded; +#[cfg(target_os = "macos")] +use super::guard::validated_qoder_data_path; +#[cfg(target_os = "macos")] +use super::helper::{installed_qoder_helper, verify_helper_trust}; +#[cfg(target_os = "macos")] +use super::limits::{HELPER_BATCH_TIMEOUT, MAX_READ_BYTES, QODER_BATCH_READ_SCRIPT}; +#[cfg(target_os = "macos")] +use super::read::{file_stamp, helper_cache_get, helper_cache_put}; + +/// Minimal standard-alphabet base64 decoder for the batch helper's output (node/bun emit padded +/// base64 without line breaks; stray CR/LF are tolerated anyway). +pub(super) fn b64_decode(s: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some((b - b'A') as u32), + b'a'..=b'z' => Some((b - b'a' + 26) as u32), + b'0'..=b'9' => Some((b - b'0' + 52) as u32), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + let mut chunk = [0u32; 4]; + let mut n = 0usize; + let mut pad = 0usize; + for &b in bytes { + if b == b'\r' || b == b'\n' { + continue; + } + if b == b'=' { + pad += 1; + chunk[n] = 0; + } else { + if pad > 0 { + return None; // data after padding + } + chunk[n] = val(b)?; + } + n += 1; + if n == 4 { + let v = (chunk[0] << 18) | (chunk[1] << 12) | (chunk[2] << 6) | chunk[3]; + out.push((v >> 16) as u8); + if pad < 2 { + out.push((v >> 8) as u8); + } + if pad < 1 { + out.push(v as u8); + } + n = 0; + if pad > 0 { + break; + } + } + } + (n == 0).then_some(out) +} + +/// Warm the helper cache for many qoder files with ONE helper invocation per data root — the +/// list, search, usage, and subagent scans otherwise pay one bun startup per file on a protected +/// macOS install (the measured stall is seconds for a first refresh). Directly-readable and +/// fresh-cached files are skipped; per-file failures fall back to the on-demand single read. +/// No-op off macOS. +pub(crate) fn prefetch(paths: &[PathBuf]) { + #[cfg(target_os = "macos")] + prefetch_macos(paths); + #[cfg(not(target_os = "macos"))] + let _ = paths; +} + +#[cfg(target_os = "macos")] +fn prefetch_macos(paths: &[PathBuf]) { + // (canonical helper target, original cache key, stamp) + let mut by_root: HashMap> = HashMap::new(); + for path in paths { + let Ok((mt, size)) = file_stamp(path) else { continue }; + if size > MAX_READ_BYTES as u64 || helper_cache_get(path, mt, size).is_some() { + continue; + } + match fs::File::open(path) { + Ok(_) => continue, // direct reads work — the ordinary path is cheap + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} + Err(_) => continue, + } + let Ok((canonical, root)) = validated_qoder_data_path(path) else { continue }; + by_root.entry(root).or_default().push((canonical, path.clone(), mt, size)); + } + for (root, files) in by_root { + let Ok(helper) = installed_qoder_helper(&root) else { continue }; + if verify_helper_trust(&helper).is_err() { + continue; + } + // Small argv chunks keep each spawn's total output within the shared byte cap and far + // below ARG_MAX; a lost chunk (timeout/oversize) degrades to per-file reads, not failure. + for chunk in files.chunks(32) { + let mut cmd = Command::new(&helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_BATCH_READ_SCRIPT); + for (canonical, _, _, _) in chunk { + cmd.arg(canonical); + } + let Ok(out) = run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_BATCH_TIMEOUT, 0) else { + continue; + }; + let by_canonical: HashMap<&Path, (&PathBuf, f64, u64)> = chunk + .iter() + .map(|(canonical, original, mt, size)| (canonical.as_path(), (original, *mt, *size))) + .collect(); + for line in out.split(|b| *b == b'\n') { + let Ok(row) = serde_json::from_slice::(line) else { continue }; + let Some(p) = row.get("p").and_then(Value::as_str) else { continue }; + let Some(&(original, mt, size)) = by_canonical.get(Path::new(p)) else { continue }; + let Some(bytes) = row.get("b64").and_then(Value::as_str).and_then(b64_decode) else { + continue; // per-file err rows fall back to the single-read path on demand + }; + helper_cache_put(original, mt, size, Arc::new(bytes)); + } + } + } +} diff --git a/src-tauri/src/qoder/read.rs b/src-tauri/src/qoder/read.rs new file mode 100644 index 0000000..81db2c0 --- /dev/null +++ b/src-tauri/src/qoder/read.rs @@ -0,0 +1,147 @@ +// Reading Qoder data files: the ordinary filesystem path, the macOS helper fallback, and the +// (mtime, size) memo that keeps repeat reads of the same file version off the helper. Moved +// verbatim from qoder.rs. + +use std::collections::HashMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::limits::{HELPER_CACHE_BUDGET, MAX_READ_BYTES}; +use super::guard::too_large; + +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use super::exec::run_helper_bounded; +#[cfg(target_os = "macos")] +use super::guard::validated_qoder_data_path; +#[cfg(target_os = "macos")] +use super::helper::{installed_qoder_helper, verify_helper_trust}; +#[cfg(target_os = "macos")] +use super::limits::{HELPER_TIMEOUT, QODER_READ_SCRIPT}; + +#[cfg(target_os = "macos")] +fn read_with_qoder_helper(path: &Path) -> io::Result> { + let (path, root) = validated_qoder_data_path(path)?; + let helper = installed_qoder_helper(&root)?; + verify_helper_trust(&helper)?; + let expected_len = fs::metadata(&path)?.len().min(MAX_READ_BYTES as u64) as usize; + + // Qoder CLI is a Bun executable. Passing the fixed program and target as distinct argv + // entries is important: never interpolate a path into JavaScript or a shell command. + let mut cmd = Command::new(helper); + cmd.env("BUN_BE_BUN", "1").arg("-e").arg(QODER_READ_SCRIPT).arg(&path); + run_helper_bounded(cmd, MAX_READ_BYTES, HELPER_TIMEOUT, expected_len) +} + +/// Read a local history file. The normal filesystem path is always attempted first; macOS may +/// fall back to Qoder's already-installed CLI only for a permission denial, and only after the +/// helper target passes the strict projects-tree validation above. +pub(crate) fn read_bytes(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) if bytes.len() <= MAX_READ_BYTES => Ok(bytes), + Ok(_) => Err(too_large()), + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + #[cfg(target_os = "macos")] + { + // Serve repeat reads of the same file version from the helper cache — stat still + // works on content-protected files, so (mtime, size) is a valid freshness key. + let stamp = file_stamp(path).ok(); + if let Some((mt, size)) = stamp { + if let Some(hit) = helper_cache_get(path, mt, size) { + return Ok(hit); + } + } + read_with_qoder_helper(path) + .map(|bytes| { + if let Some((mt, size)) = stamp { + helper_cache_put(path, mt, size, Arc::new(bytes.clone())); + } + bytes + }) + .map_err(|helper_error| { + if matches!( + helper_error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) { + // The original file read was a permission failure. A missing helper + // must not turn that into NotFound, which callers interpret as a + // moved file. + io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Qoder data is not directly readable and its CLI helper is unavailable: {helper_error}" + ), + ) + } else { + // Size/encoding failures, timeouts, and abnormal helper exits retain + // their distinct classification so the UI reports a read failure, + // not an auth hint. + helper_error + } + }) + } + #[cfg(not(target_os = "macos"))] + { + Err(error) + } + } + Err(error) => Err(error), + } +} + +/// UTF-8 text counterpart to [`read_bytes`]. Qoder's JSON/JSONL data is defined as UTF-8, so +/// malformed data is reported rather than silently replacing bytes and corrupting records. +pub(crate) fn read_text(path: &Path) -> io::Result { + String::from_utf8(read_bytes(path)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +pub(super) fn mtime_ms_of(meta: &fs::Metadata) -> f64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +pub(super) fn file_stamp(path: &Path) -> io::Result<(f64, u64)> { + let meta = fs::metadata(path)?; + Ok((mtime_ms_of(&meta), meta.len())) +} + +/// Bytes fetched through the macOS helper, memoized by (mtime, size) — the list/search/detail +/// paths and the 4s live-follow tick otherwise each pay a bun startup for the SAME file version. +/// Stat keeps working on content-protected files (discovery depends on it), so the stamp is the +/// same freshness signal the list-meta memo uses. +struct HelperCache { + map: HashMap>)>, + bytes: usize, +} + +fn helper_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HelperCache { map: HashMap::new(), bytes: 0 })) +} + +pub(super) fn helper_cache_get(path: &Path, mt: f64, size: u64) -> Option> { + let cache = helper_cache().lock().ok()?; + let (cmt, csz, bytes) = cache.map.get(path)?; + (*cmt == mt && *csz == size).then(|| bytes.as_ref().clone()) +} + +pub(super) fn helper_cache_put(path: &Path, mt: f64, size: u64, bytes: Arc>) { + if let Ok(mut cache) = helper_cache().lock() { + if cache.bytes + bytes.len() > HELPER_CACHE_BUDGET { + cache.map.clear(); + cache.bytes = 0; + } + let len = bytes.len(); + if let Some((_, _, old)) = cache.map.insert(path.to_path_buf(), (mt, size, bytes)) { + cache.bytes = cache.bytes.saturating_sub(old.len()); + } + cache.bytes += len; + } +} diff --git a/src-tauri/src/qoder/roots.rs b/src-tauri/src/qoder/roots.rs new file mode 100644 index 0000000..46ce853 --- /dev/null +++ b/src-tauri/src/qoder/roots.rs @@ -0,0 +1,48 @@ +// Qoder data roots and the container-shape path test used for routing. Moved verbatim from +// qoder.rs. + +use std::path::{Path, PathBuf}; + +pub(super) fn home() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Qoder's two known data roots (both observed in the wild): each is a history-dir entry +/// candidate for the auto-add migration; browsing itself walks every configured dir's +/// `projects/` tree, so these only seed historyDirs. +pub fn default_root() -> PathBuf { + home().join(".qoder") +} + +pub fn work_root() -> PathBuf { + home().join(".qoderwork") +} + +/// A qoder install exists at `root` when its projects tree is on disk. +pub fn root_exists(root: &Path) -> bool { + root.join("projects").is_dir() +} + +/// Container-shape test for routing: a .jsonl anywhere under a `.qoder/projects/` or +/// `.qoderwork/projects/` tree (main sessions AND `/subagents/agent-*.jsonl`). +pub fn looks_qoder_path(file: &Path) -> bool { + if file.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return false; + } + let mut child: Option<&std::ffi::OsStr> = None; + for anc in file.ancestors().skip(1) { + let name = match anc.file_name() { + Some(n) => n, + None => break, + }; + if (name == ".qoder" || name == ".qoderwork") + && child.map(|c| c == "projects").unwrap_or(false) + { + return true; + } + child = Some(name); + } + false +} diff --git a/src-tauri/src/qoder/tests.rs b/src-tauri/src/qoder/tests.rs new file mode 100644 index 0000000..0365a9a --- /dev/null +++ b/src-tauri/src/qoder/tests.rs @@ -0,0 +1,162 @@ +use super::*; +use serde_json::json; +use std::path::Path; + +#[test] +fn detects_qoder_paths() { + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-Users-a-p/1111-uuid.jsonl" + ))); + assert!(looks_qoder_path(Path::new( + "/h/.qoderwork/projects/-Users-a-p/1111-uuid.jsonl" + ))); + // subagent transcripts under the session's own dir route too (search scans them) + assert!(looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/1111-uuid/subagents/agent-x.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.claude/projects/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/projects/-enc/session-state.json" + ))); + // projects/ must be DIRECTLY under the qoder root + assert!(!looks_qoder_path(Path::new( + "/h/.qoder/sessions/-enc/1111-uuid.jsonl" + ))); + assert!(!looks_qoder_path(Path::new( + "/h/qoder/projects/-enc/1111-uuid.jsonl" + ))); +} + +#[test] +fn extracts_inline_title_workspace_and_runtime_metadata() { + let records = vec![ + json!({ "type": "user", "isMeta": true, "message": { "content": "hidden setup" } }), + json!({ "type": "user", "message": { "content": [{ "type": "tool_result", "content": "not a title" }] } }), + json!({ "type": "user", "message": { "content": " First real prompt " } }), + json!({ "type": "summary", "summary": " Summary fallback " }), + json!({ "type": "last-prompt", "lastPrompt": " Older prompt " }), + json!({ "type": "last-prompt", "lastPrompt": " Latest prompt " }), + json!({ "type": "ai-title", "aiTitle": " Generated title " }), + json!({ "type": "custom-title", "customTitle": " " }), + json!({ "type": "custom-title", "customTitle": " Chosen title " }), + json!({ "type": "workspace-directories", "directories": ["/old/workspace"] }), + json!({ "type": "workspace-directories", "directories": [" ", "/work/project", "/work/secondary"] }), + json!({ "type": "runtime-config", "model": "basic" }), + json!({ "type": "runtime-config", "model": " " }), + json!({ "type": "runtime-config", "model": "ultimate" }), + ]; + + assert_eq!( + session_title_from(&records).as_deref(), + Some("Chosen title") + ); + assert_eq!(working_dir_from(&records).as_deref(), Some("/work/project")); + assert_eq!(model_from(&records).as_deref(), Some("ultimate")); + + assert_eq!( + session_title_from(&records[..8]).as_deref(), + Some("Generated title") + ); + assert_eq!( + session_title_from(&records[..6]).as_deref(), + Some("Latest prompt") + ); + assert_eq!( + session_title_from(&records[..4]).as_deref(), + Some("Summary fallback") + ); + assert_eq!( + session_title_from(&records[..3]).as_deref(), + Some("First real prompt") + ); +} + +#[test] +fn normalizes_atomic_assistant_wrappers_and_queued_commands() { + let records = vec![ + json!({ "type": "runtime-config", "model": "ultimate" }), + json!({ + "type": "assistant", "uuid": "wrapper-1", "timestamp": "2026-01-01T00:00:00Z", + "message": { + "id": "message-1", "role": "assistant", "model": "draft", + "content": [ + { "type": "thinking", "thinking": "plan" }, + { "type": "redacted_thinking", "data": "opaque duplicate" } + ], + "usage": { "input_tokens": 1 }, "stop_reason": null + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-2", "timestamp": "2026-01-01T00:00:01Z", + "message": { + "id": "message-1", "role": "assistant", "model": "ultimate", + "content": [{ "type": "text", "text": "checking" }], + "usage": null, "stop_reason": "tool_use" + } + }), + json!({ + "type": "assistant", "uuid": "wrapper-3", "timestamp": "2026-01-01T00:00:02Z", + "message": { + "id": "message-1", "role": "assistant", "model": " ", + "content": [{ "type": "tool_use", "id": "tool-1", "name": "Read", "input": { "file_path": "/work/file" } }], + "usage": { "input_tokens": 7, "output_tokens": 3 }, "stop_reason": null + } + }), + json!({ + "type": "attachment", "uuid": "queued-1", "cwd": "/work/project", + "attachment": { "type": "queued_command", "prompt": "follow up", "commandMode": "agent" } + }), + json!({ + "type": "assistant", "uuid": "wrapper-without-id", + "message": { "role": "assistant", "content": [ + { "type": "redacted_thinking", "data": "drop me" }, + { "type": "text", "text": "kept" } + ] } + }), + ]; + + let normalized = normalize_records(&records); + assert_eq!(normalized.len(), 4); + assert_eq!(normalized[0]["type"], "runtime-config"); + + let assistant = &normalized[1]; + assert_eq!(assistant["uuid"], "wrapper-1"); + assert_eq!(assistant["timestamp"], "2026-01-01T00:00:00Z"); + assert_eq!(assistant["message"]["model"], "ultimate"); + assert_eq!( + assistant["message"]["usage"], + json!({ "input_tokens": 7, "output_tokens": 3 }) + ); + assert_eq!(assistant["message"]["stop_reason"], "tool_use"); + assert_eq!( + assistant["message"]["content"] + .as_array() + .unwrap() + .iter() + .map(|block| block["type"].as_str().unwrap()) + .collect::>(), + vec!["thinking", "text", "tool_use"] + ); + + let queued = &normalized[2]; + assert_eq!(queued["type"], "user"); + assert_eq!(queued["uuid"], "queued-1"); + assert_eq!(queued["cwd"], "/work/project"); + assert_eq!( + queued["message"], + json!({ "role": "user", "content": "follow up" }) + ); + assert!(queued.get("attachment").is_none()); + + assert_eq!( + normalized[3]["message"]["content"], + json!([{ "type": "text", "text": "kept" }]) + ); + // The caller's parsed records remain untouched. + assert_eq!( + records[1]["message"]["content"].as_array().unwrap().len(), + 2 + ); +} diff --git a/src-tauri/src/qoder/tests_more.rs b/src-tauri/src/qoder/tests_more.rs new file mode 100644 index 0000000..246af25 --- /dev/null +++ b/src-tauri/src/qoder/tests_more.rs @@ -0,0 +1,121 @@ +use super::*; +use super::guard::validated_qoder_data_path; +use super::prefetch::b64_decode; +use super::read::{helper_cache_get, helper_cache_put}; +use serde_json::json; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; + +fn test_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("ccbud-qoder-{name}-{}", std::process::id())) +} + +#[test] +fn decodes_batch_helper_base64() { + assert_eq!(b64_decode("").unwrap(), b""); + assert_eq!(b64_decode("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(b64_decode("aGVsbG8h").unwrap(), b"hello!"); + assert_eq!(b64_decode("aA==").unwrap(), b"h"); + assert_eq!(b64_decode("5Lit5paH").unwrap(), "中文".as_bytes()); + assert!(b64_decode("not base64!").is_none()); + assert!(b64_decode("aGVsbG8").is_none()); // truncated group +} + +#[test] +fn helper_cache_serves_only_fresh_stamps() { + let path = test_dir("cache").join("t.jsonl"); + assert!(helper_cache_get(&path, 1.0, 10).is_none()); + helper_cache_put(&path, 1.0, 10, Arc::new(b"v1".to_vec())); + assert_eq!(helper_cache_get(&path, 1.0, 10).unwrap(), b"v1"); + // a changed mtime or size means a new file version — the stale entry must not serve + assert!(helper_cache_get(&path, 2.0, 10).is_none()); + assert!(helper_cache_get(&path, 1.0, 11).is_none()); + helper_cache_put(&path, 2.0, 10, Arc::new(b"v2".to_vec())); + assert_eq!(helper_cache_get(&path, 2.0, 10).unwrap(), b"v2"); +} + +#[test] +fn sniffs_qoder_records_by_inline_vocabulary() { + assert!(looks_qoder_records(&[json!({ "type": "ai-title", "aiTitle": "t" })])); + assert!(looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" } }), + json!({ "type": "attachment", "attachment": { "type": "queued_command", "prompt": "p" } }), + ])); + // plain Claude / Codex shapes must not sniff as qoder + assert!(!looks_qoder_records(&[ + json!({ "type": "user", "message": { "content": "hi" }, "cwd": "/x" }), + json!({ "type": "assistant", "message": { "role": "assistant", "content": [] } }), + json!({ "type": "attachment", "attachment": { "type": "file" } }), + json!({ "type": "session_meta", "payload": {} }), + ])); +} + +#[test] +fn ordinary_reads_do_not_require_a_qoder_path() { + let dir = test_dir("ordinary-read"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ordinary.txt"); + fs::write(&file, "local UTF-8 文本").unwrap(); + + assert_eq!(read_bytes(&file).unwrap(), "local UTF-8 文本".as_bytes()); + assert_eq!(read_text(&file).unwrap(), "local UTF-8 文本"); + + fs::write(&file, [0xff, 0xfe]).unwrap(); + assert_eq!( + read_text(&file).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn helper_target_is_limited_to_canonical_qoder_project_data() { + let dir = test_dir("path-validation"); + let _ = fs::remove_dir_all(&dir); + let projects = dir.join(".qoder").join("projects"); + let session = projects.join("-encoded-cwd").join("session-id"); + fs::create_dir_all(&session).unwrap(); + + let transcript = projects.join("-encoded-cwd").join("session-id.jsonl"); + let state = session.join("state.json"); + let metadata = session.join("agent-worker.meta.json"); + fs::write(&transcript, "{}\n").unwrap(); + fs::write(&state, "{}").unwrap(); + fs::write(&metadata, "{}").unwrap(); + + for file in [&transcript, &state, &metadata] { + let (validated, root) = validated_qoder_data_path(file).unwrap(); + assert_eq!(validated, fs::canonicalize(file).unwrap()); + assert_eq!(root, fs::canonicalize(dir.join(".qoder")).unwrap()); + } + + let arbitrary = session.join("secret.txt"); + fs::write(&arbitrary, "not helper-readable").unwrap(); + assert_eq!( + validated_qoder_data_path(&arbitrary).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + let outside = dir.join("outside.jsonl"); + fs::write(&outside, "{}\n").unwrap(); + assert_eq!( + validated_qoder_data_path(&outside).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let escaped = session.join("escaped.jsonl"); + symlink(&outside, &escaped).unwrap(); + assert_eq!( + validated_qoder_data_path(&escaped).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + } + + let _ = fs::remove_dir_all(&dir); +} diff --git a/src-tauri/src/qoder/titles.rs b/src-tauri/src/qoder/titles.rs new file mode 100644 index 0000000..6ea9f14 --- /dev/null +++ b/src-tauri/src/qoder/titles.rs @@ -0,0 +1,130 @@ +// Title / workspace / model extraction from Qoder's inline metadata records. Moved verbatim +// from qoder.rs. + +use serde_json::Value; + +fn trimmed_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn latest_inline_string(records: &[Value], record_type: &str, field: &str) -> Option { + records.iter().rev().find_map(|record| { + (record.get("type").and_then(Value::as_str) == Some(record_type)) + .then(|| trimmed_string(record.get(field))) + .flatten() + }) +} + +fn text_content(value: &Value) -> Option { + if let Some(text) = value.as_str() { + let text = text.trim(); + return (!text.is_empty()).then(|| text.to_owned()); + } + if let Some(blocks) = value.as_array() { + let parts: Vec<&str> = blocks + .iter() + .filter_map(|block| { + if let Some(text) = block.as_str() { + return Some(text); + } + let kind = block.get("type").and_then(Value::as_str).unwrap_or(""); + matches!(kind, "text" | "input_text") + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + if !parts.is_empty() { + return Some(parts.join("\n")); + } + } + value + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +fn summary_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("summary") { + return None; + } + trimmed_string(record.get("summary")) + .or_else(|| record.get("content").and_then(text_content)) + .or_else(|| { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + }) + }) +} + +fn first_user_text_from(records: &[Value]) -> Option { + records + .iter() + .find_map(|record| match record.get("type").and_then(Value::as_str) { + Some("user") + if record.get("isMeta").and_then(Value::as_bool) != Some(true) + && record.get("isCompactSummary").and_then(Value::as_bool) != Some(true) => + { + record + .get("message") + .and_then(|message| message.get("content")) + .and_then(text_content) + } + Some("attachment") + if record + .get("attachment") + .and_then(|attachment| attachment.get("type")) + .and_then(Value::as_str) + == Some("queued_command") => + { + trimmed_string( + record + .get("attachment") + .and_then(|attachment| attachment.get("prompt")), + ) + } + _ => None, + }) +} + +/// Qoder's inline title, in the same precedence used by its own conversation list. Repeated +/// metadata records are append-only updates, so the last non-empty value wins within each tier. +pub(crate) fn session_title_from(records: &[Value]) -> Option { + latest_inline_string(records, "custom-title", "customTitle") + .or_else(|| latest_inline_string(records, "ai-title", "aiTitle")) + .or_else(|| latest_inline_string(records, "last-prompt", "lastPrompt")) + .or_else(|| summary_from(records)) + .or_else(|| first_user_text_from(records)) +} + +/// Primary workspace from Qoder's latest inline `workspace-directories` record. +pub(crate) fn working_dir_from(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("workspace-directories") { + return None; + } + record + .get("directories") + .and_then(Value::as_array) + .and_then(|directories| { + directories + .iter() + .find_map(|value| trimmed_string(Some(value))) + }) + }) +} + +/// Effective model from Qoder's latest inline `runtime-config` update. +pub(crate) fn model_from(records: &[Value]) -> Option { + latest_inline_string(records, "runtime-config", "model") +} diff --git a/src-tauri/src/startup.rs b/src-tauri/src/startup.rs new file mode 100644 index 0000000..571608a --- /dev/null +++ b/src-tauri/src/startup.rs @@ -0,0 +1,201 @@ +// App boot sequencing. +// +// Cold-start rule: the Tauri main thread must only build the window, tray and event hooks. +// Every filesystem-heavy step that used to run synchronously inside `setup()` — one-time +// dir migrations, plugin reconcile, CLI connection repair, gateway/plugin start, history +// watcher registration and the usage-cache warm — runs here, off the main thread, in the +// same relative order as before. This is what keeps the window from showing a white, +// busy-cursor shell while the disk is being scanned. + +use serde_json::{json, Value}; +use tauri::{Emitter, Manager}; + +/// Fire-and-forget boot chain. Ordering preserved from the old synchronous setup(): +/// 1. one-time historyDirs migrations (must precede the watcher so new trees get watched) +/// 2. plugin reconcile + persisted CLI connection repair + login-item refresh +/// 3. gateway + active plugin start +/// 4. history watcher registration + usage cache warm +/// 5. tray refresh + change events so an already-loaded renderer reconciles +pub fn spawn_background_boot( + app: tauri::AppHandle, + gw: std::sync::Arc, + pm: std::sync::Arc, + startup_cfg: Value, +) { + tauri::async_runtime::spawn(async move { + let pm_fs = pm.clone(); + let app_fs = app.clone(); + let open_at_login = startup_cfg + .get("openAtLogin") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let fs_phase = tauri::async_runtime::spawn_blocking(move || { + // One-time migrations: detected installs of the other coding CLIs (Codex, Grok + // Build, Copilot CLI, Antigravity CLI, Qoder) and an XDG Claude tree join + // historyDirs as regular work dirs. `|` (not `||`) — every probe must run. + let migrated = crate::store::ensure_codex_dir() + | crate::store::ensure_xdg_claude_dir() + | crate::store::ensure_grok_dir() + | crate::store::ensure_copilot_dir() + | crate::store::ensure_antigravity_dir() + | crate::store::ensure_qoder_dir(); + // Reconcile services with installed plugins, then repair previously managed + // CLI targets that remain selected. Startup never connects a first-time target + // or disconnects one. + pm_fs.sync_providers(); + let cfg = crate::store::read_config(); + crate::reconcile_connections_on_startup(&cfg); + // Rewrite the login item to the current exe path — in-place hot updates (and + // the one-time bundle rename) otherwise leave it pointing at a binary that no + // longer exists. + if open_at_login { + use tauri_plugin_autostart::ManagerExt; + let _ = app_fs.autolaunch().enable(); + } + (migrated, cfg) + }) + .await; + let (migrated, cfg) = match fs_phase { + Ok(v) => v, + Err(_) => (false, startup_cfg.clone()), + }; + + // Gateway + the active plugin-backed service (if any) — the active service would + // otherwise be dead until the user re-enables its plugin. + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let enabled = cfg.get("gatewayEnabled").and_then(|v| v.as_bool()).unwrap_or(true); + if enabled { + if let Err(e) = gw.start(port).await { + eprintln!("[ccbud] gateway start failed: {}", e); + } + } + if let Some(pid) = active_plugin_id(&cfg) { + if let Err(e) = pm.start(&pid).await { + eprintln!("[ccbud] active plugin '{}' start failed: {}", pid, e); + } + } + + // Watcher + usage warm live on a detached thread for the app's lifetime. + let app_watch = app.clone(); + std::thread::spawn(move || start_history_watch_and_warm(&app_watch)); + + // Reflect the real gateway/connection state in the tray, and let an already-loaded + // renderer reconcile with any config the migrations/plugin sync just changed. + crate::refresh_tray_menu(&app); + let _ = app.emit("config:changed", crate::store::read_config()); + if migrated { + let _ = app.emit("history:changed", json!({ "files": [] })); + } + }); +} + +/// Plugin id backing the active provider, if the active provider is plugin-backed. +fn active_plugin_id(cfg: &Value) -> Option { + cfg.get("activeProviderId") + .and_then(|v| v.as_str()) + .and_then(|aid| { + cfg.get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(aid)) + }) + }) + .filter(|p| p.get("backend").and_then(|v| v.as_str()) == Some("plugin")) + .and_then(|p| p.get("pluginId").and_then(|v| v.as_str())) + .map(|s| s.to_string()) +} + +/// History live-watch (fs events on the projects dirs → history:changed) + the startup +/// usage-cache warm, so the FIRST popover open is instant instead of paying the cold-scan +/// cost. Recursive watch registration walks every projects tree — precisely the work that +/// must never run on the UI thread. +fn start_history_watch_and_warm(app: &tauri::AppHandle) { + use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebounceEventResult}; + let app_w = app.clone(); + if let Ok(mut deb) = new_debouncer( + std::time::Duration::from_millis(250), + move |res: DebounceEventResult| { + if let Ok(events) = res { + let files: Vec = events + .iter() + .map(|e| e.path.to_string_lossy().to_string()) + .collect(); + let _ = app_w.emit("history:changed", json!({ "files": files })); + // History changed → drop the stale usage cache and re-warm off-thread + // (+ refresh the tray title) so the next popover open stays instant. + crate::usage::invalidate_cache(); + let h = app_w.clone(); + std::thread::spawn(move || warm_usage_cache(&h)); + } + }, + ) { + for root in crate::history::watch_roots(&crate::store::read_config()) { + if root.is_dir() { + let _ = deb.watcher().watch(&root, RecursiveMode::Recursive); + } + } + std::mem::forget(deb); // keep watching for the app's lifetime + } + warm_usage_cache(app); +} + +/// Warm the usage cache and surface the scan shape in the settings Logs panel — the first +/// place to look when the usage numbers look wrong — then sync the tray usage title. +fn warm_usage_cache(app: &tauri::AppHandle) { + let cfg = crate::store::read_config(); + crate::usage::warm_cache(&cfg, "all"); + if let Some(g) = app.try_state::>() { + g.log("info", crate::usage::diag(&cfg, "all")); + } + crate::update_tray_title(app); +} + +/// Older installs live in "ccbud.app" (pre-1.3.4) or "CCBuddy.app" (1.3.4). The +/// in-app updater swaps the bundle's contents but never the folder itself, and +/// macOS shows CFBundleDisplayName only when the folder name matches CFBundleName +/// ("CC Buddy") — any mismatch makes the Dock and the Applications list fall back +/// to the folder name. Rename the bundle once, relaunch from the new path so +/// Launch Services re-registers it, and exit. Bails out on any obstacle +/// (translocation, read-only volume, name already taken) and keeps running under +/// the old name. +#[cfg(target_os = "macos")] +pub fn migrate_legacy_bundle_name() { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + // exe = /.app/Contents/MacOS/ + let bundle = match exe.ancestors().nth(3) { + Some(p) + if matches!( + p.file_name().and_then(|n| n.to_str()), + Some("ccbud.app") | Some("CCBuddy.app") + ) => + { + p.to_path_buf() + } + _ => return, + }; + let target = match bundle.parent() { + Some(dir) => dir.join("CC Buddy.app"), + None => return, + }; + if target.exists() || std::fs::rename(&bundle, &target).is_err() { + return; + } + // `open -n` asks Launch Services to start a fresh instance from the new path + // (which also re-registers the name). Wait for its verdict rather than exiting + // on spawn: a refusal must restore the old name so the running process keeps a + // valid bundle path behind it instead of leaving the user with nothing open. + let launched = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(&target) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if launched { + std::process::exit(0); + } + let _ = std::fs::rename(&target, &bundle); +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs deleted file mode 100644 index 81b288f..0000000 --- a/src-tauri/src/store.rs +++ /dev/null @@ -1,655 +0,0 @@ -// Config persistence. -// -// All settings live under ~/.ccbud/config.json (override the dir with CCBUD_HOME, used by -// tests/self-check). Writes are atomic (temp file + rename, mode 0600) so a crash mid-write -// never tears the file. `normalize` keeps the on-disk schema stable across releases. - -use serde_json::{json, Value}; -use std::fs; -use std::path::PathBuf; -use std::sync::{Mutex, MutexGuard, OnceLock}; - -pub fn ccbud_home() -> PathBuf { - if let Ok(d) = std::env::var("CCBUD_HOME") { - if !d.is_empty() { - return PathBuf::from(d); - } - } - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".ccbud") -} - -fn config_file() -> PathBuf { - ccbud_home().join("config.json") -} - -fn config_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -pub fn default_config() -> Value { - json!({ - "port": 8788, - "activeProviderId": null, - "requireToken": false, - "gatewayToken": "", - "gatewayEnabled": true, - "openAtLogin": false, - "claudeBackup": null, - "trayUsage": { "enabled": false, "range": "7d" }, - "language": null, - "historyDirs": ["~/.claude"], - "historyActive": "all", - "connectTargets": [], - "retry429": { "enabled": true, "max": 3, "baseMs": 500 }, - "insecureSkipVerify": false, - "autoUpdate": { "check": true, "autoDownload": true }, - "providers": [] - }) -} - -/// Collapse a home-prefixed absolute path back to `~` form so the UI shows -/// `~/.claude` instead of `/Users//.claude`. Inverse of history::expand_tilde. -pub fn collapse_home(p: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - if home.is_empty() { - return p.to_string(); - } - let home = home.trim_end_matches('/'); - if p == home { - return "~".to_string(); - } - if let Some(rest) = p.strip_prefix(&format!("{}/", home)) { - return format!("~/{}", rest); - } - p.to_string() -} - -fn str_of(v: Option<&Value>) -> String { - v.and_then(|x| x.as_str()).unwrap_or("").to_string() -} -fn bool_of(v: Option<&Value>, default: bool) -> bool { - v.and_then(|x| x.as_bool()).unwrap_or(default) -} - -/// Mirror of store.js `normalize`: merge over defaults, then sanitize every field. -pub fn normalize(input: Value) -> Value { - let mut c = default_config(); - if let Value::Object(src) = &input { - let obj = c.as_object_mut().unwrap(); - for (k, v) in src { - obj.insert(k.clone(), v.clone()); - } - } - - // ---- providers ---- - let mut norm_provs: Vec = vec![]; - if let Some(Value::Array(arr)) = c.get("providers") { - for p in arr { - let name = { - let n = str_of(p.get("name")); - if n.is_empty() { "Unnamed".to_string() } else { n } - }; - let map_default = p - .get("mapDefaultModels") - .map(|v| v.as_bool().unwrap_or(true)) - .unwrap_or(true); - let mut models: Vec = vec![]; - if let Some(Value::Array(ms)) = p.get("models") { - for m in ms { - let alias = str_of(m.get("alias")); - let upstream = str_of(m.get("upstream")); - if !alias.is_empty() || !upstream.is_empty() { - models.push(json!({ "alias": alias, "upstream": upstream })); - } - } - } - // Upstream wire protocol. Default 'anthropic' = today's verbatim passthrough; the - // other two make the gateway translate Claude Code's Anthropic Messages into the - // provider's format (see src/protocol/). Anything unrecognized falls back to anthropic. - let protocol = match p.get("protocol").and_then(|v| v.as_str()) { - Some("openai-chat") => "openai-chat", - Some("openai-responses") => "openai-responses", - _ => "anthropic", - }; - // Zhipu's Anthropic-compatible endpoint is versioned. The old preset omitted `/v1`; - // its unversioned path returns HTTP 200 with an embedded `404 NOT_FOUND`, which cannot - // trigger the gateway's status-based compatibility retry. Normalize only that exact - // legacy preset URL, leaving every custom/provider URL authoritative. - let mut base_url = str_of(p.get("baseUrl")); - if protocol == "anthropic" - && base_url.trim_end_matches('/') == "https://open.bigmodel.cn/api/anthropic" - { - base_url = "https://open.bigmodel.cn/api/anthropic/v1".to_string(); - } - let mut np = json!({ - "id": p.get("id").cloned().unwrap_or(Value::Null), - "name": name, - "baseUrl": base_url, - "authToken": str_of(p.get("authToken")), - "defaultModel": str_of(p.get("defaultModel")), - "smallFastModel": str_of(p.get("smallFastModel")), - "mapDefaultModels": map_default, - "protocol": protocol, - "models": models, - }); - if let Some(ic) = p.get("icon").and_then(|v| v.as_str()) { - if !ic.trim().is_empty() { - np.as_object_mut() - .unwrap() - .insert("icon".into(), json!(ic.trim())); - } - } - // Backend type. 'http' (default) = an ordinary upstream at baseUrl. 'plugin' = fronted - // by a local sidecar plugin process (see plugin.rs); its baseUrl points at the plugin's - // localhost port, maintained by PluginManager. pluginId links back to the plugin. - let backend = match p.get("backend").and_then(|v| v.as_str()) { - Some("plugin") => "plugin", - _ => "http", - }; - np.as_object_mut() - .unwrap() - .insert("backend".into(), json!(backend)); - if backend == "plugin" { - np.as_object_mut() - .unwrap() - .insert("pluginId".into(), json!(str_of(p.get("pluginId")))); - } - norm_provs.push(np); - } - } - // activeProviderId: keep if it points at a real provider, else first provider, else null. - let active = c.get("activeProviderId").cloned().unwrap_or(Value::Null); - let active_ok = norm_provs.iter().any(|p| p.get("id") == Some(&active)); - let active = if active_ok { - active - } else { - norm_provs - .first() - .and_then(|p| p.get("id").cloned()) - .unwrap_or(Value::Null) - }; - - let obj = c.as_object_mut().unwrap(); - obj.insert("providers".into(), json!(norm_provs)); - obj.insert("activeProviderId".into(), active); - - // ---- scalars ---- - let port = obj - .get("port") - .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) - .filter(|n| *n > 0) - .unwrap_or(8788); - obj.insert("port".into(), json!(port)); - obj.insert("requireToken".into(), json!(bool_of(obj.get("requireToken"), false))); - obj.insert("gatewayEnabled".into(), json!(bool_of(obj.get("gatewayEnabled"), true))); - obj.insert("gatewayToken".into(), json!(str_of(obj.get("gatewayToken")))); - obj.insert("openAtLogin".into(), json!(bool_of(obj.get("openAtLogin"), false))); - if obj.get("claudeBackup").map(|v| v.is_null()).unwrap_or(true) { - obj.insert("claudeBackup".into(), Value::Null); - } - - // trayUsage - let tu = obj.get("trayUsage").cloned().unwrap_or(json!({})); - let tu_enabled = bool_of(tu.get("enabled"), false); - let tu_range = tu - .get("range") - .and_then(|v| v.as_str()) - .filter(|r| ["1d", "7d", "30d", "all"].contains(r)) - .unwrap_or("7d"); - obj.insert("trayUsage".into(), json!({ "enabled": tu_enabled, "range": tu_range })); - - // retry429 (clamped) - let rr = obj.get("retry429").cloned().unwrap_or(json!({})); - let rr_enabled = rr.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let rr_max = rr.get("max").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10)).unwrap_or(3); - let rr_base = rr.get("baseMs").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10000)).unwrap_or(500); - obj.insert("retry429".into(), json!({ "enabled": rr_enabled, "max": rr_max, "baseMs": rr_base })); - - obj.insert("insecureSkipVerify".into(), json!(bool_of(obj.get("insecureSkipVerify"), false))); - - // autoUpdate - let au = obj.get("autoUpdate").cloned().unwrap_or(json!({})); - let au_check = au.get("check").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - let au_dl = au.get("autoDownload").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); - obj.insert("autoUpdate".into(), json!({ "check": au_check, "autoDownload": au_dl })); - - // language: only the supported set, else null - let lang = obj - .get("language") - .and_then(|v| v.as_str()) - .filter(|l| ["en", "zh", "zh-TW", "ja", "ko"].contains(l)) - .map(|s| s.to_string()); - obj.insert("language".into(), lang.map(Value::String).unwrap_or(Value::Null)); - - // historyDirs: trim, strip trailing slashes, dedup, ensure ~/.claude present - let mut dirs: Vec = vec![]; - if let Some(Value::Array(ds)) = obj.get("historyDirs") { - for d in ds { - if let Some(s) = d.as_str() { - // Collapse home-prefixed absolute paths to `~/…` for a tidy, portable display. - let t = collapse_home(s.trim().trim_end_matches(['/', '\\'])); - if !t.is_empty() && !dirs.iter().any(|x| *x == t) { - dirs.push(t); - } - } - } - } - if !dirs.iter().any(|d| d == "~/.claude") { - dirs.insert(0, "~/.claude".to_string()); - } - obj.insert("historyDirs".into(), json!(dirs)); - - // connectTargets: which coding CLIs are wired to the gateway. Subset of {claude, codex}, deduped. - // Empty is a VALID state (everything disconnected) — don't snap it back to ["claude"], or the UI - // toggle for the last-remaining CLI could never turn off. Fresh and legacy configs deliberately - // normalize to [] so startup never mistakes a schema default for an explicit connection choice. - let mut targets: Vec = vec![]; - if let Some(arr) = obj.get("connectTargets").and_then(|v| v.as_array()) { - for t in arr { - if let Some(s) = t.as_str() { - if (s == "claude" || s == "codex") && !targets.iter().any(|x| x == s) { - targets.push(s.to_string()); - } - } - } - } - obj.insert("connectTargets".into(), json!(targets)); - - // historyActive: 'all' | '__imported__' | '__trash__' (recycle bin) | a configured dir, else 'all'. - // '__codex__' is the retired synthetic Codex bucket — map it onto the real ~/.codex dir entry. - let ha = obj.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); - let ha = if ha == "__codex__" { crate::codex::codex_label() } else { ha }; - let ha_ok = ha == "all" || ha == "__imported__" || ha == "__trash__" || dirs.iter().any(|d| *d == ha); - obj.insert("historyActive".into(), json!(if ha_ok { ha } else { "all".to_string() })); - - c -} - -fn read_config_unlocked() -> Value { - match fs::read_to_string(config_file()) { - Ok(s) => match serde_json::from_str::(&s) { - Ok(v) => normalize(v), - Err(_) => default_config(), - }, - Err(_) => default_config(), - } -} - -pub fn read_config() -> Value { - let _guard = config_lock(); - read_config_unlocked() -} - -fn write_config_unlocked(next: Value) -> (Value, bool) { - let normalized = normalize(next); - let dir = ccbud_home(); - if fs::create_dir_all(&dir).is_err() { - return (normalized, false); - } - let file = config_file(); - let tmp = dir.join("config.json.tmp"); - if let Ok(bytes) = serde_json::to_vec_pretty(&normalized) { - if fs::write(&tmp, &bytes).is_ok() { - set_0600(&tmp); - if fs::rename(&tmp, &file).is_ok() { - set_0600(&file); - return (normalized, true); - } - } - } - let _ = fs::remove_file(tmp); - (normalized, false) -} - -pub fn write_config(next: Value) -> Value { - let _guard = config_lock(); - write_config_unlocked(next).0 -} - -fn update_provider_base_url_to_v1( - config: &mut Value, - provider_id: &str, - expected_base_url: &str, -) -> bool { - let Some(provider) = config - .get_mut("providers") - .and_then(Value::as_array_mut) - .and_then(|providers| { - providers - .iter_mut() - .find(|provider| provider.get("id").and_then(Value::as_str) == Some(provider_id)) - }) - else { - return false; - }; - if provider.get("backend").and_then(Value::as_str) == Some("plugin") - || provider.get("baseUrl").and_then(Value::as_str) != Some(expected_base_url) - { - return false; - } - let Some(provider) = provider.as_object_mut() else { - return false; - }; - provider.insert( - "baseUrl".into(), - json!(format!("{}/v1", expected_base_url.trim_end_matches('/'))), - ); - true -} - -/// Atomically migrate one HTTP provider's base URL after a successful `/v1` fallback. -/// The expected URL is a compare-and-swap guard against overwriting a concurrent user edit. -pub fn migrate_provider_base_url_to_v1( - provider_id: &str, - expected_base_url: &str, -) -> Option { - let _guard = config_lock(); - let mut config = read_config_unlocked(); - if !update_provider_base_url_to_v1(&mut config, provider_id, expected_base_url) { - return None; - } - let (saved, persisted) = write_config_unlocked(config); - persisted.then_some(saved) -} - -/// One-time startup migration: when a Codex install exists (its sessions tree is on disk), -/// add its config dir (`~/.codex`, CODEX_HOME-aware) to historyDirs so Codex conversations -/// appear in 对话 like any other work dir. The `codexDirAutoAdded` flag makes this run once — -/// a user who later REMOVES the dir isn't fighting an auto-re-add. Returns true if it changed -/// the config (caller refreshes the history views). Mirrors main.js ensureCodexDir. -pub fn ensure_codex_dir() -> bool { - let mut cfg = read_config(); - if cfg.get("codexDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - if !crate::codex::root_exists() { - return false; // no Codex install yet — keep probing on future launches - } - let label = crate::codex::codex_label(); - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert("codexDirAutoAdded".into(), json!(true)); - write_config(cfg); - true -} - -/// Shared body of the ensure_*_dir migrations: when `exists` and the run-once `flag` hasn't -/// fired, add `label` to historyDirs (dedup) and set the flag. Returns true when the config -/// changed (caller refreshes the history views). A user who later REMOVES the dir isn't -/// fighting an auto-re-add; a missing install keeps probing on future launches. -fn ensure_history_dir(flag: &str, exists: bool, label: String) -> bool { - let mut cfg = read_config(); - if cfg.get(flag).and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - if !exists { - return false; // nothing there yet — keep probing on future launches - } - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert(flag.into(), json!(true)); - write_config(cfg); - true -} - -/// One-time startup migrations for the other coding CLIs whose sessions the 对话 view can -/// browse: Grok Build (~/.grok, GROK_HOME-aware), GitHub Copilot CLI (~/.copilot), and the -/// Antigravity CLI (~/.gemini/antigravity-cli). Same run-once contract as ensure_codex_dir. -pub fn ensure_grok_dir() -> bool { - ensure_history_dir("grokDirAutoAdded", crate::grok::root_exists(), crate::grok::grok_label()) -} - -pub fn ensure_copilot_dir() -> bool { - ensure_history_dir("copilotDirAutoAdded", crate::copilot::root_exists(), crate::copilot::copilot_label()) -} - -pub fn ensure_antigravity_dir() -> bool { - ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) -} - -/// Qoder writes Claude-format sessions under two known roots (~/.qoder and ~/.qoderwork) — -/// each detected root joins historyDirs once, under its own run-once flag. -pub fn ensure_qoder_dir() -> bool { - let mut changed = false; - for (flag, root) in - [("qoderDirAutoAdded", crate::qoder::default_root()), ("qoderworkDirAutoAdded", crate::qoder::work_root())] - { - changed |= ensure_history_dir(flag, crate::qoder::root_exists(&root), collapse_home(&root.to_string_lossy())); - } - changed -} - -/// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG -/// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, -/// add it to historyDirs so its sessions count toward conversations and usage. Same run-once -/// contract as ensure_codex_dir. -pub fn ensure_xdg_claude_dir() -> bool { - let mut cfg = read_config(); - if cfg.get("xdgClaudeDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { - return false; - } - let base = std::env::var("XDG_CONFIG_HOME") - .ok() - .filter(|s| !s.trim().is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| { - std::env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".config") - }); - let dir = base.join("claude"); - if !dir.join("projects").is_dir() { - return false; // nothing there yet — keep probing on future launches - } - let label = dir.to_string_lossy().to_string(); - let obj = cfg.as_object_mut().unwrap(); - let mut dirs: Vec = obj - .get("historyDirs") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - if !dirs.iter().any(|d| *d == label) { - dirs.push(label); - } - obj.insert("historyDirs".into(), json!(dirs)); - obj.insert("xdgClaudeDirAutoAdded".into(), json!(true)); - write_config(cfg); - true -} - -#[cfg(unix)] -fn set_0600(p: &PathBuf) { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o600)); -} -#[cfg(not(unix))] -fn set_0600(_p: &PathBuf) {} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn fresh_and_legacy_configs_do_not_select_a_startup_connection() { - assert_eq!(default_config()["connectTargets"], json!([])); - assert_eq!(normalize(json!({}))["connectTargets"], json!([])); - - let explicit = normalize(json!({ - "connectTargets": ["codex", "claude", "codex", "invalid"] - })); - assert_eq!(explicit["connectTargets"], json!(["codex", "claude"])); - } - - #[test] - fn normalize_sanitizes_providers_and_active() { - let input = json!({ - "port": 9000, - "providers": [{ "name": "X", "baseUrl": "u", "authToken": "t", "extra": "drop", - "models": [{ "alias": "a", "upstream": "u" }, { "alias": "", "upstream": "" }] }] - }); - let n = normalize(input); - assert_eq!(n["port"], 9000); - assert_eq!(n["providers"][0]["name"], "X"); - assert!(n["providers"][0].get("extra").is_none(), "unknown field must be dropped"); - assert_eq!(n["providers"][0]["models"].as_array().unwrap().len(), 1, "empty model dropped"); - assert_eq!(n["activeProviderId"], n["providers"][0]["id"], "active auto-set to first provider"); - assert!(n["historyDirs"].as_array().unwrap().iter().any(|d| d == "~/.claude")); - assert_eq!(n["providers"][0]["protocol"], "anthropic", "protocol defaults to anthropic (passthrough)"); - } - - #[test] - fn provider_protocol_normalized() { - let ok = normalize(json!({ "providers": [{ "name": "O", "protocol": "openai-chat" }] })); - assert_eq!(ok["providers"][0]["protocol"], "openai-chat"); - // unrecognized → safe passthrough default - let bad = normalize(json!({ "providers": [{ "name": "B", "protocol": "grpc" }] })); - assert_eq!(bad["providers"][0]["protocol"], "anthropic"); - } - #[test] - fn normalize_migrates_legacy_glm_anthropic_base_url() { - let legacy = normalize(json!({ "providers": [{ - "name": "GLM", - "baseUrl": "https://open.bigmodel.cn/api/anthropic/", - "protocol": "anthropic" - }] })); - assert_eq!( - legacy["providers"][0]["baseUrl"], - "https://open.bigmodel.cn/api/anthropic/v1" - ); - - let custom = normalize(json!({ "providers": [{ - "name": "Custom", - "baseUrl": "https://example.com/api/anthropic", - "protocol": "anthropic" - }] })); - assert_eq!(custom["providers"][0]["baseUrl"], "https://example.com/api/anthropic"); - } - #[test] - fn normalize_clamps_retry() { - let n = normalize(json!({ "retry429": { "max": 999, "baseMs": 99999 } })); - assert_eq!(n["retry429"]["max"], 10); - assert_eq!(n["retry429"]["baseMs"], 10000); - } - #[test] - fn normalize_keeps_recycle_bin_active() { - // Synthetic buckets must survive normalize, else history_set_active("__trash__") is - // silently reset to "all" and the recycle bin can never be opened. - assert_eq!(normalize(json!({ "historyActive": "__trash__" }))["historyActive"], "__trash__"); - assert_eq!(normalize(json!({ "historyActive": "__imported__" }))["historyActive"], "__imported__"); - assert_eq!(normalize(json!({ "historyActive": "bogus-dir" }))["historyActive"], "all"); - } - - #[test] - fn provider_base_url_v1_migration_updates_only_the_matching_url() { - let mut config = json!({ - "port": 9000, - "customSetting": { "keep": true }, - "providers": [ - { - "id": "target", - "name": "Target", - "backend": "http", - "baseUrl": "https://example.com/api/", - "authToken": "secret", - "defaultModel": "model-a", - "models": [{ "alias": "fast", "upstream": "model-b" }] - }, - { - "id": "other", - "backend": "http", - "baseUrl": "https://other.example/api", - "authToken": "other-secret" - } - ] - }); - let before_other = config["providers"][1].clone(); - let before_settings = config["customSetting"].clone(); - - assert!(update_provider_base_url_to_v1( - &mut config, - "target", - "https://example.com/api/" - )); - assert_eq!( - config["providers"][0]["baseUrl"], - "https://example.com/api/v1" - ); - assert_eq!(config["providers"][0]["authToken"], "secret"); - assert_eq!(config["providers"][0]["defaultModel"], "model-a"); - assert_eq!( - config["providers"][0]["models"], - json!([{ "alias": "fast", "upstream": "model-b" }]) - ); - assert_eq!(config["providers"][1], before_other); - assert_eq!(config["customSetting"], before_settings); - assert_eq!(config["port"], 9000); - } - - #[test] - fn provider_base_url_v1_migration_requires_expected_old_url() { - let mut config = json!({ - "providers": [{ - "id": "target", - "backend": "http", - "baseUrl": "https://example.com/user-edit" - }] - }); - let before = config.clone(); - - assert!(!update_provider_base_url_to_v1( - &mut config, - "target", - "https://example.com/old" - )); - assert_eq!(config, before); - } - - #[test] - fn provider_base_url_v1_migration_skips_plugins() { - let mut config = json!({ - "providers": [{ - "id": "target", - "backend": "plugin", - "baseUrl": "http://127.0.0.1:12345" - }] - }); - let before = config.clone(); - - assert!(!update_provider_base_url_to_v1( - &mut config, - "target", - "http://127.0.0.1:12345" - )); - assert_eq!(config, before); - } -} - -/// Stable-enough unique id for a new provider (single-user, serialized writes). -pub fn gen_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("p{}", n) -} diff --git a/src-tauri/src/store/defaults.rs b/src-tauri/src/store/defaults.rs new file mode 100644 index 0000000..1876971 --- /dev/null +++ b/src-tauri/src/store/defaults.rs @@ -0,0 +1,24 @@ +// The shipped default config. Moved verbatim from store.rs. + +use serde_json::{json, Value}; + +pub fn default_config() -> Value { + json!({ + "port": 8788, + "activeProviderId": null, + "requireToken": false, + "gatewayToken": "", + "gatewayEnabled": true, + "openAtLogin": false, + "claudeBackup": null, + "trayUsage": { "enabled": false, "range": "7d" }, + "language": null, + "historyDirs": ["~/.claude"], + "historyActive": "all", + "connectTargets": [], + "retry429": { "enabled": true, "max": 3, "baseMs": 500 }, + "insecureSkipVerify": false, + "autoUpdate": { "check": true, "autoDownload": true }, + "providers": [] + }) +} diff --git a/src-tauri/src/store/dirs.rs b/src-tauri/src/store/dirs.rs new file mode 100644 index 0000000..bffbf1b --- /dev/null +++ b/src-tauri/src/store/dirs.rs @@ -0,0 +1,130 @@ +// The run-once startup migrations that add each detected coding CLI's data dir to historyDirs. +// Moved verbatim from store.rs. + +use serde_json::json; +use std::path::PathBuf; + +use super::io::{read_config, write_config}; +use super::paths::collapse_home; + +/// One-time startup migration: when a Codex install exists (its sessions tree is on disk), +/// add its config dir (`~/.codex`, CODEX_HOME-aware) to historyDirs so Codex conversations +/// appear in 对话 like any other work dir. The `codexDirAutoAdded` flag makes this run once — +/// a user who later REMOVES the dir isn't fighting an auto-re-add. Returns true if it changed +/// the config (caller refreshes the history views). Mirrors main.js ensureCodexDir. +pub fn ensure_codex_dir() -> bool { + let mut cfg = read_config(); + if cfg.get("codexDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + if !crate::codex::root_exists() { + return false; // no Codex install yet — keep probing on future launches + } + let label = crate::codex::codex_label(); + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert("codexDirAutoAdded".into(), json!(true)); + write_config(cfg); + true +} + +/// Shared body of the ensure_*_dir migrations: when `exists` and the run-once `flag` hasn't +/// fired, add `label` to historyDirs (dedup) and set the flag. Returns true when the config +/// changed (caller refreshes the history views). A user who later REMOVES the dir isn't +/// fighting an auto-re-add; a missing install keeps probing on future launches. +fn ensure_history_dir(flag: &str, exists: bool, label: String) -> bool { + let mut cfg = read_config(); + if cfg.get(flag).and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + if !exists { + return false; // nothing there yet — keep probing on future launches + } + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert(flag.into(), json!(true)); + write_config(cfg); + true +} + +/// One-time startup migrations for the other coding CLIs whose sessions the 对话 view can +/// browse: Grok Build (~/.grok, GROK_HOME-aware), GitHub Copilot CLI (~/.copilot), and the +/// Antigravity CLI (~/.gemini/antigravity-cli). Same run-once contract as ensure_codex_dir. +pub fn ensure_grok_dir() -> bool { + ensure_history_dir("grokDirAutoAdded", crate::grok::root_exists(), crate::grok::grok_label()) +} + +pub fn ensure_copilot_dir() -> bool { + ensure_history_dir("copilotDirAutoAdded", crate::copilot::root_exists(), crate::copilot::copilot_label()) +} + +pub fn ensure_antigravity_dir() -> bool { + ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) +} + +/// Qoder writes Claude-format sessions under two known roots (~/.qoder and ~/.qoderwork) — +/// each detected root joins historyDirs once, under its own run-once flag. +pub fn ensure_qoder_dir() -> bool { + let mut changed = false; + for (flag, root) in + [("qoderDirAutoAdded", crate::qoder::default_root()), ("qoderworkDirAutoAdded", crate::qoder::work_root())] + { + changed |= ensure_history_dir(flag, crate::qoder::root_exists(&root), collapse_home(&root.to_string_lossy())); + } + changed +} + +/// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG +/// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, +/// add it to historyDirs so its sessions count toward conversations and usage. Same run-once +/// contract as ensure_codex_dir. +pub fn ensure_xdg_claude_dir() -> bool { + let mut cfg = read_config(); + if cfg.get("xdgClaudeDirAutoAdded").and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".config") + }); + let dir = base.join("claude"); + if !dir.join("projects").is_dir() { + return false; // nothing there yet — keep probing on future launches + } + let label = dir.to_string_lossy().to_string(); + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert("xdgClaudeDirAutoAdded".into(), json!(true)); + write_config(cfg); + true +} diff --git a/src-tauri/src/store/io.rs b/src-tauri/src/store/io.rs new file mode 100644 index 0000000..18156ca --- /dev/null +++ b/src-tauri/src/store/io.rs @@ -0,0 +1,96 @@ +// Reading and atomically writing config.json, plus the one provider baseUrl migration. Moved +// verbatim from store.rs. + +use serde_json::{json, Value}; +use std::fs; + +use super::defaults::default_config; +use super::normalize::normalize; +use super::paths::{ccbud_home, config_file, config_lock, set_0600}; + +fn read_config_unlocked() -> Value { + match fs::read_to_string(config_file()) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(v) => normalize(v), + Err(_) => default_config(), + }, + Err(_) => default_config(), + } +} + +pub fn read_config() -> Value { + let _guard = config_lock(); + read_config_unlocked() +} + +fn write_config_unlocked(next: Value) -> (Value, bool) { + let normalized = normalize(next); + let dir = ccbud_home(); + if fs::create_dir_all(&dir).is_err() { + return (normalized, false); + } + let file = config_file(); + let tmp = dir.join("config.json.tmp"); + if let Ok(bytes) = serde_json::to_vec_pretty(&normalized) { + if fs::write(&tmp, &bytes).is_ok() { + set_0600(&tmp); + if fs::rename(&tmp, &file).is_ok() { + set_0600(&file); + return (normalized, true); + } + } + } + let _ = fs::remove_file(tmp); + (normalized, false) +} + +pub fn write_config(next: Value) -> Value { + let _guard = config_lock(); + write_config_unlocked(next).0 +} + +pub(super) fn update_provider_base_url_to_v1( + config: &mut Value, + provider_id: &str, + expected_base_url: &str, +) -> bool { + let Some(provider) = config + .get_mut("providers") + .and_then(Value::as_array_mut) + .and_then(|providers| { + providers + .iter_mut() + .find(|provider| provider.get("id").and_then(Value::as_str) == Some(provider_id)) + }) + else { + return false; + }; + if provider.get("backend").and_then(Value::as_str) == Some("plugin") + || provider.get("baseUrl").and_then(Value::as_str) != Some(expected_base_url) + { + return false; + } + let Some(provider) = provider.as_object_mut() else { + return false; + }; + provider.insert( + "baseUrl".into(), + json!(format!("{}/v1", expected_base_url.trim_end_matches('/'))), + ); + true +} + +/// Atomically migrate one HTTP provider's base URL after a successful `/v1` fallback. +/// The expected URL is a compare-and-swap guard against overwriting a concurrent user edit. +pub fn migrate_provider_base_url_to_v1( + provider_id: &str, + expected_base_url: &str, +) -> Option { + let _guard = config_lock(); + let mut config = read_config_unlocked(); + if !update_provider_base_url_to_v1(&mut config, provider_id, expected_base_url) { + return None; + } + let (saved, persisted) = write_config_unlocked(config); + persisted.then_some(saved) +} diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs new file mode 100644 index 0000000..b0821fc --- /dev/null +++ b/src-tauri/src/store/mod.rs @@ -0,0 +1,25 @@ +// Config persistence. +// +// All settings live under ~/.ccbud/config.json (override the dir with CCBUD_HOME, used by +// tests/self-check). Writes are atomic (temp file + rename, mode 0600) so a crash mid-write +// never tears the file. `normalize` keeps the on-disk schema stable across releases. +mod defaults; +mod dirs; +mod io; +mod normalize; +mod paths; +#[cfg(test)] +mod tests; + +pub use dirs::{ + ensure_antigravity_dir, ensure_codex_dir, ensure_copilot_dir, ensure_grok_dir, + ensure_qoder_dir, ensure_xdg_claude_dir, +}; +pub use io::{migrate_provider_base_url_to_v1, read_config, write_config}; +pub use paths::{ccbud_home, collapse_home, gen_id}; +// Part of the module's API but currently only referenced from within it — a non-test build sees +// these re-exports as unused; allow that instead of dropping the paths. +#[allow(unused_imports)] +pub use defaults::default_config; +#[allow(unused_imports)] +pub use normalize::normalize; diff --git a/src-tauri/src/store/normalize.rs b/src-tauri/src/store/normalize.rs new file mode 100644 index 0000000..44613b7 --- /dev/null +++ b/src-tauri/src/store/normalize.rs @@ -0,0 +1,201 @@ +// Schema normalization: merge over defaults, then sanitize every field. Moved verbatim from +// store.rs. + +use serde_json::{json, Value}; + +use super::defaults::default_config; +use super::paths::{bool_of, collapse_home, str_of}; + +/// Mirror of store.js `normalize`: merge over defaults, then sanitize every field. +pub fn normalize(input: Value) -> Value { + let mut c = default_config(); + if let Value::Object(src) = &input { + let obj = c.as_object_mut().unwrap(); + for (k, v) in src { + obj.insert(k.clone(), v.clone()); + } + } + + // ---- providers ---- + let mut norm_provs: Vec = vec![]; + if let Some(Value::Array(arr)) = c.get("providers") { + for p in arr { + let name = { + let n = str_of(p.get("name")); + if n.is_empty() { "Unnamed".to_string() } else { n } + }; + let map_default = p + .get("mapDefaultModels") + .map(|v| v.as_bool().unwrap_or(true)) + .unwrap_or(true); + let mut models: Vec = vec![]; + if let Some(Value::Array(ms)) = p.get("models") { + for m in ms { + let alias = str_of(m.get("alias")); + let upstream = str_of(m.get("upstream")); + if !alias.is_empty() || !upstream.is_empty() { + models.push(json!({ "alias": alias, "upstream": upstream })); + } + } + } + // Upstream wire protocol. Default 'anthropic' = today's verbatim passthrough; the + // other two make the gateway translate Claude Code's Anthropic Messages into the + // provider's format (see src/protocol/). Anything unrecognized falls back to anthropic. + let protocol = match p.get("protocol").and_then(|v| v.as_str()) { + Some("openai-chat") => "openai-chat", + Some("openai-responses") => "openai-responses", + _ => "anthropic", + }; + // Zhipu's Anthropic-compatible endpoint is versioned. The old preset omitted `/v1`; + // its unversioned path returns HTTP 200 with an embedded `404 NOT_FOUND`, which cannot + // trigger the gateway's status-based compatibility retry. Normalize only that exact + // legacy preset URL, leaving every custom/provider URL authoritative. + let mut base_url = str_of(p.get("baseUrl")); + if protocol == "anthropic" + && base_url.trim_end_matches('/') == "https://open.bigmodel.cn/api/anthropic" + { + base_url = "https://open.bigmodel.cn/api/anthropic/v1".to_string(); + } + let mut np = json!({ + "id": p.get("id").cloned().unwrap_or(Value::Null), + "name": name, + "baseUrl": base_url, + "authToken": str_of(p.get("authToken")), + "defaultModel": str_of(p.get("defaultModel")), + "smallFastModel": str_of(p.get("smallFastModel")), + "mapDefaultModels": map_default, + "protocol": protocol, + "models": models, + }); + if let Some(ic) = p.get("icon").and_then(|v| v.as_str()) { + if !ic.trim().is_empty() { + np.as_object_mut() + .unwrap() + .insert("icon".into(), json!(ic.trim())); + } + } + // Backend type. 'http' (default) = an ordinary upstream at baseUrl. 'plugin' = fronted + // by a local sidecar plugin process (see plugin.rs); its baseUrl points at the plugin's + // localhost port, maintained by PluginManager. pluginId links back to the plugin. + let backend = match p.get("backend").and_then(|v| v.as_str()) { + Some("plugin") => "plugin", + _ => "http", + }; + np.as_object_mut() + .unwrap() + .insert("backend".into(), json!(backend)); + if backend == "plugin" { + np.as_object_mut() + .unwrap() + .insert("pluginId".into(), json!(str_of(p.get("pluginId")))); + } + norm_provs.push(np); + } + } + // activeProviderId: keep if it points at a real provider, else first provider, else null. + let active = c.get("activeProviderId").cloned().unwrap_or(Value::Null); + let active_ok = norm_provs.iter().any(|p| p.get("id") == Some(&active)); + let active = if active_ok { + active + } else { + norm_provs + .first() + .and_then(|p| p.get("id").cloned()) + .unwrap_or(Value::Null) + }; + + let obj = c.as_object_mut().unwrap(); + obj.insert("providers".into(), json!(norm_provs)); + obj.insert("activeProviderId".into(), active); + + // ---- scalars ---- + let port = obj + .get("port") + .and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) + .filter(|n| *n > 0) + .unwrap_or(8788); + obj.insert("port".into(), json!(port)); + obj.insert("requireToken".into(), json!(bool_of(obj.get("requireToken"), false))); + obj.insert("gatewayEnabled".into(), json!(bool_of(obj.get("gatewayEnabled"), true))); + obj.insert("gatewayToken".into(), json!(str_of(obj.get("gatewayToken")))); + obj.insert("openAtLogin".into(), json!(bool_of(obj.get("openAtLogin"), false))); + if obj.get("claudeBackup").map(|v| v.is_null()).unwrap_or(true) { + obj.insert("claudeBackup".into(), Value::Null); + } + + // trayUsage + let tu = obj.get("trayUsage").cloned().unwrap_or(json!({})); + let tu_enabled = bool_of(tu.get("enabled"), false); + let tu_range = tu + .get("range") + .and_then(|v| v.as_str()) + .filter(|r| ["1d", "7d", "30d", "all"].contains(r)) + .unwrap_or("7d"); + obj.insert("trayUsage".into(), json!({ "enabled": tu_enabled, "range": tu_range })); + + // retry429 (clamped) + let rr = obj.get("retry429").cloned().unwrap_or(json!({})); + let rr_enabled = rr.get("enabled").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let rr_max = rr.get("max").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10)).unwrap_or(3); + let rr_base = rr.get("baseMs").and_then(|v| v.as_i64()).filter(|n| *n >= 0).map(|n| n.min(10000)).unwrap_or(500); + obj.insert("retry429".into(), json!({ "enabled": rr_enabled, "max": rr_max, "baseMs": rr_base })); + + obj.insert("insecureSkipVerify".into(), json!(bool_of(obj.get("insecureSkipVerify"), false))); + + // autoUpdate + let au = obj.get("autoUpdate").cloned().unwrap_or(json!({})); + let au_check = au.get("check").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + let au_dl = au.get("autoDownload").map(|v| v.as_bool().unwrap_or(true)).unwrap_or(true); + obj.insert("autoUpdate".into(), json!({ "check": au_check, "autoDownload": au_dl })); + + // language: only the supported set, else null + let lang = obj + .get("language") + .and_then(|v| v.as_str()) + .filter(|l| ["en", "zh", "zh-TW", "ja", "ko"].contains(l)) + .map(|s| s.to_string()); + obj.insert("language".into(), lang.map(Value::String).unwrap_or(Value::Null)); + + // historyDirs: trim, strip trailing slashes, dedup, ensure ~/.claude present + let mut dirs: Vec = vec![]; + if let Some(Value::Array(ds)) = obj.get("historyDirs") { + for d in ds { + if let Some(s) = d.as_str() { + // Collapse home-prefixed absolute paths to `~/…` for a tidy, portable display. + let t = collapse_home(s.trim().trim_end_matches(['/', '\\'])); + if !t.is_empty() && !dirs.iter().any(|x| *x == t) { + dirs.push(t); + } + } + } + } + if !dirs.iter().any(|d| d == "~/.claude") { + dirs.insert(0, "~/.claude".to_string()); + } + obj.insert("historyDirs".into(), json!(dirs)); + + // connectTargets: which coding CLIs are wired to the gateway. Subset of {claude, codex}, deduped. + // Empty is a VALID state (everything disconnected) — don't snap it back to ["claude"], or the UI + // toggle for the last-remaining CLI could never turn off. Fresh and legacy configs deliberately + // normalize to [] so startup never mistakes a schema default for an explicit connection choice. + let mut targets: Vec = vec![]; + if let Some(arr) = obj.get("connectTargets").and_then(|v| v.as_array()) { + for t in arr { + if let Some(s) = t.as_str() { + if (s == "claude" || s == "codex") && !targets.iter().any(|x| x == s) { + targets.push(s.to_string()); + } + } + } + } + obj.insert("connectTargets".into(), json!(targets)); + + // historyActive: 'all' | '__imported__' | '__trash__' (recycle bin) | a configured dir, else 'all'. + // '__codex__' is the retired synthetic Codex bucket — map it onto the real ~/.codex dir entry. + let ha = obj.get("historyActive").and_then(|v| v.as_str()).unwrap_or("all").to_string(); + let ha = if ha == "__codex__" { crate::codex::codex_label() } else { ha }; + let ha_ok = ha == "all" || ha == "__imported__" || ha == "__trash__" || dirs.iter().any(|d| *d == ha); + obj.insert("historyActive".into(), json!(if ha_ok { ha } else { "all".to_string() })); + + c +} diff --git a/src-tauri/src/store/paths.rs b/src-tauri/src/store/paths.rs new file mode 100644 index 0000000..bc5cdb3 --- /dev/null +++ b/src-tauri/src/store/paths.rs @@ -0,0 +1,70 @@ +// Where the config lives, the write lock, home-collapsing, the small JSON accessors, the 0600 +// permission helper and provider id generation. Moved verbatim from store.rs. + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +pub fn ccbud_home() -> PathBuf { + if let Ok(d) = std::env::var("CCBUD_HOME") { + if !d.is_empty() { + return PathBuf::from(d); + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".ccbud") +} + +pub(super) fn config_file() -> PathBuf { + ccbud_home().join("config.json") +} + +pub(super) fn config_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Collapse a home-prefixed absolute path back to `~` form so the UI shows +/// `~/.claude` instead of `/Users//.claude`. Inverse of history::expand_tilde. +pub fn collapse_home(p: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return p.to_string(); + } + let home = home.trim_end_matches('/'); + if p == home { + return "~".to_string(); + } + if let Some(rest) = p.strip_prefix(&format!("{}/", home)) { + return format!("~/{}", rest); + } + p.to_string() +} + +pub(super) fn str_of(v: Option<&Value>) -> String { + v.and_then(|x| x.as_str()).unwrap_or("").to_string() +} +pub(super) fn bool_of(v: Option<&Value>, default: bool) -> bool { + v.and_then(|x| x.as_bool()).unwrap_or(default) +} + +#[cfg(unix)] +pub(super) fn set_0600(p: &PathBuf) { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +pub(super) fn set_0600(_p: &PathBuf) {} + +/// Stable-enough unique id for a new provider (single-user, serialized writes). +pub fn gen_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("p{}", n) +} diff --git a/src-tauri/src/store/tests.rs b/src-tauri/src/store/tests.rs new file mode 100644 index 0000000..7a91c1e --- /dev/null +++ b/src-tauri/src/store/tests.rs @@ -0,0 +1,158 @@ +use super::defaults::default_config; +use super::io::update_provider_base_url_to_v1; +use super::normalize::normalize; +use serde_json::json; + +#[test] +fn fresh_and_legacy_configs_do_not_select_a_startup_connection() { + assert_eq!(default_config()["connectTargets"], json!([])); + assert_eq!(normalize(json!({}))["connectTargets"], json!([])); + + let explicit = normalize(json!({ + "connectTargets": ["codex", "claude", "codex", "invalid"] + })); + assert_eq!(explicit["connectTargets"], json!(["codex", "claude"])); +} + +#[test] +fn normalize_sanitizes_providers_and_active() { + let input = json!({ + "port": 9000, + "providers": [{ "name": "X", "baseUrl": "u", "authToken": "t", "extra": "drop", + "models": [{ "alias": "a", "upstream": "u" }, { "alias": "", "upstream": "" }] }] + }); + let n = normalize(input); + assert_eq!(n["port"], 9000); + assert_eq!(n["providers"][0]["name"], "X"); + assert!(n["providers"][0].get("extra").is_none(), "unknown field must be dropped"); + assert_eq!(n["providers"][0]["models"].as_array().unwrap().len(), 1, "empty model dropped"); + assert_eq!(n["activeProviderId"], n["providers"][0]["id"], "active auto-set to first provider"); + assert!(n["historyDirs"].as_array().unwrap().iter().any(|d| d == "~/.claude")); + assert_eq!(n["providers"][0]["protocol"], "anthropic", "protocol defaults to anthropic (passthrough)"); +} + +#[test] +fn provider_protocol_normalized() { + let ok = normalize(json!({ "providers": [{ "name": "O", "protocol": "openai-chat" }] })); + assert_eq!(ok["providers"][0]["protocol"], "openai-chat"); + // unrecognized → safe passthrough default + let bad = normalize(json!({ "providers": [{ "name": "B", "protocol": "grpc" }] })); + assert_eq!(bad["providers"][0]["protocol"], "anthropic"); +} +#[test] +fn normalize_migrates_legacy_glm_anthropic_base_url() { + let legacy = normalize(json!({ "providers": [{ + "name": "GLM", + "baseUrl": "https://open.bigmodel.cn/api/anthropic/", + "protocol": "anthropic" + }] })); + assert_eq!( + legacy["providers"][0]["baseUrl"], + "https://open.bigmodel.cn/api/anthropic/v1" + ); + + let custom = normalize(json!({ "providers": [{ + "name": "Custom", + "baseUrl": "https://example.com/api/anthropic", + "protocol": "anthropic" + }] })); + assert_eq!(custom["providers"][0]["baseUrl"], "https://example.com/api/anthropic"); +} +#[test] +fn normalize_clamps_retry() { + let n = normalize(json!({ "retry429": { "max": 999, "baseMs": 99999 } })); + assert_eq!(n["retry429"]["max"], 10); + assert_eq!(n["retry429"]["baseMs"], 10000); +} +#[test] +fn normalize_keeps_recycle_bin_active() { + // Synthetic buckets must survive normalize, else history_set_active("__trash__") is + // silently reset to "all" and the recycle bin can never be opened. + assert_eq!(normalize(json!({ "historyActive": "__trash__" }))["historyActive"], "__trash__"); + assert_eq!(normalize(json!({ "historyActive": "__imported__" }))["historyActive"], "__imported__"); + assert_eq!(normalize(json!({ "historyActive": "bogus-dir" }))["historyActive"], "all"); +} + +#[test] +fn provider_base_url_v1_migration_updates_only_the_matching_url() { + let mut config = json!({ + "port": 9000, + "customSetting": { "keep": true }, + "providers": [ + { + "id": "target", + "name": "Target", + "backend": "http", + "baseUrl": "https://example.com/api/", + "authToken": "secret", + "defaultModel": "model-a", + "models": [{ "alias": "fast", "upstream": "model-b" }] + }, + { + "id": "other", + "backend": "http", + "baseUrl": "https://other.example/api", + "authToken": "other-secret" + } + ] + }); + let before_other = config["providers"][1].clone(); + let before_settings = config["customSetting"].clone(); + + assert!(update_provider_base_url_to_v1( + &mut config, + "target", + "https://example.com/api/" + )); + assert_eq!( + config["providers"][0]["baseUrl"], + "https://example.com/api/v1" + ); + assert_eq!(config["providers"][0]["authToken"], "secret"); + assert_eq!(config["providers"][0]["defaultModel"], "model-a"); + assert_eq!( + config["providers"][0]["models"], + json!([{ "alias": "fast", "upstream": "model-b" }]) + ); + assert_eq!(config["providers"][1], before_other); + assert_eq!(config["customSetting"], before_settings); + assert_eq!(config["port"], 9000); +} + +#[test] +fn provider_base_url_v1_migration_requires_expected_old_url() { + let mut config = json!({ + "providers": [{ + "id": "target", + "backend": "http", + "baseUrl": "https://example.com/user-edit" + }] + }); + let before = config.clone(); + + assert!(!update_provider_base_url_to_v1( + &mut config, + "target", + "https://example.com/old" + )); + assert_eq!(config, before); +} + +#[test] +fn provider_base_url_v1_migration_skips_plugins() { + let mut config = json!({ + "providers": [{ + "id": "target", + "backend": "plugin", + "baseUrl": "http://127.0.0.1:12345" + }] + }); + let before = config.clone(); + + assert!(!update_provider_base_url_to_v1( + &mut config, + "target", + "http://127.0.0.1:12345" + )); + assert_eq!(config, before); +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..0465bf1 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,119 @@ +// System tray title + dynamic, localized context menu, moved verbatim from lib.rs. + +use serde_json::{json, Value}; +use tauri::Manager; + +use crate::commands::format_tokens; +use crate::{gateway, store, usage}; + +/// Set the macOS menu-bar tray title to the configured usage token count (or clear it when +/// trayUsage is off). Heavy work (config read + usage scan) runs on the caller's thread; +/// only the set_title call hops to the main thread, where macOS requires UI mutation. +pub(crate) fn update_tray_title(app: &tauri::AppHandle) { + let config = store::read_config(); + let tu = config.get("trayUsage").cloned().unwrap_or_else(|| json!({})); + let enabled = tu.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); + let title: Option = if enabled { + let range = tu.get("range").and_then(|v| v.as_str()).unwrap_or("7d").to_string(); + // Same global scope as the popover — the tray count is a whole-machine number. + let tokens = usage::usage_get(&config, "all", &range) + .get("tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + Some(format!(" {}", format_tokens(tokens))) + } else { + None + }; + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(tray) = app2.tray_by_id("main") { + let _ = tray.set_title(title.as_deref()); + } + }); +} + +// ---- system tray: dynamic, localized context menu (parity with main.js buildTrayMenu) ---- +struct TrayLabels { + running_with: &'static str, + stopped: &'static str, + open_main: &'static str, + stop_gw: &'static str, + start_gw: &'static str, + quit: &'static str, + check_updates: &'static str, +} +fn tray_labels(lang: &str) -> TrayLabels { + match lang { + // config.language stores "zh" (store.rs normalize) — accept both spellings. + "zh" | "zh-CN" => TrayLabels { running_with: "● 网关运行中 · {name}", stopped: "○ 网关已停止", open_main: "打开主界面", stop_gw: "停止网关服务", start_gw: "启动网关服务", quit: "退出 CC Buddy", check_updates: "检查更新…" }, + "zh-TW" => TrayLabels { running_with: "● 閘道執行中 · {name}", stopped: "○ 閘道已停止", open_main: "開啟主視窗", stop_gw: "停止閘道服務", start_gw: "啟動閘道服務", quit: "結束 CC Buddy", check_updates: "檢查更新…" }, + "ja" => TrayLabels { running_with: "● ゲートウェイ稼働中 · {name}", stopped: "○ ゲートウェイ停止中", open_main: "メインウィンドウを開く", stop_gw: "ゲートウェイを停止", start_gw: "ゲートウェイを起動", quit: "CC Buddy を終了", check_updates: "更新を確認…" }, + "ko" => TrayLabels { running_with: "● 게이트웨이 실행 중 · {name}", stopped: "○ 게이트웨이 중지됨", open_main: "메인 창 열기", stop_gw: "게이트웨이 중지", start_gw: "게이트웨이 시작", quit: "CC Buddy 종료", check_updates: "업데이트 확인…" }, + _ => TrayLabels { running_with: "● Gateway running · {name}", stopped: "○ Gateway stopped", open_main: "Open main window", stop_gw: "Stop gateway service", start_gw: "Start gateway service", quit: "Quit CC Buddy", check_updates: "Check for updates…" }, + } +} +pub(crate) fn config_lang(config: &Value) -> String { + config.get("language").and_then(|v| v.as_str()).unwrap_or("en").to_string() +} +fn active_provider_name(config: &Value) -> String { + let id = match config.get("activeProviderId").and_then(|v| v.as_str()) { + Some(i) => i, + None => return String::new(), + }; + config + .get("providers") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|p| p.get("id").and_then(|v| v.as_str()) == Some(id)) + .and_then(|p| p.get("name").and_then(|v| v.as_str())) + }) + .unwrap_or("") + .to_string() +} +pub(crate) fn build_tray_menu( + app: &tauri::AppHandle, + running: bool, + provider: &str, + lang: &str, +) -> tauri::Result> { + use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; + let l = tray_labels(lang); + let status_txt = if running { + let name = if provider.is_empty() { "CC Buddy" } else { provider }; + l.running_with.replace("{name}", name) + } else { + l.stopped.to_string() + }; + // Status row is disabled (it's an indicator, like main.js { enabled: false }). + let status_i = MenuItem::with_id(app, "tray_status", status_txt, false, None::<&str>)?; + let open_i = MenuItem::with_id(app, "tray_open", l.open_main, true, None::<&str>)?; + let conn_i = if running { + MenuItem::with_id(app, "tray_gw_stop", l.stop_gw, true, None::<&str>)? + } else { + MenuItem::with_id(app, "tray_gw_start", l.start_gw, true, None::<&str>)? + }; + let check_i = MenuItem::with_id(app, "tray_check", l.check_updates, true, None::<&str>)?; + let quit_i = MenuItem::with_id(app, "tray_quit", l.quit, true, None::<&str>)?; + let sep1 = PredefinedMenuItem::separator(app)?; + let sep2 = PredefinedMenuItem::separator(app)?; + Menu::with_items(app, &[&status_i, &sep1, &open_i, &conn_i, &check_i, &sep2, &quit_i]) +} +/// Rebuild the tray menu to reflect the gateway service state + locale + active provider. +pub(crate) fn refresh_tray_menu(app: &tauri::AppHandle) { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || { + let config = store::read_config(); + let running = app2 + .try_state::>() + .map(|s| s.port_sync().is_some()) + .unwrap_or(false); + let provider = active_provider_name(&config); + let lang = config_lang(&config); + if let Ok(menu) = build_tray_menu(&app2, running, &provider, &lang) { + if let Some(tray) = app2.tray_by_id("main") { + let _ = tray.set_menu(Some(menu)); + } + } + }); +} diff --git a/src-tauri/src/trayicon.rs b/src-tauri/src/trayicon.rs new file mode 100644 index 0000000..4f6d313 --- /dev/null +++ b/src-tauri/src/trayicon.rs @@ -0,0 +1,207 @@ +// System tray icon construction and its menu / click event handlers. +// +// Extracted verbatim from the `setup()` closure in lib.rs `run()` (the block that built the +// TrayIconBuilder) so both files stay under the split's size cap. Called exactly once, from +// run()'s setup, with the same `app` and `startup_cfg` it closed over there. + +use serde_json::{json, Value}; +use tauri::{Emitter, Manager}; + +use crate::commands::{auto_update_on_visible, full_status, set_dock_visible}; +use crate::popover::{now_ms, LAST_POPOVER_HIDE_MS, LAST_POPOVER_SHOW_MS}; +use crate::tray::{build_tray_menu, config_lang, refresh_tray_menu}; +use crate::{gateway, store}; + +// System tray: icon + dynamic i18n menu (status / open / connect-or-disconnect / +// check-updates / quit, parity with main.js buildTrayMenu) + click-to-open popover. +pub(crate) fn install_tray(app: &tauri::App, startup_cfg: &Value) -> tauri::Result<()> { + use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; + let menu = build_tray_menu(app.handle(), false, "", &config_lang(&startup_cfg))?; + // Menu-bar icon: monochrome template (like other macOS apps), auto black/white. + let tray_img = tauri::image::Image::from_bytes(include_bytes!("../../build/iconTemplate.png")) + .unwrap_or_else(|_| app.default_window_icon().cloned().unwrap()); + let _ = TrayIconBuilder::with_id("main") + .icon(tray_img) + .icon_as_template(true) + .tooltip("CC Buddy") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "tray_open" => { + if let Some(w) = app.get_webview_window("main") { + set_dock_visible(app, true); + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + } + // Tray toggles the gateway SERVICE (start/stop), never the CLI configs. + "tray_gw_start" | "tray_gw_stop" => { + let on = event.id.as_ref() == "tray_gw_start"; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut cfg = store::read_config(); + cfg["gatewayEnabled"] = json!(on); + let saved = store::write_config(cfg); + let port = saved.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; + let gw = app + .try_state::>() + .map(|s| s.inner().clone()); + if let Some(gw) = gw { + if on { + let _ = gw.start(port).await; + } else { + gw.stop().await; + } + let status = full_status(&gw).await; + gw.emit("gateway:status", status); + } + refresh_tray_menu(&app); + }); + } + "tray_check" => { + if let Some(w) = app.get_webview_window("main") { + set_dock_visible(app, true); + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + // Open the About/update pane shortly after the window is up (main.js parity). + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let _ = app2.emit("update:openPane", json!({})); + }); + } + "tray_quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } = event + { + let app = tray.app_handle(); + if let Some(pop) = app.get_webview_window("popover") { + #[cfg(target_os = "macos")] + let vis_before = { + use tauri_nspanel::ManagerExt as _; + app.get_webview_panel("popover") + .map(|p| p.is_visible()) + .unwrap_or(false) + }; + #[cfg(not(target_os = "macos"))] + let vis_before = pop.is_visible().unwrap_or(false); + let debounced = now_ms() + - LAST_POPOVER_HIDE_MS + .load(std::sync::atomic::Ordering::Relaxed) + < 250; + let action; + if vis_before { + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt as _; + if let Ok(p) = app.get_webview_panel("popover") { + p.order_out(None); + } + } + #[cfg(not(target_os = "macos"))] + let _ = pop.hide(); + LAST_POPOVER_HIDE_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + action = "hide"; + } else if debounced { + // Debounce: clicking the tray first blurs (hides) the popover; + // without this the same click would re-show it instantly. + action = "debounce_skip"; + } else { + // Center under the tray icon, clamped to the monitor (rect + + // scale are physical px, so retina is handled correctly). + // + // Pick the monitor the TRAY icon sits on. pop.current_monitor() is the + // monitor the (hidden) popover window last sat on, which on a + // multi-display setup is often NOT the screen whose menu bar was + // clicked; using it clamps the popover to the wrong monitor's + // bounds. Find the monitor whose physical bounds contain the tray + // rect (each candidate's own scale converts the rect to px). + let mon = pop + .available_monitors() + .ok() + .and_then(|mons| { + mons.into_iter().find(|m| { + let p = rect + .position + .to_physical::(m.scale_factor()); + let mp = m.position(); + let ms = m.size(); + p.x >= mp.x as f64 + && p.x < mp.x as f64 + ms.width as f64 + && p.y >= mp.y as f64 + && p.y < mp.y as f64 + ms.height as f64 + }) + }) + .or_else(|| pop.current_monitor().ok().flatten()) + .or_else(|| pop.primary_monitor().ok().flatten()); + let geom = mon.map(|mon| { + let scale = mon.scale_factor(); + let pw = (424.0 * scale) as i32; + let sx = mon.position().x; + let sw = mon.size().width as i32; + let tray_pos = rect.position.to_physical::(scale); + let tray_size = rect.size.to_physical::(scale); + let tray_cx = (tray_pos.x + tray_size.width / 2.0) as i32; + let x = (tray_cx - pw / 2).clamp(sx + 4, sx + sw - pw - 4); + let y = (tray_pos.y + tray_size.height + 2.0) as i32; + tauri::PhysicalPosition::new(x, y) + }); + if let Some(p) = geom { + let _ = pop.set_position(p); + } + // Show via the NSPanel: nonactivating, so it appears on the + // CURRENT Space (incl. a fullscreen app's) without activating + // ccbud or switching Spaces. + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt as _; + if let Ok(p) = app.get_webview_panel("popover") { + p.show(); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = pop.show(); + let _ = pop.set_focus(); + } + if let Some(p) = geom { + let _ = pop.set_position(p); + } + let _ = app.emit("popover:show", ()); + LAST_POPOVER_SHOW_MS + .store(now_ms(), std::sync::atomic::Ordering::Relaxed); + // The popover appearing counts as "app became visible today". + auto_update_on_visible(app); + action = "show"; + } + if let Ok(path) = std::env::var("CCBUD_SELFCHECK_OUT") { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!( + f, + "{}", + json!({ "trayClick": action, "visBefore": vis_before }) + ); + } + } + } + } + }) + .build(app)?; + Ok(()) +} diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs deleted file mode 100644 index 87e28d4..0000000 --- a/src-tauri/src/usage.rs +++ /dev/null @@ -1,1137 +0,0 @@ -// Usage analytics — aggregation semantics ported from ccusage (github.com/ccusage/ccusage), -// scoped to the two agents ccbud fronts: Claude Code and Codex. -// -// Per active work dir, two session trees contribute: -// -// Claude Code `projects/**/*.jsonl` (recursive, any depth — sessions, nested session dirs, -// subagent transcripts all included by construction): -// - every line whose `message.usage` carries numeric input/output tokens counts — no -// `type=="assistant"` gate (ccusage parity); -// - a line without a parseable RFC3339 `timestamp` is DROPPED (never guessed); -// - cache-creation prefers the nested `cache_creation.ephemeral_{5m,1h}_input_tokens` -// breakdown over the flat `cache_creation_input_tokens`; -// - `` models keep their tokens but get no model attribution; `usage.speed=="fast"` -// appends a `-fast` suffix to the model; -// - global de-dup by (message.id, requestId) — entries without a message.id are never -// de-duped; a sidechain replay that reuses the parent's message.id under a NEW requestId -// collapses onto the parent (non-sidechain wins, then higher token total). -// -// Codex `sessions/**/*.jsonl` + `archived_sessions/**/*.jsonl` (an archived copy of the same -// relative path is skipped — the active sessions/ copy wins): -// - `token_count` events: prefer `info.last_token_usage` (the turn delta); fall back to -// diffing consecutive `info.total_token_usage` snapshots; the cumulative baseline always -// advances so either form stays correct; -// - `thread_spawn` subagent files replay the parent's history as a leading burst of -// token_count lines sharing one timestamp-second — those are skipped (baseline still -// advances) so parent turns aren't counted twice; -// - identical (timestamp, model, tokens) events across files (resumed/forked sessions) -// de-dup globally; -// - model comes from the event payload/info, else the last `turn_context`, else "gpt-5"; -// `input_tokens` is INCLUSIVE of `cached_input_tokens` — the cached part is split out into -// cacheRead and the remainder becomes input. -// -// Day bucketing is local-timezone (chrono::Local), matching ccusage's system-timezone default. - -#![allow(dead_code)] - -use chrono::{Datelike, Local, TimeZone, Timelike}; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; -use std::io::BufRead; -use std::path::{Path, PathBuf}; - -const DAY_MS: i64 = 86_400_000; -const HEATMAP_WEEKS: i64 = 26; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} -fn expand_tilde(p: &str) -> PathBuf { - if let Some(rest) = p.strip_prefix("~/") { - home().join(rest) - } else if p == "~" { - home() - } else { - PathBuf::from(p) - } -} - -/// Active work dirs (honors the directory switcher). A selector that matches no configured dir — -/// the synthetic recycle-bin / imported-bundle views ("__trash__", "__imported__"), or a stale -/// value from an older config — falls back to ALL dirs: a filter must never zero the stats. -fn active_roots(config: &Value, active: &str) -> Vec { - let mut all = vec![]; - let mut selected = vec![]; - if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { - for d in arr { - if let Some(s) = d.as_str() { - all.push(expand_tilde(s)); - if active == s { - selected.push(expand_tilde(s)); - } - } - } - } - if active != "all" && !selected.is_empty() { - selected - } else { - all - } -} - -fn parse_ts(s: &str) -> Option { - chrono::DateTime::parse_from_rfc3339(s).ok().map(|d| d.timestamp_millis()) -} -fn key_of(ms: i64) -> String { - match Local.timestamp_millis_opt(ms).single() { - Some(d) => format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()), - None => "1970-01-01".to_string(), - } -} -fn start_of_day(ms: i64) -> i64 { - match Local.timestamp_millis_opt(ms).single() { - Some(d) => { - let day = d.date_naive().and_hms_opt(0, 0, 0).unwrap(); - Local.from_local_datetime(&day).single().map(|x| x.timestamp_millis()).unwrap_or(ms) - } - None => ms, - } -} -fn ms_of_key(k: &str) -> i64 { - let parts: Vec = k.split('-').filter_map(|x| x.parse().ok()).collect(); - if parts.len() != 3 { - return 0; - } - let nd = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1] as u32, parts[2] as u32); - match nd.and_then(|d| d.and_hms_opt(0, 0, 0)) { - Some(dt) => Local.from_local_datetime(&dt).single().map(|x| x.timestamp_millis()).unwrap_or(0), - None => 0, - } -} -fn hour_of(ms: i64) -> u32 { - Local.timestamp_millis_opt(ms).single().map(|d| d.hour()).unwrap_or(0) -} - -#[derive(Default, Clone)] -struct Day { - tokens: i64, - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, - requests: i64, - models: HashMap, - providers: HashMap, - hours: HashMap, -} - -/// One counted usage event, whichever tree it came from. -struct UsageRec { - ts: i64, - model: Option, - input: i64, - output: i64, - cache_read: i64, - cache_creation: i64, -} - -impl UsageRec { - fn total(&self) -> i64 { - self.input + self.output + self.cache_read + self.cache_creation - } -} - -fn bump(days: &mut HashMap, rec: &UsageRec) { - let day = days.entry(key_of(rec.ts)).or_default(); - day.requests += 1; - day.tokens += rec.total(); - day.input += rec.input; - day.output += rec.output; - day.cache_read += rec.cache_read; - day.cache_creation += rec.cache_creation; - if let Some(m) = &rec.model { - *day.models.entry(m.clone()).or_insert(0) += rec.total(); - } - *day.hours.entry(hour_of(rec.ts)).or_insert(0) += rec.total(); -} - -/// Recursively collect `*.jsonl` under `dir`, any depth (ccusage walks the whole tree — nested -/// session dirs and subagent transcripts are picked up by construction). Depth-capped as a -/// symlink-loop guard. -fn collect_jsonl(dir: &Path, depth: u32, out: &mut Vec) { - if depth > 8 { - return; - } - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for ent in entries.flatten() { - let p = ent.path(); - if p.is_dir() { - collect_jsonl(&p, depth + 1, out); - } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { - out.push(p); - } - } -} - -/// Byte-based lossy line reader. History files can embed invalid UTF-8 inside tool output — -/// a strict `BufRead::lines` errors there and would silently discard the REST of the file -/// (ccusage reads raw bytes for the same reason). -struct LossyLines { - reader: std::io::BufReader, - buf: Vec, -} - -impl LossyLines { - fn open(file: &Path) -> Option { - std::fs::File::open(file) - .ok() - .map(|f| Self { reader: std::io::BufReader::new(f), buf: Vec::with_capacity(64 * 1024) }) - } - fn next_line(&mut self) -> Option { - self.buf.clear(); - match self.reader.read_until(b'\n', &mut self.buf) { - Ok(0) | Err(_) => None, - Ok(_) => Some(String::from_utf8_lossy(&self.buf).into_owned()), - } - } -} - -// --------------------------------------------------------------------------- -// Claude Code (projects/ tree) -// --------------------------------------------------------------------------- - -struct ClaudeRec { - id: Option, - request_id: Option, - sidechain: bool, - rec: UsageRec, -} - -/// Parse one history line into a usage entry. Requires numeric `message.usage.input_tokens` / -/// `output_tokens` and a parseable `timestamp`; everything else is optional. -fn parse_claude_line(line: &str) -> Option { - // cheap prefilter before JSON parse (ccusage scans for the same marker) - if !line.contains("\"usage\"") { - return None; - } - let r: Value = serde_json::from_str(line).ok()?; - let m = r.get("message")?; - let u = m.get("usage")?; - let input = u.get("input_tokens")?.as_i64()?; - let output = u.get("output_tokens")?.as_i64()?; - let cache_read = u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - // nested ephemeral breakdown wins over the flat cache_creation_input_tokens - let cache_creation = match u.get("cache_creation").filter(|v| v.is_object()) { - Some(b) => { - b.get("ephemeral_5m_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) - + b.get("ephemeral_1h_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) - } - None => u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), - }; - if input + output + cache_read + cache_creation <= 0 { - return None; // zero rows (synthetic error turns) carry no token information - } - let ts = r.get("timestamp").and_then(|v| v.as_str()).and_then(parse_ts)?; - let speed_fast = u.get("speed").and_then(|v| v.as_str()) == Some("fast"); - let model = m.get("model").and_then(|v| v.as_str()).and_then(|s| { - if s.is_empty() || s == "" { - None // tokens still count; no model attribution - } else if speed_fast { - Some(format!("{}-fast", s)) - } else { - Some(s.to_string()) - } - }); - Some(ClaudeRec { - id: m.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - request_id: r.get("requestId").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - sidechain: r.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), - rec: UsageRec { ts, model, input, output, cache_read, cache_creation }, - }) -} - -/// Message ids that older ccbud gateway builds stamped on EVERY translated response — known -/// non-unique, so they must never act as a de-dup key (an id-keyed de-dup would collapse whole -/// weeks of history written through the gateway into a single counted turn). -fn degenerate_id(id: &str) -> bool { - id == "msg_ccbud" || id == "chatcmpl-ccbud" || id == "resp_ccbud" -} - -/// Global de-dup, ccusage semantics: key (message.id, requestId); entries without an id are always -/// kept. A miss on the exact key falls back to the id-only bucket when either side is a sidechain -/// (a `/btw` replay reuses the parent's message.id under a new requestId). On a duplicate the -/// non-sidechain copy wins, then the higher token total. -fn dedup_claude(recs: Vec) -> Vec { - let mut kept: Vec = vec![]; - let mut by_exact: HashMap<(String, Option), usize> = HashMap::new(); - let mut by_id: HashMap = HashMap::new(); - for cand in recs { - let Some(id) = cand.id.clone().filter(|i| !degenerate_id(i)) else { - kept.push(cand); - continue; - }; - let exact = (id.clone(), cand.request_id.clone()); - let slot = by_exact.get(&exact).copied().or_else(|| { - by_id.get(&id).copied().filter(|&i| cand.sidechain || kept[i].sidechain) - }); - match slot { - Some(i) => { - let cur = &kept[i]; - let replace = (cur.sidechain && !cand.sidechain) - || (cur.sidechain == cand.sidechain && cand.rec.total() > cur.rec.total()); - if replace { - kept[i] = cand; - } - by_exact.insert(exact, i); - } - None => { - let i = kept.len(); - by_exact.insert(exact, i); - by_id.entry(id).or_insert(i); - kept.push(cand); - } - } - } - kept -} - -// --------------------------------------------------------------------------- -// Codex (sessions/ + archived_sessions/ trees) -// --------------------------------------------------------------------------- - -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] -struct CodexUsage { - input: i64, - cached: i64, - output: i64, - reasoning: i64, - total: i64, -} - -/// Lenient token-usage decode (ccusage accepts several field aliases per component). -fn codex_usage_of(v: &Value) -> Option { - let o = v.as_object()?; - let g = |keys: &[&str]| keys.iter().find_map(|k| o.get(*k).and_then(|v| v.as_i64())).unwrap_or(0); - let input = g(&["input_tokens", "prompt_tokens", "input"]); - let cached = g(&["cached_input_tokens", "cache_read_input_tokens", "cached_tokens"]); - let output = g(&["output_tokens", "completion_tokens", "output"]); - let reasoning = g(&["reasoning_output_tokens", "reasoning_tokens"]); - let total = match o.get("total_tokens").and_then(|v| v.as_i64()) { - Some(t) if t > 0 || input + output + reasoning == 0 => t, - _ => input + output + reasoning, - }; - Some(CodexUsage { input, cached, output, reasoning, total }) -} - -fn codex_usage_sub(cur: CodexUsage, prev: Option) -> CodexUsage { - let p = prev.unwrap_or_default(); - CodexUsage { - input: (cur.input - p.input).max(0), - cached: (cur.cached - p.cached).max(0), - output: (cur.output - p.output).max(0), - reasoning: (cur.reasoning - p.reasoning).max(0), - total: (cur.total - p.total).max(0), - } -} - -fn codex_model_of(v: Option<&Value>) -> Option { - let o = v?.as_object()?; - o.get("model") - .or_else(|| o.get("model_name")) - .or_else(|| o.get("metadata").and_then(|m| m.get("model"))) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) -} - -/// Whether this rollout is a `thread_spawn` subagent session (marker in the file head). -fn codex_is_subagent(file: &Path) -> bool { - use std::io::Read; - let Ok(mut f) = std::fs::File::open(file) else { return false }; - let mut buf = [0u8; 16 * 1024]; - let n = f.read(&mut buf).unwrap_or(0); - buf[..n].windows(b"thread_spawn".len()).any(|w| w == b"thread_spawn") -} - -/// A subagent file replays the parent's token_count history as a leading burst that shares one -/// timestamp-second — detect that second (the first two usage events landing on the same second), -/// so the replay can be skipped while the cumulative baseline still advances. -fn codex_replay_second(file: &Path) -> Option { - let mut first: Option = None; - let mut lines = LossyLines::open(file)?; - while let Some(line) = lines.next_line() { - let Some((ts, payload)) = codex_token_count_line(&line) else { continue }; - let info = payload.get("info"); - let has_usage = info - .map(|i| i.get("last_token_usage").is_some() || i.get("total_token_usage").is_some()) - .unwrap_or(false); - if !has_usage { - continue; - } - let second: String = ts.chars().take(19).collect(); - match &first { - None => first = Some(second), - Some(f) => return if *f == second { Some(second) } else { None }, - } - } - None -} - -/// Parse a line as a `token_count` event → (timestamp, payload). None for everything else. -fn codex_token_count_line(line: &str) -> Option<(String, Value)> { - if !line.contains("token_count") { - return None; - } - let r: Value = serde_json::from_str(line).ok()?; - if r.get("type").and_then(|v| v.as_str()) != Some("event_msg") { - return None; - } - let p = r.get("payload")?; - if p.get("type").and_then(|v| v.as_str()) != Some("token_count") { - return None; - } - let ts = r.get("timestamp").and_then(|v| v.as_str())?.to_string(); - Some((ts, p.clone())) -} - -/// Parse one Codex rollout file into per-turn usage events (ccusage semantics — see module doc). -fn parse_codex_file(file: &Path, out: &mut Vec<(CodexUsage, i64, String)>) { - let replay_second = if codex_is_subagent(file) { codex_replay_second(file) } else { None }; - let mut skip_replay = replay_second.is_some(); - let mut current_model: Option = None; - let mut prev_totals: Option = None; - let Some(mut lines) = LossyLines::open(file) else { return }; - while let Some(line) = lines.next_line() { - let s = line.trim(); - if s.is_empty() { - continue; - } - // turn_context carries the active model - if s.contains("turn_context") { - if let Ok(r) = serde_json::from_str::(s) { - if r.get("type").and_then(|v| v.as_str()) == Some("turn_context") { - if let Some(m) = codex_model_of(r.get("payload")) { - current_model = Some(m); - } - continue; - } - } - } - let Some((ts_str, payload)) = codex_token_count_line(s) else { continue }; - let info = payload.get("info").filter(|i| !i.is_null()); - let total = info.and_then(|i| i.get("total_token_usage")).and_then(codex_usage_of); - let last = info.and_then(|i| i.get("last_token_usage")).and_then(codex_usage_of); - // leading parent-history replay in a subagent file: skip, but keep the baseline moving - if skip_replay { - let second: String = ts_str.chars().take(19).collect(); - if Some(&second) == replay_second.as_ref() { - if let Some(t) = total { - prev_totals = Some(t); - } - continue; - } - skip_replay = false; - } - let usage = last.or_else(|| total.map(|t| codex_usage_sub(t, prev_totals))); - if let Some(t) = total { - prev_totals = Some(t); - } - let Some(mut u) = usage else { continue }; - if u.input + u.cached + u.output + u.reasoning == 0 { - continue; - } - let Some(ts) = parse_ts(&ts_str) else { continue }; - u.cached = u.cached.min(u.input); // input is INCLUSIVE of cached - let model = codex_model_of(Some(&payload)) - .or_else(|| codex_model_of(info)) - .or_else(|| current_model.clone()) - .unwrap_or_else(|| "gpt-5".to_string()); - out.push((u, ts, model)); - } -} - -/// Collect a work dir's Codex rollout files: sessions/ plus archived_sessions/, where an archived -/// copy of the same relative path loses to the active sessions/ copy. -fn codex_files(root: &Path) -> Vec { - let mut out: Vec = vec![]; - let mut seen_rel: HashSet = HashSet::new(); - for sub in ["sessions", "archived_sessions"] { - let dir = root.join(sub); - let mut files = vec![]; - collect_jsonl(&dir, 0, &mut files); - files.sort(); - for f in files { - // Grok Build shares the sessions/ root but keys children by percent-encoded cwd - // (`%2FUsers%2F…//chat_history.jsonl` + events/updates sidecar jsonl). Those - // must never hit the Codex token parser — wasteful and would mix formats if a line - // ever looked like a token_count event. Skip any path under a Grok-encoded dir. - if f.components().any(|c| crate::grok::is_cwd_dir_name(&c.as_os_str().to_string_lossy())) { - continue; - } - let rel = f.strip_prefix(&dir).map(|p| p.to_path_buf()).unwrap_or_else(|_| f.clone()); - if seen_rel.insert(rel) { - out.push(f); - } - } - } - out -} - -// --------------------------------------------------------------------------- -// aggregation -// --------------------------------------------------------------------------- - -fn build_data(config: &Value, active: &str) -> HashMap { - let mut days: HashMap = HashMap::new(); - let roots = active_roots(config, active); - - // Claude Code: parse everything, then de-dup globally, then bucket. - let mut claude_recs: Vec = vec![]; - for root in &roots { - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - files.sort(); - // Qoder projects/ trees can be macOS-protected: route those files through the guarded - // reader (helper fallback + cache, warmed in one batch) so 用量 counts the same sessions - // the 对话 view can browse. Wrapper records that repeat a message id are handled by the - // usual dedup (max-total wins) and partial snapshots lack output_tokens, so nothing - // double-counts. - let qoder_files: Vec = files - .iter() - .filter(|f| crate::qoder::looks_qoder_path(f)) - .cloned() - .collect(); - crate::qoder::prefetch(&qoder_files); - for file in files { - if crate::qoder::looks_qoder_path(&file) { - let Ok(bytes) = crate::qoder::read_bytes(&file) else { continue }; - for line in String::from_utf8_lossy(&bytes).lines() { - if let Some(rec) = parse_claude_line(line.trim()) { - claude_recs.push(rec); - } - } - continue; - } - let Some(mut lines) = LossyLines::open(&file) else { continue }; - while let Some(line) = lines.next_line() { - if let Some(rec) = parse_claude_line(line.trim()) { - claude_recs.push(rec); - } - } - } - } - for kept in dedup_claude(claude_recs) { - bump(&mut days, &kept.rec); - } - - // Codex: per-turn events, de-duped globally by (timestamp, model, tokens) so resumed/forked - // session copies collapse. - let mut events: Vec<(CodexUsage, i64, String)> = vec![]; - for root in &roots { - for file in codex_files(root) { - parse_codex_file(&file, &mut events); - } - } - let mut seen: HashSet<(i64, String, CodexUsage)> = HashSet::new(); - for (u, ts, model) in events { - if !seen.insert((ts, model.clone(), u)) { - continue; - } - bump( - &mut days, - &UsageRec { - ts, - model: Some(model), - input: (u.input - u.cached).max(0), - output: u.output, - cache_read: u.cached, - cache_creation: 0, - }, - ); - } - days -} - -// ---- usage cache ---- -// build_data scans every history .jsonl (~0.5s cold for ~1200 files), and the popover calls -// usage_get TWICE per open (heatmap "all" + stats range). Cache the scanned per-day map keyed by -// the active dirs, invalidated when history files change (notify watcher) — so the second per-open -// call + repeated opens are instant, and a startup/post-change warm makes the first open instant. -struct UsageCache { - sig: String, - days: HashMap, -} -static USAGE_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); - -fn dirs_sig(config: &Value, active: &str) -> String { - format!("{}|{:?}", active, active_roots(config, active)) -} - -fn build_data_cached(config: &Value, active: &str) -> HashMap { - let sig = dirs_sig(config, active); - { - // recover a poisoned lock (a panicked scan thread must not disable caching forever) - let cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(c) = cache.as_ref() { - if c.sig == sig { - return c.days.clone(); - } - } - } - let days = build_data(config, active); - let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - *cache = Some(UsageCache { sig, days: days.clone() }); - days -} - -/// Drop the cached scan — call when history files change so the next read rescans. -pub fn invalidate_cache() { - let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); - *cache = None; -} - -/// Scan + cache now (off the click path). Call at startup and after history changes so the first -/// popover open is instant instead of paying the cold-scan cost. -pub fn warm_cache(config: &Value, active: &str) { - let _ = build_data_cached(config, active); -} - -fn range_keys(days: &HashMap, range: &str, now: i64) -> Vec { - let mut all: Vec = days.keys().cloned().collect(); - all.sort(); - if range == "all" { - return all; - } - let n = match range { - "1d" => 1, - "30d" => 30, - _ => 7, - }; - let cut = start_of_day(now - (n - 1) * DAY_MS); - all.into_iter().filter(|k| ms_of_key(k) >= cut).collect() -} - -fn top_key(map: &HashMap) -> Option { - map.iter().max_by_key(|(_, v)| **v).map(|(k, _)| k.clone()) -} - -fn streaks(days: &HashMap, now: i64) -> (i64, i64) { - let mut active: Vec = days - .iter() - .filter(|(_, d)| d.requests > 0) - .map(|(k, _)| ms_of_key(k)) - .collect(); - active.sort(); - let set: HashSet = active.iter().cloned().collect(); - let (mut longest, mut run, mut prev): (i64, i64, Option) = (0, 0, None); - for t in &active { - run = if prev.map(|p| t - p == DAY_MS).unwrap_or(false) { run + 1 } else { 1 }; - prev = Some(*t); - if run > longest { - longest = run; - } - } - let mut cur = 0; - let mut t = start_of_day(now); - if !set.contains(&t) { - t -= DAY_MS; - } - while set.contains(&t) { - cur += 1; - t -= DAY_MS; - } - (cur, longest) -} - -fn build_heatmap(days: &HashMap, weeks: i64, now: i64) -> Vec { - let today = start_of_day(now); - let span = weeks * 7; - let mut start = today - (span - 1) * DAY_MS; - let dow = Local.timestamp_millis_opt(start).single().map(|d| d.weekday().num_days_from_sunday() as i64).unwrap_or(0); - start -= dow * DAY_MS; - let mut cells: Vec<(String, i64)> = vec![]; - let mut max = 1i64; - let mut t = start; - while t <= today { - let k = key_of(t); - let tok = days.get(&k).map(|d| d.tokens).unwrap_or(0); - if tok > max { - max = tok; - } - cells.push((k, tok)); - t += DAY_MS; - } - cells - .into_iter() - .map(|(date, tokens)| { - let r = tokens as f64 / max as f64; - let level = if tokens == 0 { - 0 - } else if r > 0.66 { - 4 - } else if r > 0.33 { - 3 - } else if r > 0.1 { - 2 - } else { - 1 - }; - json!({ "date": date, "tokens": tokens, "level": level }) - }) - .collect() -} - -fn query(days: &HashMap, range: &str, now: i64) -> Value { - let keys = range_keys(days, range, now); - let (mut tokens, mut input, mut output, mut cache_read, mut cache_creation, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64); - let mut models: HashMap = HashMap::new(); - let mut providers: HashMap = HashMap::new(); - let mut hours: HashMap = HashMap::new(); - let mut active_days = 0; - for k in &keys { - if let Some(d) = days.get(k) { - tokens += d.tokens; - input += d.input; - output += d.output; - cache_read += d.cache_read; - cache_creation += d.cache_creation; - requests += d.requests; - if d.requests > 0 { - active_days += 1; - } - for (m, v) in &d.models { - *models.entry(m.clone()).or_insert(0) += v; - } - for (p, v) in &d.providers { - *providers.entry(p.clone()).or_insert(0) += v; - } - for (h, v) in &d.hours { - *hours.entry(*h).or_insert(0) += v; - } - } - } - let mut by_model: Vec = models - .iter() - .map(|(m, t)| json!({ "model": m, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) - .collect(); - by_model.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); - let mut by_provider: Vec = providers - .iter() - .map(|(p, t)| json!({ "provider": p, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) - .collect(); - by_provider.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); - let peak_hour = hours.iter().max_by_key(|(_, v)| **v).map(|(h, _)| *h as i64); - let (cur, longest) = streaks(days, now); - - json!({ - "range": range, - "tokens": tokens, "input": input, "output": output, "cacheRead": cache_read, "cacheCreation": cache_creation, - "requests": requests, "activeDays": active_days, - "peakHour": peak_hour, - "favoriteModel": top_key(&models), - "favoriteProvider": top_key(&providers), - "byModel": by_model, - "byProvider": by_provider, - "currentStreak": cur, - "longestStreak": longest, - "heatmap": build_heatmap(days, HEATMAP_WEEKS, now), - }) -} - -/// One-line scan diagnostic for the gateway log — which dirs resolved, how many files/lines -/// reached each pipeline stage, and the day span. Makes an empty/partial aggregation visible -/// without a debugger ("only today shows up" → the counters name the stage that dropped it). -pub fn diag(config: &Value, active: &str) -> String { - let roots = active_roots(config, active); - let mut claude_files = 0usize; - let mut codex_file_count = 0usize; - let (mut usage_lines, mut parsed, mut zero_rows, mut no_ts, mut degen) = (0usize, 0usize, 0usize, 0usize, 0usize); - let mut recs: Vec = vec![]; - for root in &roots { - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - claude_files += files.len(); - for f in &files { - let Some(mut lines) = LossyLines::open(f) else { continue }; - while let Some(l) = lines.next_line() { - let l = l.trim(); - if !l.contains("\"usage\"") { - continue; - } - usage_lines += 1; - match parse_claude_line(l) { - Some(r) => { - parsed += 1; - if r.id.as_deref().map(degenerate_id).unwrap_or(false) { - degen += 1; - } - recs.push(r); - } - None => { - if let Ok(v) = serde_json::from_str::(l) { - if v.get("message").and_then(|m| m.get("usage")).is_some() { - if v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() { - no_ts += 1; - } else { - zero_rows += 1; - } - } - } - } - } - } - } - codex_file_count += codex_files(root).len(); - } - let kept = dedup_claude(recs).len(); - let days = build_data(config, active); - let mut keys: Vec<_> = days.keys().cloned().collect(); - keys.sort(); - let span = match (keys.first(), keys.last()) { - (Some(a), Some(b)) => format!("{}..{}", a, b), - _ => "-".to_string(), - }; - format!( - "usage scan: active={} roots={:?} claude-files={} codex-files={} usage-lines={} parsed={} kept={} days={} span={} dropped(no-ts)={} dropped(zero/invalid)={} degenerate-ids={}", - active, - roots.iter().map(|r| r.to_string_lossy().to_string()).collect::>(), - claude_files, codex_file_count, usage_lines, parsed, kept, keys.len(), span, no_ts, zero_rows, degen - ) -} - -/// Public entry: aggregate the active dirs and return the usage stats payload for `range`. -pub fn usage_get(config: &Value, active: &str, range: &str) -> Value { - let days = build_data_cached(config, active); - let now = Local::now().timestamp_millis(); - query(&days, range, now) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn line(v: Value) -> String { - format!("{}\n", v) - } - fn asst(id: &str, req: &str, model: &str, ts: &str, inp: i64, out: i64) -> String { - line(json!({ "type": "assistant", "timestamp": ts, "requestId": req, - "message": { "id": id, "model": model, - "usage": { "input_tokens": inp, "output_tokens": out } } })) - } - - fn sum(days: &HashMap) -> (i64, i64, i64, i64, i64, HashMap) { - let (mut tokens, mut input, mut output, mut cache_read, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64); - let mut models: HashMap = HashMap::new(); - for d in days.values() { - tokens += d.tokens; - input += d.input; - output += d.output; - cache_read += d.cache_read; - requests += d.requests; - for (m, v) in &d.models { - *models.entry(m.clone()).or_insert(0) += v; - } - } - (tokens, input, output, cache_read, requests, models) - } - - #[test] - fn claude_ccusage_semantics() { - let base = std::env::temp_dir().join(format!("ccbud-usage-cl-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - // nested session dir + subagent transcript at arbitrary depth — recursive walk finds both - let deep = proj.join("s1").join("subagents"); - fs::create_dir_all(&deep).unwrap(); - - fs::write( - proj.join("s1.jsonl"), - // counted (110) - asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) - // same (id, requestId) duplicate → collapsed - + &asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) - // same id, DIFFERENT requestId, no sidechain → distinct entry (counted, 55) - + &asst("m1", "r2", "claude-x", "2026-07-01T10:05:00Z", 50, 5) - // undated → dropped - + &line(json!({ "type": "assistant", - "message": { "id": "m2", "model": "claude-x", "usage": { "input_tokens": 9, "output_tokens": 9 } } })) - // zero usage → dropped - + &asst("m3", "r3", "", "2026-07-01T10:06:00Z", 0, 0) - // synthetic model with tokens → counted (7), no model attribution - + &asst("m4", "r4", "", "2026-07-01T10:07:00Z", 5, 2) - // no type field at all (ccusage has no type gate) → counted (13) - + &line(json!({ "timestamp": "2026-07-01T10:08:00Z", "requestId": "r5", - "message": { "id": "m5", "model": "claude-x", - "usage": { "input_tokens": 10, "output_tokens": 3 } } })), - ) - .unwrap(); - // subagent transcript, nested cache_creation breakdown + fast speed suffix (counted, 3+4+6+7=20) - fs::write( - deep.join("agent-a.jsonl"), - line(json!({ "timestamp": "2026-07-01T11:00:00Z", "requestId": "r6", - "message": { "id": "m6", "model": "claude-x", - "usage": { "input_tokens": 3, "output_tokens": 4, "speed": "fast", - "cache_read_input_tokens": 6, - "cache_creation_input_tokens": 999, - "cache_creation": { "ephemeral_5m_input_tokens": 5, "ephemeral_1h_input_tokens": 2 } } } })), - ) - .unwrap(); - // sidechain replay: reuses m1 under a NEW requestId with isSidechain → collapses onto parent - fs::write( - proj.join("s2.jsonl"), - line(json!({ "type": "assistant", "timestamp": "2026-07-01T10:00:01Z", "requestId": "r9", - "isSidechain": true, - "message": { "id": "m1", "model": "claude-x", "usage": { "input_tokens": 100, "output_tokens": 10 } } })), - ) - .unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (tokens, input, output, cache_read, requests, models) = sum(&days); - // m1(110) + m1/r2(55) + m4(7) + m5(13) + m6(20) - assert_eq!(requests, 5); - assert_eq!(input, 100 + 50 + 5 + 10 + 3); - assert_eq!(output, 10 + 5 + 2 + 3 + 4); - assert_eq!(cache_read, 6); - assert_eq!(tokens, 110 + 55 + 7 + 13 + 20); - // synthetic tokens counted but unattributed; fast suffix applied - assert_eq!(models.get("claude-x").copied(), Some(110 + 55 + 13)); - assert_eq!(models.get("claude-x-fast").copied(), Some(20)); - assert!(models.get("").is_none()); - - let _ = fs::remove_dir_all(&base); - } - - // History written through OLD ccbud gateway builds: every streamed response carries the - // constant id "msg_ccbud" (and often no requestId — the gateway didn't forward the header). - // Those ids must never act as de-dup keys, or weeks of history collapse into one turn. - #[test] - fn degenerate_gateway_ids_never_dedup() { - let base = std::env::temp_dir().join(format!("ccbud-usage-degen-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - let no_req = |ts: &str, inp: i64| { - line(json!({ "type": "assistant", "timestamp": ts, - "message": { "id": "msg_ccbud", "model": "glm-4.7", - "usage": { "input_tokens": inp, "output_tokens": 1 } } })) - }; - fs::write( - proj.join("old-era.jsonl"), - no_req("2026-06-20T10:00:00Z", 100) - + &no_req("2026-06-21T10:00:00Z", 200) - + &no_req("2026-06-22T10:00:00Z", 300) - + &line(json!({ "type": "assistant", "timestamp": "2026-06-23T10:00:00Z", "requestId": "r1", - "message": { "id": "chatcmpl-ccbud", "model": "glm-4.7", - "usage": { "input_tokens": 400, "output_tokens": 1 } } })), - ) - .unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (_, input, _, _, requests, _) = sum(&days); - // all four turns count — four distinct days survive - assert_eq!(requests, 4); - assert_eq!(input, 100 + 200 + 300 + 400); - assert_eq!(days.len(), 4); - let _ = fs::remove_dir_all(&base); - } - - // The 对话 page's dir switcher persists synthetic views (recycle bin, imported bundles) into - // historyActive — those match no configured dir and previously zeroed every usage number. - #[test] - fn synthetic_or_stale_active_falls_back_to_all_dirs() { - let base = std::env::temp_dir().join(format!("ccbud-usage-active-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - fs::write(proj.join("s.jsonl"), asst("a1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1)).unwrap(); - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - for active in ["all", "__trash__", "__imported__", "/no/such/dir"] { - let days = build_data(&config, active); - let (tokens, ..) = sum(&days); - assert_eq!(tokens, 11, "active={} must not zero the stats", active); - } - // a VALID selector still filters - let days = build_data(&config, base.to_string_lossy().as_ref()); - let (tokens, ..) = sum(&days); - assert_eq!(tokens, 11); - let _ = fs::remove_dir_all(&base); - } - - #[test] - fn invalid_utf8_does_not_truncate_a_file() { - let base = std::env::temp_dir().join(format!("ccbud-usage-u8-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let proj = base.join("projects").join("-p"); - fs::create_dir_all(&proj).unwrap(); - let mut bytes = asst("u1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1).into_bytes(); - bytes.extend_from_slice(b"{\"garbage\": \"\xff\xfe binary tool output\"}\n"); - bytes.extend_from_slice(asst("u2", "r2", "m", "2026-07-02T10:00:00Z", 20, 2).as_bytes()); - fs::write(proj.join("s.jsonl"), bytes).unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (_, input, _, _, requests, _) = sum(&days); - // the record AFTER the invalid-UTF-8 line still counts - assert_eq!(requests, 2); - assert_eq!(input, 30); - let _ = fs::remove_dir_all(&base); - } - - fn tc(ts: &str, last: Option<(i64, i64, i64)>, total: Option<(i64, i64, i64)>) -> String { - let mut info = json!({}); - if let Some((i, c, o)) = last { - info["last_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, - "total_tokens": i + o }); - } - if let Some((i, c, o)) = total { - info["total_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, - "total_tokens": i + o }); - } - line(json!({ "timestamp": ts, "type": "event_msg", "payload": { "type": "token_count", "info": info } })) - } - - #[test] - fn codex_ccusage_semantics() { - let base = std::env::temp_dir().join(format!("ccbud-usage-cx-{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let day = base.join("sessions").join("2026").join("07").join("01"); - fs::create_dir_all(&day).unwrap(); - - // main session: model from turn_context; one last_token_usage turn; one turn WITHOUT - // last (only cumulative total) → counted as the diff from the baseline. - fs::write( - day.join("rollout-a.jsonl"), - line(json!({ "timestamp": "2026-07-01T12:00:00Z", "type": "session_meta", "payload": { "id": "a" } })) - + &line(json!({ "timestamp": "2026-07-01T12:00:01Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) - + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), Some((900, 600, 80))) - + &tc("2026-07-01T12:00:03Z", None, Some((1400, 900, 130))) // diff: 500/300/50 - + &tc("2026-07-01T12:00:04Z", None, None), // info without usage → skipped - ) - .unwrap(); - // resumed copy of the same session: identical events must de-dup, a new turn counts. - fs::write( - day.join("rollout-b.jsonl"), - line(json!({ "timestamp": "2026-07-01T12:10:00Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) - + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None) // duplicate of a's turn 1 - + &tc("2026-07-01T12:10:01Z", Some((10, 0, 5)), None), // new turn (15) - ) - .unwrap(); - // archived copy of rollout-a (same relative path) → file-level de-dup, never read twice. - let arch = base.join("archived_sessions").join("2026").join("07").join("01"); - fs::create_dir_all(&arch).unwrap(); - fs::write(arch.join("rollout-a.jsonl"), tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None)).unwrap(); - // thread_spawn subagent: leading replay burst (same second) skipped, own turn counted, - // and the baseline carried from the replayed cumulative total. - fs::write( - day.join("rollout-sub.jsonl"), - line(json!({ "timestamp": "2026-07-01T13:00:00Z", "type": "session_meta", - "payload": { "id": "sub", "source": { "type": "thread_spawn" } } })) - + &tc("2026-07-01T13:00:01Z", Some((900, 600, 80)), Some((900, 600, 80))) - + &tc("2026-07-01T13:00:01Z", Some((500, 300, 50)), Some((1400, 900, 130))) - + &tc("2026-07-01T13:00:05Z", None, Some((1600, 900, 160))), // own turn: diff 200/0/30 - ) - .unwrap(); - - let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); - let days = build_data(&config, "all"); - let (tokens, input, output, cache_read, requests, models) = sum(&days); - // a#1: in 900 (cached 600) out 80 → input 300, cacheRead 600, out 80 (980) - // a#2 (diff): in 500 (cached 300) out 50 → input 200, cacheRead 300, out 50 (550) - // b#2: 10/0/5 (15) - // sub own turn: 200/0/30 (230) - assert_eq!(requests, 4); - assert_eq!(input, 300 + 200 + 10 + 200); - assert_eq!(cache_read, 600 + 300); - assert_eq!(output, 80 + 50 + 5 + 30); - assert_eq!(tokens, 980 + 550 + 15 + 230); - assert_eq!(models.get("gpt-5.5").copied(), Some(980 + 550 + 15)); - // subagent file had no turn_context → fallback model - assert_eq!(models.get("gpt-5").copied(), Some(230)); - - let _ = fs::remove_dir_all(&base); - } -} - -#[cfg(test)] -mod real_data_probe { - use super::*; - - // Diagnostic harness (not an assertion): aggregate a REAL history dir and print per-range - // totals, so the implementation can be diffed against `ccusage` on the same data. - // Run: CCBUD_PROBE_DIR=~/.claude cargo test --lib probe_real_dir -- --ignored --nocapture - #[test] - #[ignore] - fn probe_real_dir() { - let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { - eprintln!("set CCBUD_PROBE_DIR"); - return; - }; - // parse-level diagnostics: where do lines fall out of the pipeline? - let root = expand_tilde(&dir); - let mut files = vec![]; - collect_jsonl(&root.join("projects"), 0, &mut files); - let (mut n_files, mut n_usage_lines, mut n_parsed, mut n_no_ts, mut n_degen) = (0u64, 0u64, 0u64, 0u64, 0u64); - for file in &files { - n_files += 1; - let Some(mut lines) = LossyLines::open(file) else { continue }; - while let Some(l) = lines.next_line() { - let l = l.trim(); - if !l.contains("\"usage\"") { - continue; - } - n_usage_lines += 1; - match parse_claude_line(l) { - Some(rec) => { - n_parsed += 1; - if rec.id.as_deref().map(degenerate_id).unwrap_or(false) { - n_degen += 1; - } - } - None => { - // distinguish the "usage present but timestamp bad/missing" case - if let Ok(v) = serde_json::from_str::(l) { - if v.get("message").and_then(|m| m.get("usage")).is_some() - && v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() - { - n_no_ts += 1; - } - } - } - } - } - } - eprintln!( - "claude files={} usage-lines={} parsed={} dropped-no-ts={} degenerate-id={}", - n_files, n_usage_lines, n_parsed, n_no_ts, n_degen - ); - let config = json!({ "historyDirs": [dir] }); - let days = build_data(&config, "all"); - let now = Local::now().timestamp_millis(); - let mut keys: Vec<_> = days.keys().cloned().collect(); - keys.sort(); - for k in &keys { - let d = &days[k]; - eprintln!("{} tokens={} in={} out={} cr={} cc={} req={}", k, d.tokens, d.input, d.output, d.cache_read, d.cache_creation, d.requests); - } - for range in ["1d", "7d", "30d", "all"] { - let q = query(&days, range, now); - eprintln!("range {:>3}: tokens={} requests={}", range, q["tokens"], q["requests"]); - } - } -} - -#[cfg(test)] -mod diag_probe { - use super::*; - #[test] - #[ignore] - fn probe_diag() { - let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { return }; - eprintln!("{}", diag(&json!({ "historyDirs": [dir] }), "all")); - } -} diff --git a/src-tauri/src/usage/build.rs b/src-tauri/src/usage/build.rs new file mode 100644 index 0000000..d2d955b --- /dev/null +++ b/src-tauri/src/usage/build.rs @@ -0,0 +1,130 @@ +// Whole-scan aggregation across both trees, plus the cache that keeps the popover's two +// usage_get calls per open off the disk. Moved verbatim from usage.rs. + +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use super::claude::{dedup_claude, parse_claude_line, ClaudeRec}; +use super::codex::{codex_files, parse_codex_file, CodexUsage}; +use super::model::{bump, collect_jsonl, Day, LossyLines, UsageRec}; +use super::roots::active_roots; + +// --------------------------------------------------------------------------- +// aggregation +// --------------------------------------------------------------------------- + +pub(super) fn build_data(config: &Value, active: &str) -> HashMap { + let mut days: HashMap = HashMap::new(); + let roots = active_roots(config, active); + + // Claude Code: parse everything, then de-dup globally, then bucket. + let mut claude_recs: Vec = vec![]; + for root in &roots { + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + files.sort(); + // Qoder projects/ trees can be macOS-protected: route those files through the guarded + // reader (helper fallback + cache, warmed in one batch) so 用量 counts the same sessions + // the 对话 view can browse. Wrapper records that repeat a message id are handled by the + // usual dedup (max-total wins) and partial snapshots lack output_tokens, so nothing + // double-counts. + let qoder_files: Vec = files + .iter() + .filter(|f| crate::qoder::looks_qoder_path(f)) + .cloned() + .collect(); + crate::qoder::prefetch(&qoder_files); + for file in files { + if crate::qoder::looks_qoder_path(&file) { + let Ok(bytes) = crate::qoder::read_bytes(&file) else { continue }; + for line in String::from_utf8_lossy(&bytes).lines() { + if let Some(rec) = parse_claude_line(line.trim()) { + claude_recs.push(rec); + } + } + continue; + } + let Some(mut lines) = LossyLines::open(&file) else { continue }; + while let Some(line) = lines.next_line() { + if let Some(rec) = parse_claude_line(line.trim()) { + claude_recs.push(rec); + } + } + } + } + for kept in dedup_claude(claude_recs) { + bump(&mut days, &kept.rec); + } + + // Codex: per-turn events, de-duped globally by (timestamp, model, tokens) so resumed/forked + // session copies collapse. + let mut events: Vec<(CodexUsage, i64, String)> = vec![]; + for root in &roots { + for file in codex_files(root) { + parse_codex_file(&file, &mut events); + } + } + let mut seen: HashSet<(i64, String, CodexUsage)> = HashSet::new(); + for (u, ts, model) in events { + if !seen.insert((ts, model.clone(), u)) { + continue; + } + bump( + &mut days, + &UsageRec { + ts, + model: Some(model), + input: (u.input - u.cached).max(0), + output: u.output, + cache_read: u.cached, + cache_creation: 0, + }, + ); + } + days +} + +// ---- usage cache ---- +// build_data scans every history .jsonl (~0.5s cold for ~1200 files), and the popover calls +// usage_get TWICE per open (heatmap "all" + stats range). Cache the scanned per-day map keyed by +// the active dirs, invalidated when history files change (notify watcher) — so the second per-open +// call + repeated opens are instant, and a startup/post-change warm makes the first open instant. +struct UsageCache { + sig: String, + days: HashMap, +} +static USAGE_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn dirs_sig(config: &Value, active: &str) -> String { + format!("{}|{:?}", active, active_roots(config, active)) +} + +pub(super) fn build_data_cached(config: &Value, active: &str) -> HashMap { + let sig = dirs_sig(config, active); + { + // recover a poisoned lock (a panicked scan thread must not disable caching forever) + let cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(c) = cache.as_ref() { + if c.sig == sig { + return c.days.clone(); + } + } + } + let days = build_data(config, active); + let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + *cache = Some(UsageCache { sig, days: days.clone() }); + days +} + +/// Drop the cached scan — call when history files change so the next read rescans. +pub fn invalidate_cache() { + let mut cache = USAGE_CACHE.lock().unwrap_or_else(|p| p.into_inner()); + *cache = None; +} + +/// Scan + cache now (off the click path). Call at startup and after history changes so the first +/// popover open is instant instead of paying the cold-scan cost. +pub fn warm_cache(config: &Value, active: &str) { + let _ = build_data_cached(config, active); +} diff --git a/src-tauri/src/usage/claude.rs b/src-tauri/src/usage/claude.rs new file mode 100644 index 0000000..7c5646c --- /dev/null +++ b/src-tauri/src/usage/claude.rs @@ -0,0 +1,107 @@ +// Claude Code (projects/ tree) line parsing and the global ccusage de-dup. Moved verbatim from +// usage.rs. + +use serde_json::Value; +use std::collections::HashMap; + +use super::model::UsageRec; +use super::roots::parse_ts; + +// --------------------------------------------------------------------------- +// Claude Code (projects/ tree) +// --------------------------------------------------------------------------- + +pub(super) struct ClaudeRec { + pub(super) id: Option, + pub(super) request_id: Option, + pub(super) sidechain: bool, + pub(super) rec: UsageRec, +} + +/// Parse one history line into a usage entry. Requires numeric `message.usage.input_tokens` / +/// `output_tokens` and a parseable `timestamp`; everything else is optional. +pub(super) fn parse_claude_line(line: &str) -> Option { + // cheap prefilter before JSON parse (ccusage scans for the same marker) + if !line.contains("\"usage\"") { + return None; + } + let r: Value = serde_json::from_str(line).ok()?; + let m = r.get("message")?; + let u = m.get("usage")?; + let input = u.get("input_tokens")?.as_i64()?; + let output = u.get("output_tokens")?.as_i64()?; + let cache_read = u.get("cache_read_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + // nested ephemeral breakdown wins over the flat cache_creation_input_tokens + let cache_creation = match u.get("cache_creation").filter(|v| v.is_object()) { + Some(b) => { + b.get("ephemeral_5m_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) + + b.get("ephemeral_1h_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0) + } + None => u.get("cache_creation_input_tokens").and_then(|v| v.as_i64()).unwrap_or(0), + }; + if input + output + cache_read + cache_creation <= 0 { + return None; // zero rows (synthetic error turns) carry no token information + } + let ts = r.get("timestamp").and_then(|v| v.as_str()).and_then(parse_ts)?; + let speed_fast = u.get("speed").and_then(|v| v.as_str()) == Some("fast"); + let model = m.get("model").and_then(|v| v.as_str()).and_then(|s| { + if s.is_empty() || s == "" { + None // tokens still count; no model attribution + } else if speed_fast { + Some(format!("{}-fast", s)) + } else { + Some(s.to_string()) + } + }); + Some(ClaudeRec { + id: m.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), + request_id: r.get("requestId").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), + sidechain: r.get("isSidechain").and_then(|v| v.as_bool()).unwrap_or(false), + rec: UsageRec { ts, model, input, output, cache_read, cache_creation }, + }) +} + +/// Message ids that older ccbud gateway builds stamped on EVERY translated response — known +/// non-unique, so they must never act as a de-dup key (an id-keyed de-dup would collapse whole +/// weeks of history written through the gateway into a single counted turn). +pub(super) fn degenerate_id(id: &str) -> bool { + id == "msg_ccbud" || id == "chatcmpl-ccbud" || id == "resp_ccbud" +} + +/// Global de-dup, ccusage semantics: key (message.id, requestId); entries without an id are always +/// kept. A miss on the exact key falls back to the id-only bucket when either side is a sidechain +/// (a `/btw` replay reuses the parent's message.id under a new requestId). On a duplicate the +/// non-sidechain copy wins, then the higher token total. +pub(super) fn dedup_claude(recs: Vec) -> Vec { + let mut kept: Vec = vec![]; + let mut by_exact: HashMap<(String, Option), usize> = HashMap::new(); + let mut by_id: HashMap = HashMap::new(); + for cand in recs { + let Some(id) = cand.id.clone().filter(|i| !degenerate_id(i)) else { + kept.push(cand); + continue; + }; + let exact = (id.clone(), cand.request_id.clone()); + let slot = by_exact.get(&exact).copied().or_else(|| { + by_id.get(&id).copied().filter(|&i| cand.sidechain || kept[i].sidechain) + }); + match slot { + Some(i) => { + let cur = &kept[i]; + let replace = (cur.sidechain && !cand.sidechain) + || (cur.sidechain == cand.sidechain && cand.rec.total() > cur.rec.total()); + if replace { + kept[i] = cand; + } + by_exact.insert(exact, i); + } + None => { + let i = kept.len(); + by_exact.insert(exact, i); + by_id.entry(id).or_insert(i); + kept.push(cand); + } + } + } + kept +} diff --git a/src-tauri/src/usage/codex.rs b/src-tauri/src/usage/codex.rs new file mode 100644 index 0000000..e6e770b --- /dev/null +++ b/src-tauri/src/usage/codex.rs @@ -0,0 +1,190 @@ +// Codex (sessions/ + archived_sessions/ trees) rollout parsing. Moved verbatim from usage.rs. + +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use super::model::{collect_jsonl, LossyLines}; +use super::roots::parse_ts; + +// --------------------------------------------------------------------------- +// Codex (sessions/ + archived_sessions/ trees) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +pub(super) struct CodexUsage { + pub(super) input: i64, + pub(super) cached: i64, + pub(super) output: i64, + pub(super) reasoning: i64, + total: i64, +} + +/// Lenient token-usage decode (ccusage accepts several field aliases per component). +fn codex_usage_of(v: &Value) -> Option { + let o = v.as_object()?; + let g = |keys: &[&str]| keys.iter().find_map(|k| o.get(*k).and_then(|v| v.as_i64())).unwrap_or(0); + let input = g(&["input_tokens", "prompt_tokens", "input"]); + let cached = g(&["cached_input_tokens", "cache_read_input_tokens", "cached_tokens"]); + let output = g(&["output_tokens", "completion_tokens", "output"]); + let reasoning = g(&["reasoning_output_tokens", "reasoning_tokens"]); + let total = match o.get("total_tokens").and_then(|v| v.as_i64()) { + Some(t) if t > 0 || input + output + reasoning == 0 => t, + _ => input + output + reasoning, + }; + Some(CodexUsage { input, cached, output, reasoning, total }) +} + +fn codex_usage_sub(cur: CodexUsage, prev: Option) -> CodexUsage { + let p = prev.unwrap_or_default(); + CodexUsage { + input: (cur.input - p.input).max(0), + cached: (cur.cached - p.cached).max(0), + output: (cur.output - p.output).max(0), + reasoning: (cur.reasoning - p.reasoning).max(0), + total: (cur.total - p.total).max(0), + } +} + +fn codex_model_of(v: Option<&Value>) -> Option { + let o = v?.as_object()?; + o.get("model") + .or_else(|| o.get("model_name")) + .or_else(|| o.get("metadata").and_then(|m| m.get("model"))) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// Whether this rollout is a `thread_spawn` subagent session (marker in the file head). +fn codex_is_subagent(file: &Path) -> bool { + use std::io::Read; + let Ok(mut f) = std::fs::File::open(file) else { return false }; + let mut buf = [0u8; 16 * 1024]; + let n = f.read(&mut buf).unwrap_or(0); + buf[..n].windows(b"thread_spawn".len()).any(|w| w == b"thread_spawn") +} + +/// A subagent file replays the parent's token_count history as a leading burst that shares one +/// timestamp-second — detect that second (the first two usage events landing on the same second), +/// so the replay can be skipped while the cumulative baseline still advances. +fn codex_replay_second(file: &Path) -> Option { + let mut first: Option = None; + let mut lines = LossyLines::open(file)?; + while let Some(line) = lines.next_line() { + let Some((ts, payload)) = codex_token_count_line(&line) else { continue }; + let info = payload.get("info"); + let has_usage = info + .map(|i| i.get("last_token_usage").is_some() || i.get("total_token_usage").is_some()) + .unwrap_or(false); + if !has_usage { + continue; + } + let second: String = ts.chars().take(19).collect(); + match &first { + None => first = Some(second), + Some(f) => return if *f == second { Some(second) } else { None }, + } + } + None +} + +/// Parse a line as a `token_count` event → (timestamp, payload). None for everything else. +fn codex_token_count_line(line: &str) -> Option<(String, Value)> { + if !line.contains("token_count") { + return None; + } + let r: Value = serde_json::from_str(line).ok()?; + if r.get("type").and_then(|v| v.as_str()) != Some("event_msg") { + return None; + } + let p = r.get("payload")?; + if p.get("type").and_then(|v| v.as_str()) != Some("token_count") { + return None; + } + let ts = r.get("timestamp").and_then(|v| v.as_str())?.to_string(); + Some((ts, p.clone())) +} + +/// Parse one Codex rollout file into per-turn usage events (ccusage semantics — see module doc). +pub(super) fn parse_codex_file(file: &Path, out: &mut Vec<(CodexUsage, i64, String)>) { + let replay_second = if codex_is_subagent(file) { codex_replay_second(file) } else { None }; + let mut skip_replay = replay_second.is_some(); + let mut current_model: Option = None; + let mut prev_totals: Option = None; + let Some(mut lines) = LossyLines::open(file) else { return }; + while let Some(line) = lines.next_line() { + let s = line.trim(); + if s.is_empty() { + continue; + } + // turn_context carries the active model + if s.contains("turn_context") { + if let Ok(r) = serde_json::from_str::(s) { + if r.get("type").and_then(|v| v.as_str()) == Some("turn_context") { + if let Some(m) = codex_model_of(r.get("payload")) { + current_model = Some(m); + } + continue; + } + } + } + let Some((ts_str, payload)) = codex_token_count_line(s) else { continue }; + let info = payload.get("info").filter(|i| !i.is_null()); + let total = info.and_then(|i| i.get("total_token_usage")).and_then(codex_usage_of); + let last = info.and_then(|i| i.get("last_token_usage")).and_then(codex_usage_of); + // leading parent-history replay in a subagent file: skip, but keep the baseline moving + if skip_replay { + let second: String = ts_str.chars().take(19).collect(); + if Some(&second) == replay_second.as_ref() { + if let Some(t) = total { + prev_totals = Some(t); + } + continue; + } + skip_replay = false; + } + let usage = last.or_else(|| total.map(|t| codex_usage_sub(t, prev_totals))); + if let Some(t) = total { + prev_totals = Some(t); + } + let Some(mut u) = usage else { continue }; + if u.input + u.cached + u.output + u.reasoning == 0 { + continue; + } + let Some(ts) = parse_ts(&ts_str) else { continue }; + u.cached = u.cached.min(u.input); // input is INCLUSIVE of cached + let model = codex_model_of(Some(&payload)) + .or_else(|| codex_model_of(info)) + .or_else(|| current_model.clone()) + .unwrap_or_else(|| "gpt-5".to_string()); + out.push((u, ts, model)); + } +} + +/// Collect a work dir's Codex rollout files: sessions/ plus archived_sessions/, where an archived +/// copy of the same relative path loses to the active sessions/ copy. +pub(super) fn codex_files(root: &Path) -> Vec { + let mut out: Vec = vec![]; + let mut seen_rel: HashSet = HashSet::new(); + for sub in ["sessions", "archived_sessions"] { + let dir = root.join(sub); + let mut files = vec![]; + collect_jsonl(&dir, 0, &mut files); + files.sort(); + for f in files { + // Grok Build shares the sessions/ root but keys children by percent-encoded cwd + // (`%2FUsers%2F…//chat_history.jsonl` + events/updates sidecar jsonl). Those + // must never hit the Codex token parser — wasteful and would mix formats if a line + // ever looked like a token_count event. Skip any path under a Grok-encoded dir. + if f.components().any(|c| crate::grok::is_cwd_dir_name(&c.as_os_str().to_string_lossy())) { + continue; + } + let rel = f.strip_prefix(&dir).map(|p| p.to_path_buf()).unwrap_or_else(|_| f.clone()); + if seen_rel.insert(rel) { + out.push(f); + } + } + } + out +} diff --git a/src-tauri/src/usage/diag.rs b/src-tauri/src/usage/diag.rs new file mode 100644 index 0000000..b487012 --- /dev/null +++ b/src-tauri/src/usage/diag.rs @@ -0,0 +1,79 @@ +// The scan diagnostic line and the module's public entry point. Moved verbatim from usage.rs. + +use chrono::Local; +use serde_json::Value; + +use super::build::{build_data, build_data_cached}; +use super::claude::{dedup_claude, degenerate_id, parse_claude_line, ClaudeRec}; +use super::codex::codex_files; +use super::model::{collect_jsonl, LossyLines}; +use super::query::query; +use super::roots::{active_roots, parse_ts}; + +/// One-line scan diagnostic for the gateway log — which dirs resolved, how many files/lines +/// reached each pipeline stage, and the day span. Makes an empty/partial aggregation visible +/// without a debugger ("only today shows up" → the counters name the stage that dropped it). +pub fn diag(config: &Value, active: &str) -> String { + let roots = active_roots(config, active); + let mut claude_files = 0usize; + let mut codex_file_count = 0usize; + let (mut usage_lines, mut parsed, mut zero_rows, mut no_ts, mut degen) = (0usize, 0usize, 0usize, 0usize, 0usize); + let mut recs: Vec = vec![]; + for root in &roots { + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + claude_files += files.len(); + for f in &files { + let Some(mut lines) = LossyLines::open(f) else { continue }; + while let Some(l) = lines.next_line() { + let l = l.trim(); + if !l.contains("\"usage\"") { + continue; + } + usage_lines += 1; + match parse_claude_line(l) { + Some(r) => { + parsed += 1; + if r.id.as_deref().map(degenerate_id).unwrap_or(false) { + degen += 1; + } + recs.push(r); + } + None => { + if let Ok(v) = serde_json::from_str::(l) { + if v.get("message").and_then(|m| m.get("usage")).is_some() { + if v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() { + no_ts += 1; + } else { + zero_rows += 1; + } + } + } + } + } + } + } + codex_file_count += codex_files(root).len(); + } + let kept = dedup_claude(recs).len(); + let days = build_data(config, active); + let mut keys: Vec<_> = days.keys().cloned().collect(); + keys.sort(); + let span = match (keys.first(), keys.last()) { + (Some(a), Some(b)) => format!("{}..{}", a, b), + _ => "-".to_string(), + }; + format!( + "usage scan: active={} roots={:?} claude-files={} codex-files={} usage-lines={} parsed={} kept={} days={} span={} dropped(no-ts)={} dropped(zero/invalid)={} degenerate-ids={}", + active, + roots.iter().map(|r| r.to_string_lossy().to_string()).collect::>(), + claude_files, codex_file_count, usage_lines, parsed, kept, keys.len(), span, no_ts, zero_rows, degen + ) +} + +/// Public entry: aggregate the active dirs and return the usage stats payload for `range`. +pub fn usage_get(config: &Value, active: &str, range: &str) -> Value { + let days = build_data_cached(config, active); + let now = Local::now().timestamp_millis(); + query(&days, range, now) +} diff --git a/src-tauri/src/usage/diag_probe.rs b/src-tauri/src/usage/diag_probe.rs new file mode 100644 index 0000000..3e8348f --- /dev/null +++ b/src-tauri/src/usage/diag_probe.rs @@ -0,0 +1,8 @@ +use super::*; +use serde_json::json; +#[test] +#[ignore] +fn probe_diag() { + let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { return }; + eprintln!("{}", diag(&json!({ "historyDirs": [dir] }), "all")); +} diff --git a/src-tauri/src/usage/mod.rs b/src-tauri/src/usage/mod.rs new file mode 100644 index 0000000..4f63ac8 --- /dev/null +++ b/src-tauri/src/usage/mod.rs @@ -0,0 +1,53 @@ +// Usage analytics — aggregation semantics ported from ccusage (github.com/ccusage/ccusage), +// scoped to the two agents ccbud fronts: Claude Code and Codex. +// +// Per active work dir, two session trees contribute: +// +// Claude Code `projects/**/*.jsonl` (recursive, any depth — sessions, nested session dirs, +// subagent transcripts all included by construction): +// - every line whose `message.usage` carries numeric input/output tokens counts — no +// `type=="assistant"` gate (ccusage parity); +// - a line without a parseable RFC3339 `timestamp` is DROPPED (never guessed); +// - cache-creation prefers the nested `cache_creation.ephemeral_{5m,1h}_input_tokens` +// breakdown over the flat `cache_creation_input_tokens`; +// - `` models keep their tokens but get no model attribution; `usage.speed=="fast"` +// appends a `-fast` suffix to the model; +// - global de-dup by (message.id, requestId) — entries without a message.id are never +// de-duped; a sidechain replay that reuses the parent's message.id under a NEW requestId +// collapses onto the parent (non-sidechain wins, then higher token total). +// +// Codex `sessions/**/*.jsonl` + `archived_sessions/**/*.jsonl` (an archived copy of the same +// relative path is skipped — the active sessions/ copy wins): +// - `token_count` events: prefer `info.last_token_usage` (the turn delta); fall back to +// diffing consecutive `info.total_token_usage` snapshots; the cumulative baseline always +// advances so either form stays correct; +// - `thread_spawn` subagent files replay the parent's history as a leading burst of +// token_count lines sharing one timestamp-second — those are skipped (baseline still +// advances) so parent turns aren't counted twice; +// - identical (timestamp, model, tokens) events across files (resumed/forked sessions) +// de-dup globally; +// - model comes from the event payload/info, else the last `turn_context`, else "gpt-5"; +// `input_tokens` is INCLUSIVE of `cached_input_tokens` — the cached part is split out into +// cacheRead and the remainder becomes input. +// +// Day bucketing is local-timezone (chrono::Local), matching ccusage's system-timezone default. + +#![allow(dead_code)] +mod build; +mod claude; +mod codex; +mod diag; +mod model; +mod query; +mod roots; +#[cfg(test)] +mod diag_probe; +#[cfg(test)] +mod real_data_probe; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_codex; + +pub use build::{invalidate_cache, warm_cache}; +pub use diag::{diag, usage_get}; diff --git a/src-tauri/src/usage/model.rs b/src-tauri/src/usage/model.rs new file mode 100644 index 0000000..db445df --- /dev/null +++ b/src-tauri/src/usage/model.rs @@ -0,0 +1,95 @@ +// The per-day bucket, the counted usage event both trees produce, and the two file-walking +// primitives they share. Moved verbatim from usage.rs. + +use std::collections::HashMap; +use std::io::BufRead; +use std::path::{Path, PathBuf}; + +use super::roots::{hour_of, key_of}; + +#[derive(Default, Clone)] +pub(super) struct Day { + pub(super) tokens: i64, + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, + pub(super) requests: i64, + pub(super) models: HashMap, + pub(super) providers: HashMap, + pub(super) hours: HashMap, +} + +/// One counted usage event, whichever tree it came from. +pub(super) struct UsageRec { + pub(super) ts: i64, + pub(super) model: Option, + pub(super) input: i64, + pub(super) output: i64, + pub(super) cache_read: i64, + pub(super) cache_creation: i64, +} + +impl UsageRec { + pub(super) fn total(&self) -> i64 { + self.input + self.output + self.cache_read + self.cache_creation + } +} + +pub(super) fn bump(days: &mut HashMap, rec: &UsageRec) { + let day = days.entry(key_of(rec.ts)).or_default(); + day.requests += 1; + day.tokens += rec.total(); + day.input += rec.input; + day.output += rec.output; + day.cache_read += rec.cache_read; + day.cache_creation += rec.cache_creation; + if let Some(m) = &rec.model { + *day.models.entry(m.clone()).or_insert(0) += rec.total(); + } + *day.hours.entry(hour_of(rec.ts)).or_insert(0) += rec.total(); +} + +/// Recursively collect `*.jsonl` under `dir`, any depth (ccusage walks the whole tree — nested +/// session dirs and subagent transcripts are picked up by construction). Depth-capped as a +/// symlink-loop guard. +pub(super) fn collect_jsonl(dir: &Path, depth: u32, out: &mut Vec) { + if depth > 8 { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_dir() { + collect_jsonl(&p, depth + 1, out); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + out.push(p); + } + } +} + +/// Byte-based lossy line reader. History files can embed invalid UTF-8 inside tool output — +/// a strict `BufRead::lines` errors there and would silently discard the REST of the file +/// (ccusage reads raw bytes for the same reason). +pub(super) struct LossyLines { + reader: std::io::BufReader, + buf: Vec, +} + +impl LossyLines { + pub(super) fn open(file: &Path) -> Option { + std::fs::File::open(file) + .ok() + .map(|f| Self { reader: std::io::BufReader::new(f), buf: Vec::with_capacity(64 * 1024) }) + } + pub(super) fn next_line(&mut self) -> Option { + self.buf.clear(); + match self.reader.read_until(b'\n', &mut self.buf) { + Ok(0) | Err(_) => None, + Ok(_) => Some(String::from_utf8_lossy(&self.buf).into_owned()), + } + } +} diff --git a/src-tauri/src/usage/query.rs b/src-tauri/src/usage/query.rs new file mode 100644 index 0000000..a663672 --- /dev/null +++ b/src-tauri/src/usage/query.rs @@ -0,0 +1,151 @@ +// Range selection, streaks, heatmap and the stats payload the renderer consumes. Moved verbatim +// from usage.rs. + +use chrono::{Datelike, Local, TimeZone}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +use super::model::Day; +use super::roots::{key_of, ms_of_key, start_of_day, DAY_MS, HEATMAP_WEEKS}; + +fn range_keys(days: &HashMap, range: &str, now: i64) -> Vec { + let mut all: Vec = days.keys().cloned().collect(); + all.sort(); + if range == "all" { + return all; + } + let n = match range { + "1d" => 1, + "30d" => 30, + _ => 7, + }; + let cut = start_of_day(now - (n - 1) * DAY_MS); + all.into_iter().filter(|k| ms_of_key(k) >= cut).collect() +} + +fn top_key(map: &HashMap) -> Option { + map.iter().max_by_key(|(_, v)| **v).map(|(k, _)| k.clone()) +} + +fn streaks(days: &HashMap, now: i64) -> (i64, i64) { + let mut active: Vec = days + .iter() + .filter(|(_, d)| d.requests > 0) + .map(|(k, _)| ms_of_key(k)) + .collect(); + active.sort(); + let set: HashSet = active.iter().cloned().collect(); + let (mut longest, mut run, mut prev): (i64, i64, Option) = (0, 0, None); + for t in &active { + run = if prev.map(|p| t - p == DAY_MS).unwrap_or(false) { run + 1 } else { 1 }; + prev = Some(*t); + if run > longest { + longest = run; + } + } + let mut cur = 0; + let mut t = start_of_day(now); + if !set.contains(&t) { + t -= DAY_MS; + } + while set.contains(&t) { + cur += 1; + t -= DAY_MS; + } + (cur, longest) +} + +fn build_heatmap(days: &HashMap, weeks: i64, now: i64) -> Vec { + let today = start_of_day(now); + let span = weeks * 7; + let mut start = today - (span - 1) * DAY_MS; + let dow = Local.timestamp_millis_opt(start).single().map(|d| d.weekday().num_days_from_sunday() as i64).unwrap_or(0); + start -= dow * DAY_MS; + let mut cells: Vec<(String, i64)> = vec![]; + let mut max = 1i64; + let mut t = start; + while t <= today { + let k = key_of(t); + let tok = days.get(&k).map(|d| d.tokens).unwrap_or(0); + if tok > max { + max = tok; + } + cells.push((k, tok)); + t += DAY_MS; + } + cells + .into_iter() + .map(|(date, tokens)| { + let r = tokens as f64 / max as f64; + let level = if tokens == 0 { + 0 + } else if r > 0.66 { + 4 + } else if r > 0.33 { + 3 + } else if r > 0.1 { + 2 + } else { + 1 + }; + json!({ "date": date, "tokens": tokens, "level": level }) + }) + .collect() +} + +pub(super) fn query(days: &HashMap, range: &str, now: i64) -> Value { + let keys = range_keys(days, range, now); + let (mut tokens, mut input, mut output, mut cache_read, mut cache_creation, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64); + let mut models: HashMap = HashMap::new(); + let mut providers: HashMap = HashMap::new(); + let mut hours: HashMap = HashMap::new(); + let mut active_days = 0; + for k in &keys { + if let Some(d) = days.get(k) { + tokens += d.tokens; + input += d.input; + output += d.output; + cache_read += d.cache_read; + cache_creation += d.cache_creation; + requests += d.requests; + if d.requests > 0 { + active_days += 1; + } + for (m, v) in &d.models { + *models.entry(m.clone()).or_insert(0) += v; + } + for (p, v) in &d.providers { + *providers.entry(p.clone()).or_insert(0) += v; + } + for (h, v) in &d.hours { + *hours.entry(*h).or_insert(0) += v; + } + } + } + let mut by_model: Vec = models + .iter() + .map(|(m, t)| json!({ "model": m, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) + .collect(); + by_model.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); + let mut by_provider: Vec = providers + .iter() + .map(|(p, t)| json!({ "provider": p, "tokens": t, "pct": if tokens > 0 { *t as f64 / tokens as f64 } else { 0.0 } })) + .collect(); + by_provider.sort_by(|a, b| b["tokens"].as_i64().unwrap_or(0).cmp(&a["tokens"].as_i64().unwrap_or(0))); + let peak_hour = hours.iter().max_by_key(|(_, v)| **v).map(|(h, _)| *h as i64); + let (cur, longest) = streaks(days, now); + + json!({ + "range": range, + "tokens": tokens, "input": input, "output": output, "cacheRead": cache_read, "cacheCreation": cache_creation, + "requests": requests, "activeDays": active_days, + "peakHour": peak_hour, + "favoriteModel": top_key(&models), + "favoriteProvider": top_key(&providers), + "byModel": by_model, + "byProvider": by_provider, + "currentStreak": cur, + "longestStreak": longest, + "heatmap": build_heatmap(days, HEATMAP_WEEKS, now), + }) +} diff --git a/src-tauri/src/usage/real_data_probe.rs b/src-tauri/src/usage/real_data_probe.rs new file mode 100644 index 0000000..6847047 --- /dev/null +++ b/src-tauri/src/usage/real_data_probe.rs @@ -0,0 +1,70 @@ +use super::build::build_data; +use super::claude::{degenerate_id, parse_claude_line}; +use super::model::{collect_jsonl, LossyLines}; +use super::query::query; +use super::roots::{expand_tilde, parse_ts}; +use chrono::Local; +use serde_json::{json, Value}; + +// Diagnostic harness (not an assertion): aggregate a REAL history dir and print per-range +// totals, so the implementation can be diffed against `ccusage` on the same data. +// Run: CCBUD_PROBE_DIR=~/.claude cargo test --lib probe_real_dir -- --ignored --nocapture +#[test] +#[ignore] +fn probe_real_dir() { + let Ok(dir) = std::env::var("CCBUD_PROBE_DIR") else { + eprintln!("set CCBUD_PROBE_DIR"); + return; + }; + // parse-level diagnostics: where do lines fall out of the pipeline? + let root = expand_tilde(&dir); + let mut files = vec![]; + collect_jsonl(&root.join("projects"), 0, &mut files); + let (mut n_files, mut n_usage_lines, mut n_parsed, mut n_no_ts, mut n_degen) = (0u64, 0u64, 0u64, 0u64, 0u64); + for file in &files { + n_files += 1; + let Some(mut lines) = LossyLines::open(file) else { continue }; + while let Some(l) = lines.next_line() { + let l = l.trim(); + if !l.contains("\"usage\"") { + continue; + } + n_usage_lines += 1; + match parse_claude_line(l) { + Some(rec) => { + n_parsed += 1; + if rec.id.as_deref().map(degenerate_id).unwrap_or(false) { + n_degen += 1; + } + } + None => { + // distinguish the "usage present but timestamp bad/missing" case + if let Ok(v) = serde_json::from_str::(l) { + if v.get("message").and_then(|m| m.get("usage")).is_some() + && v.get("timestamp").and_then(|t| t.as_str()).and_then(parse_ts).is_none() + { + n_no_ts += 1; + } + } + } + } + } + } + eprintln!( + "claude files={} usage-lines={} parsed={} dropped-no-ts={} degenerate-id={}", + n_files, n_usage_lines, n_parsed, n_no_ts, n_degen + ); + let config = json!({ "historyDirs": [dir] }); + let days = build_data(&config, "all"); + let now = Local::now().timestamp_millis(); + let mut keys: Vec<_> = days.keys().cloned().collect(); + keys.sort(); + for k in &keys { + let d = &days[k]; + eprintln!("{} tokens={} in={} out={} cr={} cc={} req={}", k, d.tokens, d.input, d.output, d.cache_read, d.cache_creation, d.requests); + } + for range in ["1d", "7d", "30d", "all"] { + let q = query(&days, range, now); + eprintln!("range {:>3}: tokens={} requests={}", range, q["tokens"], q["requests"]); + } +} diff --git a/src-tauri/src/usage/roots.rs b/src-tauri/src/usage/roots.rs new file mode 100644 index 0000000..82d5abb --- /dev/null +++ b/src-tauri/src/usage/roots.rs @@ -0,0 +1,78 @@ +// Active work-dir resolution and the local-timezone day-key math every aggregation stage keys +// on. Moved verbatim from usage.rs. + +use chrono::{Datelike, Local, TimeZone, Timelike}; +use serde_json::Value; +use std::path::PathBuf; + +pub(super) const DAY_MS: i64 = 86_400_000; +pub(super) const HEATMAP_WEEKS: i64 = 26; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} +pub(super) fn expand_tilde(p: &str) -> PathBuf { + if let Some(rest) = p.strip_prefix("~/") { + home().join(rest) + } else if p == "~" { + home() + } else { + PathBuf::from(p) + } +} + +/// Active work dirs (honors the directory switcher). A selector that matches no configured dir — +/// the synthetic recycle-bin / imported-bundle views ("__trash__", "__imported__"), or a stale +/// value from an older config — falls back to ALL dirs: a filter must never zero the stats. +pub(super) fn active_roots(config: &Value, active: &str) -> Vec { + let mut all = vec![]; + let mut selected = vec![]; + if let Some(arr) = config.get("historyDirs").and_then(|v| v.as_array()) { + for d in arr { + if let Some(s) = d.as_str() { + all.push(expand_tilde(s)); + if active == s { + selected.push(expand_tilde(s)); + } + } + } + } + if active != "all" && !selected.is_empty() { + selected + } else { + all + } +} + +pub(super) fn parse_ts(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s).ok().map(|d| d.timestamp_millis()) +} +pub(super) fn key_of(ms: i64) -> String { + match Local.timestamp_millis_opt(ms).single() { + Some(d) => format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()), + None => "1970-01-01".to_string(), + } +} +pub(super) fn start_of_day(ms: i64) -> i64 { + match Local.timestamp_millis_opt(ms).single() { + Some(d) => { + let day = d.date_naive().and_hms_opt(0, 0, 0).unwrap(); + Local.from_local_datetime(&day).single().map(|x| x.timestamp_millis()).unwrap_or(ms) + } + None => ms, + } +} +pub(super) fn ms_of_key(k: &str) -> i64 { + let parts: Vec = k.split('-').filter_map(|x| x.parse().ok()).collect(); + if parts.len() != 3 { + return 0; + } + let nd = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1] as u32, parts[2] as u32); + match nd.and_then(|d| d.and_hms_opt(0, 0, 0)) { + Some(dt) => Local.from_local_datetime(&dt).single().map(|x| x.timestamp_millis()).unwrap_or(0), + None => 0, + } +} +pub(super) fn hour_of(ms: i64) -> u32 { + Local.timestamp_millis_opt(ms).single().map(|d| d.hour()).unwrap_or(0) +} diff --git a/src-tauri/src/usage/tests.rs b/src-tauri/src/usage/tests.rs new file mode 100644 index 0000000..a2ab3c3 --- /dev/null +++ b/src-tauri/src/usage/tests.rs @@ -0,0 +1,173 @@ +use super::build::build_data; +use super::model::Day; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; + +pub(super) fn line(v: Value) -> String { + format!("{}\n", v) +} +fn asst(id: &str, req: &str, model: &str, ts: &str, inp: i64, out: i64) -> String { + line(json!({ "type": "assistant", "timestamp": ts, "requestId": req, + "message": { "id": id, "model": model, + "usage": { "input_tokens": inp, "output_tokens": out } } })) +} + +pub(super) fn sum(days: &HashMap) -> (i64, i64, i64, i64, i64, HashMap) { + let (mut tokens, mut input, mut output, mut cache_read, mut requests) = (0i64, 0i64, 0i64, 0i64, 0i64); + let mut models: HashMap = HashMap::new(); + for d in days.values() { + tokens += d.tokens; + input += d.input; + output += d.output; + cache_read += d.cache_read; + requests += d.requests; + for (m, v) in &d.models { + *models.entry(m.clone()).or_insert(0) += v; + } + } + (tokens, input, output, cache_read, requests, models) +} + +#[test] +fn claude_ccusage_semantics() { + let base = std::env::temp_dir().join(format!("ccbud-usage-cl-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + // nested session dir + subagent transcript at arbitrary depth — recursive walk finds both + let deep = proj.join("s1").join("subagents"); + fs::create_dir_all(&deep).unwrap(); + + fs::write( + proj.join("s1.jsonl"), + // counted (110) + asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) + // same (id, requestId) duplicate → collapsed + + &asst("m1", "r1", "claude-x", "2026-07-01T10:00:00Z", 100, 10) + // same id, DIFFERENT requestId, no sidechain → distinct entry (counted, 55) + + &asst("m1", "r2", "claude-x", "2026-07-01T10:05:00Z", 50, 5) + // undated → dropped + + &line(json!({ "type": "assistant", + "message": { "id": "m2", "model": "claude-x", "usage": { "input_tokens": 9, "output_tokens": 9 } } })) + // zero usage → dropped + + &asst("m3", "r3", "", "2026-07-01T10:06:00Z", 0, 0) + // synthetic model with tokens → counted (7), no model attribution + + &asst("m4", "r4", "", "2026-07-01T10:07:00Z", 5, 2) + // no type field at all (ccusage has no type gate) → counted (13) + + &line(json!({ "timestamp": "2026-07-01T10:08:00Z", "requestId": "r5", + "message": { "id": "m5", "model": "claude-x", + "usage": { "input_tokens": 10, "output_tokens": 3 } } })), + ) + .unwrap(); + // subagent transcript, nested cache_creation breakdown + fast speed suffix (counted, 3+4+6+7=20) + fs::write( + deep.join("agent-a.jsonl"), + line(json!({ "timestamp": "2026-07-01T11:00:00Z", "requestId": "r6", + "message": { "id": "m6", "model": "claude-x", + "usage": { "input_tokens": 3, "output_tokens": 4, "speed": "fast", + "cache_read_input_tokens": 6, + "cache_creation_input_tokens": 999, + "cache_creation": { "ephemeral_5m_input_tokens": 5, "ephemeral_1h_input_tokens": 2 } } } })), + ) + .unwrap(); + // sidechain replay: reuses m1 under a NEW requestId with isSidechain → collapses onto parent + fs::write( + proj.join("s2.jsonl"), + line(json!({ "type": "assistant", "timestamp": "2026-07-01T10:00:01Z", "requestId": "r9", + "isSidechain": true, + "message": { "id": "m1", "model": "claude-x", "usage": { "input_tokens": 100, "output_tokens": 10 } } })), + ) + .unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (tokens, input, output, cache_read, requests, models) = sum(&days); + // m1(110) + m1/r2(55) + m4(7) + m5(13) + m6(20) + assert_eq!(requests, 5); + assert_eq!(input, 100 + 50 + 5 + 10 + 3); + assert_eq!(output, 10 + 5 + 2 + 3 + 4); + assert_eq!(cache_read, 6); + assert_eq!(tokens, 110 + 55 + 7 + 13 + 20); + // synthetic tokens counted but unattributed; fast suffix applied + assert_eq!(models.get("claude-x").copied(), Some(110 + 55 + 13)); + assert_eq!(models.get("claude-x-fast").copied(), Some(20)); + assert!(models.get("").is_none()); + + let _ = fs::remove_dir_all(&base); +} + +// History written through OLD ccbud gateway builds: every streamed response carries the +// constant id "msg_ccbud" (and often no requestId — the gateway didn't forward the header). +// Those ids must never act as de-dup keys, or weeks of history collapse into one turn. +#[test] +fn degenerate_gateway_ids_never_dedup() { + let base = std::env::temp_dir().join(format!("ccbud-usage-degen-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + let no_req = |ts: &str, inp: i64| { + line(json!({ "type": "assistant", "timestamp": ts, + "message": { "id": "msg_ccbud", "model": "glm-4.7", + "usage": { "input_tokens": inp, "output_tokens": 1 } } })) + }; + fs::write( + proj.join("old-era.jsonl"), + no_req("2026-06-20T10:00:00Z", 100) + + &no_req("2026-06-21T10:00:00Z", 200) + + &no_req("2026-06-22T10:00:00Z", 300) + + &line(json!({ "type": "assistant", "timestamp": "2026-06-23T10:00:00Z", "requestId": "r1", + "message": { "id": "chatcmpl-ccbud", "model": "glm-4.7", + "usage": { "input_tokens": 400, "output_tokens": 1 } } })), + ) + .unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (_, input, _, _, requests, _) = sum(&days); + // all four turns count — four distinct days survive + assert_eq!(requests, 4); + assert_eq!(input, 100 + 200 + 300 + 400); + assert_eq!(days.len(), 4); + let _ = fs::remove_dir_all(&base); +} + +// The 对话 page's dir switcher persists synthetic views (recycle bin, imported bundles) into +// historyActive — those match no configured dir and previously zeroed every usage number. +#[test] +fn synthetic_or_stale_active_falls_back_to_all_dirs() { + let base = std::env::temp_dir().join(format!("ccbud-usage-active-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + fs::write(proj.join("s.jsonl"), asst("a1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1)).unwrap(); + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + for active in ["all", "__trash__", "__imported__", "/no/such/dir"] { + let days = build_data(&config, active); + let (tokens, ..) = sum(&days); + assert_eq!(tokens, 11, "active={} must not zero the stats", active); + } + // a VALID selector still filters + let days = build_data(&config, base.to_string_lossy().as_ref()); + let (tokens, ..) = sum(&days); + assert_eq!(tokens, 11); + let _ = fs::remove_dir_all(&base); +} + +#[test] +fn invalid_utf8_does_not_truncate_a_file() { + let base = std::env::temp_dir().join(format!("ccbud-usage-u8-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let proj = base.join("projects").join("-p"); + fs::create_dir_all(&proj).unwrap(); + let mut bytes = asst("u1", "r1", "m", "2026-07-01T10:00:00Z", 10, 1).into_bytes(); + bytes.extend_from_slice(b"{\"garbage\": \"\xff\xfe binary tool output\"}\n"); + bytes.extend_from_slice(asst("u2", "r2", "m", "2026-07-02T10:00:00Z", 20, 2).as_bytes()); + fs::write(proj.join("s.jsonl"), bytes).unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (_, input, _, _, requests, _) = sum(&days); + // the record AFTER the invalid-UTF-8 line still counts + assert_eq!(requests, 2); + assert_eq!(input, 30); + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/usage/tests_codex.rs b/src-tauri/src/usage/tests_codex.rs new file mode 100644 index 0000000..5bfdf0b --- /dev/null +++ b/src-tauri/src/usage/tests_codex.rs @@ -0,0 +1,78 @@ +use super::build::build_data; +use super::tests::{line, sum}; +use serde_json::json; +use std::fs; + +fn tc(ts: &str, last: Option<(i64, i64, i64)>, total: Option<(i64, i64, i64)>) -> String { + let mut info = json!({}); + if let Some((i, c, o)) = last { + info["last_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, + "total_tokens": i + o }); + } + if let Some((i, c, o)) = total { + info["total_token_usage"] = json!({ "input_tokens": i, "cached_input_tokens": c, "output_tokens": o, + "total_tokens": i + o }); + } + line(json!({ "timestamp": ts, "type": "event_msg", "payload": { "type": "token_count", "info": info } })) +} + +#[test] +fn codex_ccusage_semantics() { + let base = std::env::temp_dir().join(format!("ccbud-usage-cx-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let day = base.join("sessions").join("2026").join("07").join("01"); + fs::create_dir_all(&day).unwrap(); + + // main session: model from turn_context; one last_token_usage turn; one turn WITHOUT + // last (only cumulative total) → counted as the diff from the baseline. + fs::write( + day.join("rollout-a.jsonl"), + line(json!({ "timestamp": "2026-07-01T12:00:00Z", "type": "session_meta", "payload": { "id": "a" } })) + + &line(json!({ "timestamp": "2026-07-01T12:00:01Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) + + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), Some((900, 600, 80))) + + &tc("2026-07-01T12:00:03Z", None, Some((1400, 900, 130))) // diff: 500/300/50 + + &tc("2026-07-01T12:00:04Z", None, None), // info without usage → skipped + ) + .unwrap(); + // resumed copy of the same session: identical events must de-dup, a new turn counts. + fs::write( + day.join("rollout-b.jsonl"), + line(json!({ "timestamp": "2026-07-01T12:10:00Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } })) + + &tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None) // duplicate of a's turn 1 + + &tc("2026-07-01T12:10:01Z", Some((10, 0, 5)), None), // new turn (15) + ) + .unwrap(); + // archived copy of rollout-a (same relative path) → file-level de-dup, never read twice. + let arch = base.join("archived_sessions").join("2026").join("07").join("01"); + fs::create_dir_all(&arch).unwrap(); + fs::write(arch.join("rollout-a.jsonl"), tc("2026-07-01T12:00:02Z", Some((900, 600, 80)), None)).unwrap(); + // thread_spawn subagent: leading replay burst (same second) skipped, own turn counted, + // and the baseline carried from the replayed cumulative total. + fs::write( + day.join("rollout-sub.jsonl"), + line(json!({ "timestamp": "2026-07-01T13:00:00Z", "type": "session_meta", + "payload": { "id": "sub", "source": { "type": "thread_spawn" } } })) + + &tc("2026-07-01T13:00:01Z", Some((900, 600, 80)), Some((900, 600, 80))) + + &tc("2026-07-01T13:00:01Z", Some((500, 300, 50)), Some((1400, 900, 130))) + + &tc("2026-07-01T13:00:05Z", None, Some((1600, 900, 160))), // own turn: diff 200/0/30 + ) + .unwrap(); + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let days = build_data(&config, "all"); + let (tokens, input, output, cache_read, requests, models) = sum(&days); + // a#1: in 900 (cached 600) out 80 → input 300, cacheRead 600, out 80 (980) + // a#2 (diff): in 500 (cached 300) out 50 → input 200, cacheRead 300, out 50 (550) + // b#2: 10/0/5 (15) + // sub own turn: 200/0/30 (230) + assert_eq!(requests, 4); + assert_eq!(input, 300 + 200 + 10 + 200); + assert_eq!(cache_read, 600 + 300); + assert_eq!(output, 80 + 50 + 5 + 30); + assert_eq!(tokens, 980 + 550 + 15 + 230); + assert_eq!(models.get("gpt-5.5").copied(), Some(980 + 550 + 15)); + // subagent file had no turn_context → fallback model + assert_eq!(models.get("gpt-5").copied(), Some(230)); + + let _ = fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/ziputil.rs b/src-tauri/src/ziputil.rs deleted file mode 100644 index bfb91a4..0000000 --- a/src-tauri/src/ziputil.rs +++ /dev/null @@ -1,291 +0,0 @@ -// Minimal ZIP reader/writer for conversation bundles. Rust port of src/main/zipStore.js — the byte -// layout is proven there by test/zip.test.js (round-trip + system `unzip`), so this mirror stays in -// lockstep with it. -// -// A conversation with subagents exports as a .zip whose FIRST level is the main session .jsonl and -// whose `subagents/` directory holds the per-subagent files; re-importing restores that layout. -// Only the round-trip slice of the spec is implemented: -// - write: STORE or raw-DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. -// - read : parse via the central directory (so OS-repacked zips with data descriptors still read), -// handling STORE (0) and DEFLATE (8); unreadable members are skipped, never panic. - -#![allow(dead_code)] - -use flate2::read::DeflateDecoder; -use flate2::write::DeflateEncoder; -use flate2::Compression; -use std::io::{Read, Write}; - -pub struct Entry { - pub name: String, - pub data: Vec, -} - -fn crc32(data: &[u8]) -> u32 { - let mut crc: u32 = 0xffff_ffff; - for &b in data { - crc ^= b as u32; - for _ in 0..8 { - let mask = (crc & 1).wrapping_neg(); - crc = (crc >> 1) ^ (0xedb8_8320 & mask); - } - } - !crc -} - -fn deflate(data: &[u8]) -> Option> { - let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); - enc.write_all(data).ok()?; - enc.finish().ok() -} - -/// Build a .zip from entries. STORE unless raw-DEFLATE is strictly smaller. -pub fn build(entries: &[Entry]) -> Vec { - let mut local: Vec = Vec::new(); - let mut central: Vec = Vec::new(); - let mut offset: u32 = 0; - for e in entries { - let name = e.name.as_bytes(); - let crc = crc32(&e.data); - let deflated = deflate(&e.data); - let (method, payload): (u16, &[u8]) = match &deflated { - Some(d) if d.len() < e.data.len() => (8, d.as_slice()), - _ => (0, e.data.as_slice()), - }; - let comp_size = payload.len() as u32; - let uncomp_size = e.data.len() as u32; - - // local file header - local.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); - local.extend_from_slice(&20u16.to_le_bytes()); // version needed - local.extend_from_slice(&0u16.to_le_bytes()); // flags - local.extend_from_slice(&method.to_le_bytes()); - local.extend_from_slice(&0u16.to_le_bytes()); // mod time - local.extend_from_slice(&0x21u16.to_le_bytes()); // mod date = 1980-01-01 - local.extend_from_slice(&crc.to_le_bytes()); - local.extend_from_slice(&comp_size.to_le_bytes()); - local.extend_from_slice(&uncomp_size.to_le_bytes()); - local.extend_from_slice(&(name.len() as u16).to_le_bytes()); - local.extend_from_slice(&0u16.to_le_bytes()); // extra length - local.extend_from_slice(name); - local.extend_from_slice(payload); - - // central directory header - central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); - central.extend_from_slice(&20u16.to_le_bytes()); // version made by - central.extend_from_slice(&20u16.to_le_bytes()); // version needed - central.extend_from_slice(&0u16.to_le_bytes()); // flags - central.extend_from_slice(&method.to_le_bytes()); - central.extend_from_slice(&0u16.to_le_bytes()); // mod time - central.extend_from_slice(&0x21u16.to_le_bytes()); // mod date - central.extend_from_slice(&crc.to_le_bytes()); - central.extend_from_slice(&comp_size.to_le_bytes()); - central.extend_from_slice(&uncomp_size.to_le_bytes()); - central.extend_from_slice(&(name.len() as u16).to_le_bytes()); - central.extend_from_slice(&0u16.to_le_bytes()); // extra length - central.extend_from_slice(&0u16.to_le_bytes()); // comment length - central.extend_from_slice(&0u16.to_le_bytes()); // disk number start - central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs - central.extend_from_slice(&0u32.to_le_bytes()); // external attrs - central.extend_from_slice(&offset.to_le_bytes()); // relative offset of local header - central.extend_from_slice(name); - - offset += 30 + name.len() as u32 + comp_size; - } - let central_start = offset; - let central_size = central.len() as u32; - let mut out = local; - out.extend_from_slice(¢ral); - // end of central directory record - out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); - out.extend_from_slice(&0u16.to_le_bytes()); // this disk - out.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir - out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // entries this disk - out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // total entries - out.extend_from_slice(¢ral_size.to_le_bytes()); - out.extend_from_slice(¢ral_start.to_le_bytes()); - out.extend_from_slice(&0u16.to_le_bytes()); // comment length - out -} - -fn rd_u16(buf: &[u8], at: usize) -> Option { - buf.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) -} -fn rd_u32(buf: &[u8], at: usize) -> Option { - buf.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) -} - -/// Parse a .zip → entries. Best-effort: unreadable/unsupported members are skipped. -pub fn read(buf: &[u8]) -> Vec { - let mut out: Vec = Vec::new(); - if buf.len() < 22 { - return out; - } - // Locate the End Of Central Directory record by scanning backwards for its signature. - let mut eocd: Option = None; - let top = buf.len() - 22; - let floor = top.saturating_sub(65535); - let mut i = top; - loop { - if rd_u32(buf, i) == Some(0x0605_4b50) { - eocd = Some(i); - break; - } - if i <= floor { - break; - } - i -= 1; - } - let eocd = match eocd { - Some(e) => e, - None => return out, - }; - let count = rd_u16(buf, eocd + 10).unwrap_or(0) as usize; - let mut p = rd_u32(buf, eocd + 16).unwrap_or(0) as usize; // central directory offset - for _ in 0..count { - if rd_u32(buf, p) != Some(0x0201_4b50) { - break; - } - let method = rd_u16(buf, p + 10).unwrap_or(0); - let comp_size = rd_u32(buf, p + 20).unwrap_or(0) as usize; - let name_len = rd_u16(buf, p + 28).unwrap_or(0) as usize; - let extra_len = rd_u16(buf, p + 30).unwrap_or(0) as usize; - let comment_len = rd_u16(buf, p + 32).unwrap_or(0) as usize; - let local_off = rd_u32(buf, p + 42).unwrap_or(0) as usize; - let name = buf - .get(p + 46..(p + 46 + name_len).min(buf.len())) - .map(|s| String::from_utf8_lossy(s).into_owned()) - .unwrap_or_default(); - // The local header repeats name/extra lengths; trust it for the data offset. - if rd_u32(buf, local_off) == Some(0x0403_4b50) { - let lh_name = rd_u16(buf, local_off + 26).unwrap_or(0) as usize; - let lh_extra = rd_u16(buf, local_off + 28).unwrap_or(0) as usize; - let data_start = local_off + 30 + lh_name + lh_extra; - let data_end = data_start + comp_size; - if data_end <= buf.len() { - let payload = &buf[data_start..data_end]; - let data = match method { - 0 => Some(payload.to_vec()), - 8 => { - let mut v = Vec::new(); - DeflateDecoder::new(payload).read_to_end(&mut v).ok().map(|_| v) - } - _ => None, - }; - if let Some(data) = data { - out.push(Entry { name, data }); - } - } - } - p += 46 + name_len + extra_len + comment_len; - } - out -} - -fn norm(name: &str) -> String { - name.replace('\\', "/").trim_start_matches("./").to_string() -} -fn in_subagents(name: &str) -> bool { - norm(name).split('/').any(|seg| seg == "subagents") -} -fn depth(name: &str) -> usize { - norm(name).matches('/').count() -} -fn base_name(name: &str) -> String { - norm(name).split('/').filter(|s| !s.is_empty()).last().unwrap_or("").to_string() -} - -/// Split a bundle's entries into (main, subagents), mirroring zipStore.js splitBundle: the main -/// session is the shallowest top-level *.jsonl (never under a subagents/ segment); subagents are the -/// agent-* transcript / meta files under any subagents/ directory. Tolerant of a wrapping folder. -/// Returns (Some((name, data)), Vec<(name, data)>); main is None when no session file is present. -pub fn split_bundle(entries: Vec) -> (Option<(String, Vec)>, Vec<(String, Vec)>) { - let mut main: Option = None; - for (i, e) in entries.iter().enumerate() { - if !e.name.to_lowercase().ends_with(".jsonl") || in_subagents(&e.name) { - continue; - } - match main { - Some(m) if depth(&entries[m].name) <= depth(&e.name) => {} - _ => main = Some(i), - } - } - let mut subagents: Vec<(String, Vec)> = Vec::new(); - for e in &entries { - if !in_subagents(&e.name) { - continue; - } - let base = base_name(&e.name); - let lower = base.to_lowercase(); - if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { - subagents.push((base, e.data.clone())); - } - } - let main_out = main.map(|i| (base_name(&entries[i].name), entries[i].data.clone())); - (main_out, subagents) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn crc32_check_value() { - // Standard CRC-32 check value for "123456789". - assert_eq!(crc32(b"123456789"), 0xcbf4_3926); - } - - #[test] - fn round_trips_store_and_deflate() { - let big = b"{\"type\":\"assistant\"}\n".repeat(4000); // very compressible → DEFLATE - let bin = vec![0u8, 1, 2, 3, 255, 254, 10, 13, 0, 42]; - let entries = vec![ - Entry { name: "main.jsonl".into(), data: b"hi\n".to_vec() }, - Entry { name: "subagents/agent-aaa.jsonl".into(), data: big.clone() }, - Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{\"toolUseId\":\"tu1\"}".to_vec() }, - Entry { name: "blob.bin".into(), data: bin.clone() }, - ]; - let zip = build(&entries); - assert_eq!(u32::from_le_bytes([zip[0], zip[1], zip[2], zip[3]]), 0x0403_4b50); - assert!(zip.len() < big.len(), "deflate should shrink: zip={} raw={}", zip.len(), big.len()); - - let read_back = read(&zip); - assert_eq!(read_back.len(), entries.len()); - for src in &entries { - let got = read_back.iter().find(|r| r.name == src.name).expect("entry present"); - assert_eq!(got.data, src.data, "payload mismatch for {}", src.name); - } - } - - #[test] - fn split_bundle_recovers_main_and_subagents() { - let entries = vec![ - Entry { name: "main.jsonl".into(), data: b"m".to_vec() }, - Entry { name: "subagents/agent-aaa.jsonl".into(), data: b"a".to_vec() }, - Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{}".to_vec() }, - Entry { name: "blob.bin".into(), data: b"x".to_vec() }, - ]; - let (main, subs) = split_bundle(entries); - assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("main.jsonl")); - assert_eq!(subs.len(), 2); - assert!(subs.iter().all(|(n, _)| !n.contains('/'))); - assert!(subs.iter().any(|(n, _)| n == "agent-aaa.meta.json")); - } - - #[test] - fn split_bundle_tolerates_wrapping_folder() { - let entries = vec![ - Entry { name: "bundle/sess.jsonl".into(), data: b"m".to_vec() }, - Entry { name: "bundle/subagents/agent-x.jsonl".into(), data: b"a".to_vec() }, - ]; - let (main, subs) = split_bundle(entries); - assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("sess.jsonl")); - assert_eq!(subs.len(), 1); - } - - #[test] - fn read_tolerates_garbage() { - assert_eq!(read(b"not a zip at all").len(), 0); - assert_eq!(read(&[]).len(), 0); - } -} diff --git a/src-tauri/src/ziputil/bundle.rs b/src-tauri/src/ziputil/bundle.rs new file mode 100644 index 0000000..effcd27 --- /dev/null +++ b/src-tauri/src/ziputil/bundle.rs @@ -0,0 +1,47 @@ +// Conversation-bundle shape: the FIRST level holds the main session .jsonl and `subagents/` +// holds the per-subagent files. split_bundle recovers that layout from a flat entry list. + +use super::write::Entry; + +fn norm(name: &str) -> String { + name.replace('\\', "/").trim_start_matches("./").to_string() +} +fn in_subagents(name: &str) -> bool { + norm(name).split('/').any(|seg| seg == "subagents") +} +fn depth(name: &str) -> usize { + norm(name).matches('/').count() +} +fn base_name(name: &str) -> String { + norm(name).split('/').filter(|s| !s.is_empty()).last().unwrap_or("").to_string() +} + +/// Split a bundle's entries into (main, subagents), mirroring zipStore.js splitBundle: the main +/// session is the shallowest top-level *.jsonl (never under a subagents/ segment); subagents are the +/// agent-* transcript / meta files under any subagents/ directory. Tolerant of a wrapping folder. +/// Returns (Some((name, data)), Vec<(name, data)>); main is None when no session file is present. +pub fn split_bundle(entries: Vec) -> (Option<(String, Vec)>, Vec<(String, Vec)>) { + let mut main: Option = None; + for (i, e) in entries.iter().enumerate() { + if !e.name.to_lowercase().ends_with(".jsonl") || in_subagents(&e.name) { + continue; + } + match main { + Some(m) if depth(&entries[m].name) <= depth(&e.name) => {} + _ => main = Some(i), + } + } + let mut subagents: Vec<(String, Vec)> = Vec::new(); + for e in &entries { + if !in_subagents(&e.name) { + continue; + } + let base = base_name(&e.name); + let lower = base.to_lowercase(); + if lower.starts_with("agent-") && (lower.ends_with(".jsonl") || lower.ends_with(".meta.json")) { + subagents.push((base, e.data.clone())); + } + } + let main_out = main.map(|i| (base_name(&entries[i].name), entries[i].data.clone())); + (main_out, subagents) +} diff --git a/src-tauri/src/ziputil/mod.rs b/src-tauri/src/ziputil/mod.rs new file mode 100644 index 0000000..153fe76 --- /dev/null +++ b/src-tauri/src/ziputil/mod.rs @@ -0,0 +1,23 @@ +// Minimal ZIP reader/writer for conversation bundles. Rust port of src/main/zipStore.js — the byte +// layout is proven there by test/zip.test.js (round-trip + system `unzip`), so this mirror stays in +// lockstep with it. +// +// A conversation with subagents exports as a .zip whose FIRST level is the main session .jsonl and +// whose `subagents/` directory holds the per-subagent files; re-importing restores that layout. +// Only the round-trip slice of the spec is implemented: +// - write: STORE or raw-DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. +// - read : parse via the central directory (so OS-repacked zips with data descriptors still read), +// handling STORE (0) and DEFLATE (8); unreadable members are skipped, never panic. + +#![allow(dead_code)] + + +mod bundle; +mod read; +mod write; +#[cfg(test)] +mod tests; + +pub use bundle::split_bundle; +pub use read::read; +pub use write::{build, Entry}; diff --git a/src-tauri/src/ziputil/read.rs b/src-tauri/src/ziputil/read.rs new file mode 100644 index 0000000..84a27ca --- /dev/null +++ b/src-tauri/src/ziputil/read.rs @@ -0,0 +1,80 @@ +// Reading: parse via the central directory (so OS-repacked zips with data descriptors still +// read), handling STORE (0) and DEFLATE (8). Unreadable members are skipped, never panic. + +use super::write::Entry; +use flate2::read::DeflateDecoder; +use std::io::Read; + +fn rd_u16(buf: &[u8], at: usize) -> Option { + buf.get(at..at + 2).map(|s| u16::from_le_bytes([s[0], s[1]])) +} +fn rd_u32(buf: &[u8], at: usize) -> Option { + buf.get(at..at + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +/// Parse a .zip → entries. Best-effort: unreadable/unsupported members are skipped. +pub fn read(buf: &[u8]) -> Vec { + let mut out: Vec = Vec::new(); + if buf.len() < 22 { + return out; + } + // Locate the End Of Central Directory record by scanning backwards for its signature. + let mut eocd: Option = None; + let top = buf.len() - 22; + let floor = top.saturating_sub(65535); + let mut i = top; + loop { + if rd_u32(buf, i) == Some(0x0605_4b50) { + eocd = Some(i); + break; + } + if i <= floor { + break; + } + i -= 1; + } + let eocd = match eocd { + Some(e) => e, + None => return out, + }; + let count = rd_u16(buf, eocd + 10).unwrap_or(0) as usize; + let mut p = rd_u32(buf, eocd + 16).unwrap_or(0) as usize; // central directory offset + for _ in 0..count { + if rd_u32(buf, p) != Some(0x0201_4b50) { + break; + } + let method = rd_u16(buf, p + 10).unwrap_or(0); + let comp_size = rd_u32(buf, p + 20).unwrap_or(0) as usize; + let name_len = rd_u16(buf, p + 28).unwrap_or(0) as usize; + let extra_len = rd_u16(buf, p + 30).unwrap_or(0) as usize; + let comment_len = rd_u16(buf, p + 32).unwrap_or(0) as usize; + let local_off = rd_u32(buf, p + 42).unwrap_or(0) as usize; + let name = buf + .get(p + 46..(p + 46 + name_len).min(buf.len())) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .unwrap_or_default(); + // The local header repeats name/extra lengths; trust it for the data offset. + if rd_u32(buf, local_off) == Some(0x0403_4b50) { + let lh_name = rd_u16(buf, local_off + 26).unwrap_or(0) as usize; + let lh_extra = rd_u16(buf, local_off + 28).unwrap_or(0) as usize; + let data_start = local_off + 30 + lh_name + lh_extra; + let data_end = data_start + comp_size; + if data_end <= buf.len() { + let payload = &buf[data_start..data_end]; + let data = match method { + 0 => Some(payload.to_vec()), + 8 => { + let mut v = Vec::new(); + DeflateDecoder::new(payload).read_to_end(&mut v).ok().map(|_| v) + } + _ => None, + }; + if let Some(data) = data { + out.push(Entry { name, data }); + } + } + } + p += 46 + name_len + extra_len + comment_len; + } + out +} diff --git a/src-tauri/src/ziputil/tests.rs b/src-tauri/src/ziputil/tests.rs new file mode 100644 index 0000000..9015837 --- /dev/null +++ b/src-tauri/src/ziputil/tests.rs @@ -0,0 +1,63 @@ +use super::bundle::split_bundle; +use super::read::read; +use super::write::{build, crc32, Entry}; + +#[test] +fn crc32_check_value() { + // Standard CRC-32 check value for "123456789". + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); +} + +#[test] +fn round_trips_store_and_deflate() { + let big = b"{\"type\":\"assistant\"}\n".repeat(4000); // very compressible → DEFLATE + let bin = vec![0u8, 1, 2, 3, 255, 254, 10, 13, 0, 42]; + let entries = vec![ + Entry { name: "main.jsonl".into(), data: b"hi\n".to_vec() }, + Entry { name: "subagents/agent-aaa.jsonl".into(), data: big.clone() }, + Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{\"toolUseId\":\"tu1\"}".to_vec() }, + Entry { name: "blob.bin".into(), data: bin.clone() }, + ]; + let zip = build(&entries); + assert_eq!(u32::from_le_bytes([zip[0], zip[1], zip[2], zip[3]]), 0x0403_4b50); + assert!(zip.len() < big.len(), "deflate should shrink: zip={} raw={}", zip.len(), big.len()); + + let read_back = read(&zip); + assert_eq!(read_back.len(), entries.len()); + for src in &entries { + let got = read_back.iter().find(|r| r.name == src.name).expect("entry present"); + assert_eq!(got.data, src.data, "payload mismatch for {}", src.name); + } +} + +#[test] +fn split_bundle_recovers_main_and_subagents() { + let entries = vec![ + Entry { name: "main.jsonl".into(), data: b"m".to_vec() }, + Entry { name: "subagents/agent-aaa.jsonl".into(), data: b"a".to_vec() }, + Entry { name: "subagents/agent-aaa.meta.json".into(), data: b"{}".to_vec() }, + Entry { name: "blob.bin".into(), data: b"x".to_vec() }, + ]; + let (main, subs) = split_bundle(entries); + assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("main.jsonl")); + assert_eq!(subs.len(), 2); + assert!(subs.iter().all(|(n, _)| !n.contains('/'))); + assert!(subs.iter().any(|(n, _)| n == "agent-aaa.meta.json")); +} + +#[test] +fn split_bundle_tolerates_wrapping_folder() { + let entries = vec![ + Entry { name: "bundle/sess.jsonl".into(), data: b"m".to_vec() }, + Entry { name: "bundle/subagents/agent-x.jsonl".into(), data: b"a".to_vec() }, + ]; + let (main, subs) = split_bundle(entries); + assert_eq!(main.as_ref().map(|(n, _)| n.as_str()), Some("sess.jsonl")); + assert_eq!(subs.len(), 1); +} + +#[test] +fn read_tolerates_garbage() { + assert_eq!(read(b"not a zip at all").len(), 0); + assert_eq!(read(&[]).len(), 0); +} diff --git a/src-tauri/src/ziputil/write.rs b/src-tauri/src/ziputil/write.rs new file mode 100644 index 0000000..2bc169e --- /dev/null +++ b/src-tauri/src/ziputil/write.rs @@ -0,0 +1,98 @@ +// Writing: CRC-32, raw DEFLATE, and the local-header + central-directory layout. +// STORE or DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. + +use flate2::write::DeflateEncoder; +use flate2::Compression; +use std::io::Write; + +pub struct Entry { + pub name: String, + pub data: Vec, +} + +pub(super) fn crc32(data: &[u8]) -> u32 { + let mut crc: u32 = 0xffff_ffff; + for &b in data { + crc ^= b as u32; + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +fn deflate(data: &[u8]) -> Option> { + let mut enc = DeflateEncoder::new(Vec::new(), Compression::default()); + enc.write_all(data).ok()?; + enc.finish().ok() +} + +/// Build a .zip from entries. STORE unless raw-DEFLATE is strictly smaller. +pub fn build(entries: &[Entry]) -> Vec { + let mut local: Vec = Vec::new(); + let mut central: Vec = Vec::new(); + let mut offset: u32 = 0; + for e in entries { + let name = e.name.as_bytes(); + let crc = crc32(&e.data); + let deflated = deflate(&e.data); + let (method, payload): (u16, &[u8]) = match &deflated { + Some(d) if d.len() < e.data.len() => (8, d.as_slice()), + _ => (0, e.data.as_slice()), + }; + let comp_size = payload.len() as u32; + let uncomp_size = e.data.len() as u32; + + // local file header + local.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + local.extend_from_slice(&20u16.to_le_bytes()); // version needed + local.extend_from_slice(&0u16.to_le_bytes()); // flags + local.extend_from_slice(&method.to_le_bytes()); + local.extend_from_slice(&0u16.to_le_bytes()); // mod time + local.extend_from_slice(&0x21u16.to_le_bytes()); // mod date = 1980-01-01 + local.extend_from_slice(&crc.to_le_bytes()); + local.extend_from_slice(&comp_size.to_le_bytes()); + local.extend_from_slice(&uncomp_size.to_le_bytes()); + local.extend_from_slice(&(name.len() as u16).to_le_bytes()); + local.extend_from_slice(&0u16.to_le_bytes()); // extra length + local.extend_from_slice(name); + local.extend_from_slice(payload); + + // central directory header + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); // version made by + central.extend_from_slice(&20u16.to_le_bytes()); // version needed + central.extend_from_slice(&0u16.to_le_bytes()); // flags + central.extend_from_slice(&method.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); // mod time + central.extend_from_slice(&0x21u16.to_le_bytes()); // mod date + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&comp_size.to_le_bytes()); + central.extend_from_slice(&uncomp_size.to_le_bytes()); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); // extra length + central.extend_from_slice(&0u16.to_le_bytes()); // comment length + central.extend_from_slice(&0u16.to_le_bytes()); // disk number start + central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs + central.extend_from_slice(&0u32.to_le_bytes()); // external attrs + central.extend_from_slice(&offset.to_le_bytes()); // relative offset of local header + central.extend_from_slice(name); + + offset += 30 + name.len() as u32 + comp_size; + } + let central_start = offset; + let central_size = central.len() as u32; + let mut out = local; + out.extend_from_slice(¢ral); + // end of central directory record + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // this disk + out.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // entries this disk + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); // total entries + out.extend_from_slice(¢ral_size.to_le_bytes()); + out.extend_from_slice(¢ral_start.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + out +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4865036..73ed647 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "CCBuddy", - "version": "1.3.8", + "version": "1.3.9", "identifier": "dev.ccbud.gateway", "build": { "frontendDist": "../src/renderer", diff --git a/src/main/bootstrap.js b/src/main/bootstrap.js deleted file mode 100644 index b9cd82e..0000000 --- a/src/main/bootstrap.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -/* - * Hot-update bootstrap — the app's real Electron entry point (package.json "main"). - * - * Before the actual app code runs, this resolves WHICH copy of the app to load: - * - the version baked into the installed bundle ("shell"), or - * - a newer JS-only bundle the updater downloaded into /hot//. - * - * Because a hot bundle is just interpreted JS loaded by the already-installed native shell, - * applying one needs no code-signing / reinstall — only the JS/renderer layer changes. Native - * changes (Electron bump, bundled binaries) are gated by the manifest's minShellVersion and - * fall back to a full installer instead (see updater.js). - * - * Safety: promotion of a staged bundle and rollback of a bundle that fails to boot both happen - * here, and the require is wrapped so a broken bundle can never brick the app — it falls back to - * the packaged shell. main.js confirms a successful boot (clears `trying`) via updater.js. - */ - -const { app } = require('electron'); -const fs = require('fs'); -const path = require('path'); -const hp = require('./hotpaths'); - -const PACKAGED_ROOT = app.getAppPath(); // dir (or app.asar) that contains src/main/main.js - -function entryExists(root) { - try { return !!root && fs.existsSync(hp.mainEntry(root)); } catch (_) { return false; } -} - -// Resolve the bundle root to load, performing pending-promotion and crash-rollback on the way. -function resolveRoot(userData) { - let state; - try { state = hp.readState(userData); } catch (_) { return PACKAGED_ROOT; } - let dirty = false; - - // 1) A staged bundle is waiting → promote it to active for this launch. - if (state.pending && state.pending.dir) { - const stagedRoot = hp.bundleDir(userData, state.pending.dir); - if (entryExists(stagedRoot)) { - state.previous = state.active || null; - state.active = state.pending; - state.trying = state.active.version || null; // unconfirmed until main.js says it booted - } - state.pending = null; - dirty = true; - } else if (state.trying && state.active && state.trying === state.active.version) { - // 2) Last launch promoted this active bundle but never confirmed a clean boot - // (likely crashed during startup) → roll back to the previous known-good / packaged. - const bad = state.active; - state.active = state.previous || null; - state.previous = null; - state.trying = null; - dirty = true; - // best-effort: drop the bad bundle so it isn't retried - try { if (bad && bad.dir) fs.rmSync(hp.bundleDir(userData, bad.dir), { recursive: true, force: true }); } catch (_) {} - } - - if (dirty) { try { hp.writeState(userData, state); } catch (_) {} } - - if (state.active && state.active.dir) { - const activeRoot = hp.bundleDir(userData, state.active.dir); - if (entryExists(activeRoot)) return activeRoot; - // active points at a missing/broken dir → clear it and use packaged - try { state.active = null; state.trying = null; hp.writeState(userData, state); } catch (_) {} - } - return PACKAGED_ROOT; -} - -function loadMain(root) { - // Mark which root won so main.js / updater can report the running JS version accurately. - try { process.env.CCBUD_APP_ROOT = root; } catch (_) {} - require(hp.mainEntry(root)); -} - -(function bootstrap() { - let userData; - try { userData = app.getPath('userData'); } catch (_) { userData = null; } - - const root = userData ? resolveRoot(userData) : PACKAGED_ROOT; - try { - loadMain(root); - } catch (e) { - // A staged bundle threw on load — quarantine it and fall back to the packaged shell so the - // app still starts. (If the packaged shell itself throws, there's nothing left to do.) - if (root !== PACKAGED_ROOT && userData) { - try { - const state = hp.readState(userData); - const bad = state.active; - state.active = state.previous || null; - state.previous = null; - state.trying = null; - hp.writeState(userData, state); - if (bad && bad.dir) { try { fs.rmSync(hp.bundleDir(userData, bad.dir), { recursive: true, force: true }); } catch (_) {} } - } catch (_) {} - try { console.error('[ccbud] hot bundle failed to load, falling back to packaged:', e && e.message); } catch (_) {} - loadMain(PACKAGED_ROOT); - } else { - throw e; - } - } -})(); diff --git a/src/main/claude.js b/src/main/claude.js deleted file mode 100644 index be2ef0f..0000000 --- a/src/main/claude.js +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; - -/** - * One-click integration with Claude Code's user settings (~/.claude/settings.json). - * - * Connect: point Claude Code at the local gateway by writing env.ANTHROPIC_BASE_URL / - * env.ANTHROPIC_AUTH_TOKEN, and CLEAR any model-name overrides so Claude Code - * sends its native claude-* names — the gateway then auto-maps them to whichever - * provider is active. The user's original values are backed up first. - * Disconnect: restore the exact prior state from the backup. - * - * The settings path is overridable via CCBUD_CLAUDE_SETTINGS (used by tests so the real - * user config is never touched). - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// Model-selection keys we clear while connected (so the gateway controls routing). -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_SMALL_FAST_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', -]; -const ALL_BACKUP_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', ...MODEL_ENV_KEYS]; - -function settingsPath() { - return process.env.CCBUD_CLAUDE_SETTINGS || path.join(os.homedir(), '.claude', 'settings.json'); -} - -function readSettings() { - try { - const raw = fs.readFileSync(settingsPath(), 'utf8'); - const obj = JSON.parse(raw); - return obj && typeof obj === 'object' ? obj : {}; - } catch (_) { - return {}; - } -} - -function writeSettings(obj) { - const p = settingsPath(); - fs.mkdirSync(path.dirname(p), { recursive: true }); - const tmp = p + '.ccbud.tmp'; - fs.writeFileSync(tmp, JSON.stringify(obj, null, 2)); - fs.renameSync(tmp, p); -} - -function endpoint(port) { - return `http://localhost:${port}`; -} - -function isGatewayUrl(url, port) { - if (!url) return false; - try { - const u = new URL(url); - const p = u.port || (u.protocol === 'https:' ? '443' : '80'); - return (u.hostname === 'localhost' || u.hostname === '127.0.0.1') && String(p) === String(port); - } catch (_) { - return false; - } -} - -function isConnected(port) { - const s = readSettings(); - return isGatewayUrl(s.env && s.env.ANTHROPIC_BASE_URL, port); -} - -/** Connect Claude Code to the gateway. `store` is the app config store (get/save). */ -function connect(port, token, store) { - const s = readSettings(); - s.env = s.env || {}; - - // Back up the original values exactly once (preserve across reconnects). - const cfg = store.get(); - if (!cfg.claudeBackup) { - const backup = { model: 'model' in s ? s.model : undefined, env: {} }; - for (const k of ALL_BACKUP_KEYS) backup.env[k] = k in s.env ? s.env[k] : undefined; - store.save(Object.assign({}, cfg, { claudeBackup: backup })); - } - - s.env.ANTHROPIC_BASE_URL = endpoint(port); - s.env.ANTHROPIC_AUTH_TOKEN = token; - for (const k of MODEL_ENV_KEYS) delete s.env[k]; - delete s.model; // let Claude Code send native claude-* names; the gateway maps them - writeSettings(s); -} - -/** Disconnect Claude Code: restore the backed-up state (or just remove our keys). */ -function disconnect(store) { - const s = readSettings(); - s.env = s.env || {}; - const cfg = store.get(); - const b = cfg.claudeBackup; - - if (b) { - const restore = (k) => { - if (b.env[k] === undefined) delete s.env[k]; - else s.env[k] = b.env[k]; - }; - for (const k of ALL_BACKUP_KEYS) restore(k); - if (b.model === undefined) delete s.model; - else s.model = b.model; - store.save(Object.assign({}, cfg, { claudeBackup: null })); - } else { - delete s.env.ANTHROPIC_BASE_URL; - delete s.env.ANTHROPIC_AUTH_TOKEN; - } - - if (s.env && Object.keys(s.env).length === 0) delete s.env; - writeSettings(s); -} - -module.exports = { settingsPath, readSettings, isConnected, connect, disconnect, endpoint, MODEL_ENV_KEYS }; diff --git a/src/main/claudeDesktop.js b/src/main/claudeDesktop.js deleted file mode 100644 index 4705f88..0000000 --- a/src/main/claudeDesktop.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict'; - -/** - * One-click integration with the Claude Desktop app's "Third-Party Inference". - * - * Unlike Claude Code (a plain ~/.claude/settings.json we write directly), Claude Desktop - * (bundle `com.anthropic.claudefordesktop`) reads its third-party-inference settings from macOS - * *Managed Preferences*, delivered as a Configuration Profile (.mobileconfig). So: - * - * connect(): generate a profile pre-filled with the local ccbud gateway, then hand it to macOS. - * `profiles` CLI "no longer supports installs", so the user approves it once in - * System Settings › Profiles (admin password). This is NOT a risky operation — it - * only changes where inference is sent; macOS just requires you to confirm it. - * disconnect(): `profiles remove -identifier …` still works, run via an admin prompt → ~one-click - * restore. Falls back to opening System Settings if that path is unavailable. - * - * Schema (extracted from Claude Desktop's own profile generator): - * PayloadType (inner) = com.anthropic.claudefordesktop, with managed keys: - * inferenceProvider="gateway", inferenceCredentialKind="static", - * inferenceGatewayBaseUrl, inferenceGatewayApiKey, inferenceGatewayAuthScheme="bearer". - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { exec, execFile, execFileSync } = require('child_process'); -const { CLAUDE_TIER_MODELS } = require('./claudeModels'); - -const BUNDLE_ID = 'com.anthropic.claudefordesktop'; -const PROFILE_IDENTIFIER = 'dev.ccbud.gateway.claude-desktop-inference'; -const PROFILES_PANE = 'x-apple.systempreferences:com.apple.preferences.configurationprofiles'; - -const isMac = () => process.platform === 'darwin'; -const endpoint = (port) => `http://localhost:${port || 8788}`; -const profilePath = () => - path.join(os.homedir(), '.ccbud', 'claude-desktop-inference.mobileconfig'); - -function appInstalled() { - if (!isMac()) return false; - const candidates = [ - '/Applications/Claude.app', - path.join(os.homedir(), 'Applications', 'Claude.app'), - path.join(os.homedir(), 'Library', 'Application Support', 'Claude'), - ]; - return candidates.some((p) => { try { return fs.existsSync(p); } catch (_) { return false; } }); -} - -// Stable UUIDs so re-generating the profile updates it in place rather than duplicating. -function uuidFrom(seed) { - const h = crypto.createHash('sha1').update(String(seed)).digest('hex'); - return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`.toUpperCase(); -} -const xmlEsc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); - -function buildProfile(port, token) { - // Claude Desktop's Gateway picker needs an explicit model list, stored as a SINGLE JSON string. - // Names carry Anthropic keywords so its client-side validation accepts them, and they match what - // ccbud returns from /v1/models; the gateway then tier-maps each onto the active provider. - const inferenceModels = JSON.stringify(CLAUDE_TIER_MODELS.map((m) => Object.assign( - { name: m.name, anthropicFamilyTier: m.tier }, m.familyDefault ? { isFamilyDefault: true } : {}, - ))); - const settings = { - inferenceProvider: 'gateway', - inferenceCredentialKind: 'static', - inferenceGatewayBaseUrl: endpoint(port), - inferenceGatewayApiKey: token || 'ccbud-local', - inferenceGatewayAuthScheme: 'bearer', - inferenceModels, - }; - const body = Object.entries(settings) - .map(([k, v]) => ` ${k}\n ${xmlEsc(v)}`).join('\n'); - return ` - - - - PayloadContent - - - PayloadType - ${BUNDLE_ID} - PayloadIdentifier - ${PROFILE_IDENTIFIER}.settings - PayloadUUID - ${uuidFrom(PROFILE_IDENTIFIER + '.settings')} - PayloadVersion - 1 - PayloadDisplayName - Claude Desktop Third-Party Inference (CC Buddy) -${body} - - - PayloadDisplayName - CC Buddy · Claude Desktop 第三方推理 - PayloadDescription - 将 Claude 桌面版的模型推理指向本地 CC Buddy 网关(${endpoint(port)})。可随时移除以还原为官方推理。 - PayloadIdentifier - ${PROFILE_IDENTIFIER} - PayloadOrganization - CC Buddy - PayloadRemovalDisallowed - - PayloadScope - User - PayloadType - Configuration - PayloadUUID - ${uuidFrom(PROFILE_IDENTIFIER)} - PayloadVersion - 1 - - -`; -} - -// Read the effective managed baseUrl Claude Desktop would use (mirrors how it reads managed prefs). -function managedBaseUrl() { - let user = ''; - try { user = os.userInfo().username; } catch (_) {} - const paths = [`/Library/Managed Preferences/${BUNDLE_ID}.plist`]; - if (user) paths.push(`/Library/Managed Preferences/${user}/${BUNDLE_ID}.plist`); - for (const p of paths) { - try { - if (!fs.existsSync(p)) continue; - const out = execFileSync('/usr/bin/plutil', ['-extract', 'inferenceGatewayBaseUrl', 'raw', '-o', '-', p], { - encoding: 'utf8', timeout: 4000, - }).trim(); - if (out) return out; - } catch (_) {} - } - return null; -} - -function status(port) { - return { - supported: isMac(), - installed: appInstalled(), - connected: isMac() && managedBaseUrl() === endpoint(port), - endpoint: endpoint(port), - }; -} - -// Write the profile and open it + the Profiles pane for the user to approve (one-time macOS step). -function connect(port, token) { - if (!isMac()) return { ok: false, reason: 'unsupported' }; - if (!appInstalled()) return { ok: false, reason: 'notInstalled' }; - const file = profilePath(); - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, buildProfile(port, token), 'utf8'); - } catch (e) { - return { ok: false, reason: 'write', message: e.message }; - } - exec(`/usr/bin/open ${JSON.stringify(file)}`, () => { - setTimeout(() => exec(`/usr/bin/open ${JSON.stringify(PROFILES_PANE)}`, () => {}), 1200); - }); - return { ok: true, needsApproval: true, path: file }; -} - -// Remove the profile via an admin prompt (~one-click); fall back to System Settings on failure. -function disconnect() { - if (!isMac()) return Promise.resolve({ ok: false, reason: 'unsupported' }); - return new Promise((resolve) => { - const osa = `do shell script "/usr/bin/profiles remove -identifier ${PROFILE_IDENTIFIER}" with administrator privileges`; - execFile('/usr/bin/osascript', ['-e', osa], (err, _stdout, stderr) => { - if (!err) { resolve({ ok: true, removed: true }); return; } - const msg = String((stderr || '') + (err && err.message ? err.message : '')); - if (/-128|User canceled/i.test(msg)) { resolve({ ok: false, cancelled: true }); return; } - // CLI removal unavailable → open System Settings so the user can remove it manually. - exec(`/usr/bin/open ${JSON.stringify(PROFILES_PANE)}`, () => {}); - resolve({ ok: true, removed: false, needsApproval: true }); - }); - }); -} - -module.exports = { - appInstalled, status, connect, disconnect, buildProfile, profilePath, - BUNDLE_ID, PROFILE_IDENTIFIER, -}; diff --git a/src/main/claudeModels.js b/src/main/claudeModels.js deleted file mode 100644 index e7719f9..0000000 --- a/src/main/claudeModels.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -/** - * Standard Claude tier model names ccbud advertises to clients. - * - * The gateway accepts any claude-* name and tier-maps it onto the active provider (see resolveRouting): - * opus/sonnet → the provider's main model, haiku → its small/fast model. Claude Desktop's "Gateway" - * mode needs an explicit model list (`inferenceModels`) whose names (a) pass its client-side validation - * that rejects names without an Anthropic keyword, and (b) match what the gateway returns from - * /v1/models. Exposing these three names in BOTH places lets a freshly-installed Claude Desktop pick a - * model and drive the gateway with zero per-user setup — the actual upstream is the user's provider. - * - * Version numbers are cosmetic here: ccbud never forwards these names to Anthropic; it routes by tier. - * Keep this list in sync with gateway.rs CLAUDE_TIER_MODELS. - */ -const CLAUDE_TIER_MODELS = [ - { name: 'claude-fable-5', tier: 'opus' }, - { name: 'claude-opus-4-8', tier: 'opus' }, - { name: 'claude-sonnet-5', tier: 'sonnet', familyDefault: true }, - { name: 'claude-haiku-4-5', tier: 'haiku' }, - { name: 'claude-haiku-4-5-20251001', tier: 'haiku' }, -]; - -module.exports = { CLAUDE_TIER_MODELS }; diff --git a/src/main/codex.js b/src/main/codex.js deleted file mode 100644 index a6c04a3..0000000 --- a/src/main/codex.js +++ /dev/null @@ -1,723 +0,0 @@ -'use strict'; - -/** - * Codex CLI session support — reads OpenAI Codex's on-disk rollout logs - * (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`) and normalizes them into the SAME - * session/message shape the renderer consumes for Claude Code history, so the 对话 view - * (list / detail / search / live-follow / export) browses both without renderer forks. - * JS twin of src-tauri/src/codex.rs — keep the two in lockstep. - * - * A rollout line is `{timestamp, type, payload}` with type ∈ {session_meta, turn_context, - * response_item, event_msg, compacted}. Conversation content lives in response_item payloads; - * event_msg mostly duplicates it; token_count carries usage, and user_message is a bounded title - * fallback when an image-heavy response_item is too large for the list view's head read. - * Very old Codex builds wrote payload objects directly per line (no envelope) — handled by - * treating such a line as its own payload. - * - * Tool calls map onto the tool vocabulary the renderer already draws natively: - * shell/exec_command/local_shell_call → Bash, update_plan → TodoWrite, view_image → Read, - * web_search → WebSearch, apply_patch → ApplyPatch (a codex-specific card). - * - * Title/tags/soft-delete: Codex files belong to another tool, so per-conversation - * customization never rewrites them — it lives in a sidecar map at ~/.ccbud/codex-meta.json - * (shared with the Tauri build), keyed by the rollout file stem. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -/** Codex's DEFAULT sessions tree (CODEX_HOME-aware, like the codex CLI). Only the auto-add - * migration keys off this — browsing walks `/sessions` of every configured work dir. */ -function sessionsRoot() { - if (process.env.CCBUD_CODEX_DIR) return process.env.CCBUD_CODEX_DIR; // test override - const ch = (process.env.CODEX_HOME || '').trim(); - return ch ? path.join(ch, 'sessions') : path.join(os.homedir(), '.codex', 'sessions'); -} -// The DEFAULT config dir as a history-dir entry string (`~/.codex`), used by the one-time -// startup migration that adds it to historyDirs. -function codexLabel() { - const dir = path.dirname(sessionsRoot()); - const home = os.homedir(); - if (dir === home) return '~'; - return dir.startsWith(home + path.sep) ? '~' + dir.slice(home.length) : dir; -} -function rootExists() { - try { return fs.statSync(sessionsRoot()).isDirectory(); } catch (_) { return false; } -} - -/** Walk every rollout .jsonl under a sessions tree (date-sharded, walked generically). */ -function walkSessions(root, cb) { - const walk = (dir, depth) => { - if (depth > 6) return; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } - for (const e of entries) { - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p, depth + 1); - else if (e.isFile() && e.name.endsWith('.jsonl')) cb(p); - } - }; - walk(root, 0); -} - -/** - * Format sniff on parsed records — routes files that LOOK like Codex rollouts (incl. copies - * imported into the app store). Claude Code records never use these type tags, and old-format - * bare Codex items lack Claude's `.message` wrapper. - */ -function looksCodex(recs) { - return (recs || []).slice(0, 8).some((r) => { - if (!r || typeof r !== 'object') return false; - switch (r.type) { - case 'session_meta': case 'turn_context': case 'event_msg': case 'compacted': return true; - case 'response_item': return r.payload !== undefined; - case 'message': case 'function_call': case 'function_call_output': - case 'reasoning': case 'local_shell_call': return r.message === undefined; // old envelope-less rollout - default: return r.record_type !== undefined; - } - }); -} - -/** (type, payload, timestamp) of a rollout line, tolerating the old envelope-less format. */ -function splitLine(rec) { - const ts = rec.timestamp || null; - const t = rec.type || ''; - if (rec.payload !== undefined) return { t, p: rec.payload || {}, ts }; - if (['message', 'function_call', 'function_call_output', 'reasoning', 'local_shell_call', - 'custom_tool_call', 'custom_tool_call_output', 'web_search_call'].includes(t)) { - return { t: 'response_item', p: rec, ts }; - } - if (!t && rec.id !== undefined && rec.timestamp !== undefined) return { t: 'session_meta', p: rec, ts }; - return { t, p: rec, ts }; -} - -// The FIRST SessionMeta belongs to this physical rollout. A forked/subagent rollout may copy -// ancestor SessionMeta records after it, so later ones must never replace the canonical thread -// identity. `session_id` is shared by the whole multi-agent tree; `id` is the unique thread key. -function canonicalThreadMeta(payload) { - payload = payload && typeof payload === 'object' ? payload : {}; - const source = payload.source && typeof payload.source === 'object' ? payload.source : {}; - const threadSource = payload.thread_source && typeof payload.thread_source === 'object' ? payload.thread_source : {}; - const subagent = source.subagent || source.sub_agent || threadSource.subagent || threadSource.sub_agent || null; - let detail = null; - if (subagent && typeof subagent === 'object') { - detail = subagent.thread_spawn || subagent.review || subagent.compact || subagent.other || null; - if (!detail || typeof detail !== 'object') { - detail = Object.values(subagent).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || null; - } - } - detail = detail && typeof detail === 'object' ? detail : {}; - // `id` is mandatory in a valid Codex SessionMeta. Never promote the tree-shared session_id to - // canonical status; malformed/legacy records can still fall back to the filename for display. - const threadId = payload.id || payload.thread_id || null; - const rootSessionId = payload.session_id || threadId; - const parentThreadId = payload.parent_thread_id || detail.parent_thread_id || null; - const threadSourceKind = typeof payload.thread_source === 'string' ? payload.thread_source.toLowerCase() : ''; - return { - threadId, - rootSessionId, - parentThreadId, - forkedFromId: payload.forked_from_id || null, - isSubagent: !!subagent || threadSourceKind === 'subagent' - || !!payload.agent_path || !!payload.agent_nickname - || (!!parentThreadId && threadId !== rootSessionId), - // Current Codex writes these canonical fields at SessionMeta top level. Older multi-agent - // rollouts kept them only inside source.subagent., so retain that as a fallback. - agentPath: payload.agent_path || detail.agent_path || null, - agentNickname: payload.agent_nickname || detail.agent_nickname || null, - agentRole: payload.agent_role || payload.agent_type || detail.agent_role || detail.agent_type || null, - agentDepth: Number.isFinite(detail.depth) ? detail.depth : null, - }; -} - -function subagentTitle(n) { - if (!n || !n.isSubagent) return ''; - const pathLabel = String(n.agentPath || '').replace(/^\/?root\/?/, ''); - return [n.agentNickname, pathLabel].filter(Boolean).join(' · ') || 'Codex subagent'; -} - -function isCanonicalThreadId(value) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '')); -} - -// Harness-injected user turns (environment/permissions/instructions wrappers) that aren't -// human prose — hidden from the timeline, exactly like Claude's isMeta records. -function isMetaUserText(t) { - t = String(t || '').replace(/^\s+/, ''); - return ['', '', ' t.startsWith(p)); -} - -// Codex injects the workspace AGENTS instructions as a user-role transport message. Keep it in -// the transcript (the renderer turns it into readable Markdown), but mark it as metadata so it -// never becomes the conversation title or a user navigation point. -function isAgentsBootstrap(t) { - const source = String(t || '').trimStart(); - return /^#\s+AGENTS\.md instructions for [^\r\n]+/i.test(source) - && /]*>[\s\S]*?<\/INSTRUCTIONS>/i.test(source); -} - -// Codex records a loaded Skill as a synthetic user turn. Preserve the embedded snapshot instead -// of re-reading SKILL.md: it is the exact version that influenced this historical turn. Only an -// entire, well-formed envelope is recognized so quoted markup remains ordinary prose. -function skillLoadBlock(t) { - const source = String(t || ''); - const match = /^\s*\s*([\s\S]*?)<\/name>\s*([\s\S]*?)<\/path>([\s\S]*)<\/skill>\s*$/i.exec(source); - if (!match) return null; - const name = match[1].trim(); - const path = match[2].trim(); - if (!name || !path) return null; - return { type: 'skill_load', name, path, snapshot: match[3] }; -} - -function joinedText(content, kinds) { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return content - .filter((b) => b && kinds.includes(b.type)) - .map((b) => b.text || '') - .join('\n'); -} - -// A Codex image prompt is serialized as three transport blocks around the actual prose: -// , input_image, -// Surface only the safe/readable name while preserving the real input_image block separately. -function imageTransportLabel(text) { - const source = String(text || '').trim(); - if (!/^]*>$/i.test(source)) return null; - const match = /\bname\s*=\s*(?:["']([^"']+)["']|(\[[^\]]+\])|([^\s>]+))/i.exec(source); - return match ? (match[1] || match[2] || match[3] || '').trim() : '[Image]'; -} - -function joinedUserText(content) { - if (!Array.isArray(content)) return typeof content === 'string' ? content : ''; - const hasImage = content.some((b) => b && b.type === 'input_image'); - return content - .filter((b) => b && (b.type === 'input_text' || b.type === 'text')) - .map((b) => { - const text = b.text || ''; - if (!hasImage) return text; - if (/^\s*<\/image>\s*$/i.test(text)) return ''; - const label = imageTransportLabel(text); - return label == null ? text : label; - }) - .filter(Boolean) - .join('\n'); -} - -function eventUserDisplayText(payload) { - const message = String((payload && payload.message) || '').trim(); - const imageCount = (Array.isArray(payload && payload.images) ? payload.images.length : 0) - + (Array.isArray(payload && payload.local_images) ? payload.local_images.length : 0); - const labels = []; - for (let i = 0; i < imageCount; i++) labels.push('[Image #' + (i + 1) + ']'); - return (labels.join(' ') + (labels.length && message ? ' ' : '') + message).trim(); -} - -function eventUserTitleFromRecord(rec) { - if (!rec || typeof rec !== 'object') return ''; - const { t, p } = splitLine(rec); - if (t !== 'event_msg' || !p || p.type !== 'user_message') return ''; - const text = eventUserDisplayText(p); - return text ? firstUserText([{ role: 'user', content: [{ type: 'text', text }] }]) : ''; -} - -function firstEventUserTitle(recs) { - for (const rec of recs || []) { - const title = eventUserTitleFromRecord(rec); - if (title) return title; - } - return ''; -} - -// List rows normally shape only the first 128 KiB. An image-first response_item can be one much -// larger JSON line, so the bounded read drops it and everything after it. If no title was found, -// scan forward without retaining oversized lines and use Codex's following user_message event. -function scanEventUserTitle(file) { - const CHUNK = 64 * 1024; - const MAX_SCAN = 64 * 1024 * 1024; - const MAX_LINE = 256 * 1024; - let fd = null; - try { - fd = fs.openSync(file, 'r'); - const buf = Buffer.allocUnsafe(CHUNK); - let scanned = 0; - let parts = []; - let lineBytes = 0; - let dropping = false; - - const append = (piece) => { - if (dropping || !piece.length) return; - if (lineBytes + piece.length > MAX_LINE) { - dropping = true; - parts = []; - lineBytes = 0; - return; - } - parts.push(Buffer.from(piece)); - lineBytes += piece.length; - }; - const finishLine = () => { - let title = ''; - if (!dropping && lineBytes) { - try { title = eventUserTitleFromRecord(JSON.parse(Buffer.concat(parts, lineBytes).toString('utf8').trim())); } catch (_) {} - } - parts = []; - lineBytes = 0; - dropping = false; - return title; - }; - - while (scanned < MAX_SCAN) { - const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, MAX_SCAN - scanned), null); - if (!n) break; - scanned += n; - let start = 0; - for (let i = 0; i < n; i++) { - if (buf[i] !== 10) continue; - append(buf.subarray(start, i)); - const title = finishLine(); - if (title) return title; - start = i + 1; - } - append(buf.subarray(start, n)); - } - return finishLine(); - } catch (_) { - return ''; - } finally { - if (fd != null) { try { fs.closeSync(fd); } catch (_) {} } - } -} - -/** argv → display command: unwrap the ["bash","-lc", script] convention, else shell-ish join. */ -function joinArgv(cmd) { - if (typeof cmd === 'string') return cmd; - if (!Array.isArray(cmd)) return ''; - const parts = cmd.map((x) => (typeof x === 'string' ? x : String(x == null ? '' : x))); - if (parts.length === 3 && ['bash', 'sh', 'zsh', 'dash'].includes(parts[0]) && ['-lc', '-c'].includes(parts[1])) { - return parts[2]; - } - return parts.map((p) => (!p || /[\s"']/.test(p) ? JSON.stringify(p) : p)).join(' '); -} - -/** Codex tool name + parsed arguments → [renderer tool name, renderer input]. */ -function mapTool(name, args) { - args = args && typeof args === 'object' ? args : {}; - const s = (k) => (typeof args[k] === 'string' ? args[k] : ''); - switch (name) { - case 'shell': case 'local_shell': case 'container.exec': { - const input = { command: joinArgv(args.command) }; - const desc = s('justification') || s('workdir'); - if (desc) input.description = desc; - return ['Bash', input]; - } - case 'shell_command': return ['Bash', { command: s('command') }]; - case 'exec_command': return ['Bash', { command: s('cmd') || s('command') }]; - case 'apply_patch': return ['ApplyPatch', { patch: s('input') || s('patch') }]; - case 'update_plan': - return ['TodoWrite', { - todos: (Array.isArray(args.plan) ? args.plan : []).map((st) => ({ - content: (st && st.step) || '', - status: (st && st.status) || 'pending', - })), - }]; - case 'view_image': return ['Read', { file_path: s('path') }]; - case 'web_search': return ['WebSearch', { query: s('query') }]; - default: return [name, args]; - } -} - -/** - * Tool output payload → { text, err }. Unwraps codex's JSON-wrapped shell output - * ({"output","metadata":{exit_code}}) and reads exec_command's "exited with code N" header. - */ -function shapeOutput(out) { - if (out && typeof out === 'object') { - const text = typeof out.content === 'string' ? out.content : JSON.stringify(out, null, 2); - return { text, err: out.success === false }; - } - const s = typeof out === 'string' ? out : ''; - try { - const v = JSON.parse(s); - if (v && typeof v === 'object') { - if (typeof v.output === 'string') { - const code = (v.metadata && typeof v.metadata.exit_code === 'number') ? v.metadata.exit_code : 0; - return { text: v.output, err: code !== 0 }; - } - if (typeof v.content === 'string') return { text: v.content, err: v.success === false }; - } - } catch (_) {} - const m = s.slice(0, 240).match(/exited with code (\d+)/); - if (m) return { text: s, err: m[1] !== '0' }; - return { text: s, err: false }; -} - -/** data-URL input_image → Claude-style image source block, else null. */ -function imageBlock(url) { - const m = /^data:([^;]+);base64,(.*)$/s.exec(String(url || '')); - if (!m) return null; - return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } }; -} - -/** Normalize parsed rollout records into the renderer's message model. */ -function normalize(recs) { - const messages = []; - const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; - let model = null, cwd = null, sessionId = null, threadId = null, parentThreadId = null; - let forkedFromId = null, isSubagent = false, agentPath = null, agentNickname = null; - let agentRole = null, agentDepth = null, gitBranch = null, version = null, sawSessionMeta = false; - - for (const rec of recs || []) { - if (!rec || typeof rec !== 'object') continue; - const { t, p, ts } = splitLine(rec); - const withTs = (m) => { if (ts) m.ts = ts; return m; }; - if (t === 'session_meta') { - if (!sawSessionMeta) { - sawSessionMeta = true; - const identity = canonicalThreadMeta(p); - threadId = identity.threadId; - sessionId = identity.rootSessionId; - parentThreadId = identity.parentThreadId; - forkedFromId = identity.forkedFromId; - isSubagent = identity.isSubagent; - agentPath = identity.agentPath; - agentNickname = identity.agentNickname; - agentRole = identity.agentRole; - agentDepth = identity.agentDepth; - } - if (!sessionId) sessionId = p.session_id || p.id || null; - if (!cwd) cwd = p.cwd || null; - if (!version) version = p.cli_version || null; - if (!gitBranch) gitBranch = (p.git && p.git.branch) || null; - } else if (t === 'turn_context') { - if (p.model) model = p.model; - if (!cwd) cwd = p.cwd || null; - } else if (t === 'compacted') { - const text = String(p.message || '').trim(); - if (text) messages.push(withTs({ role: 'user', content: [{ type: 'text', text }] })); - } else if (t === 'event_msg') { - if (p.type === 'token_count') { - const u = p.info && p.info.last_token_usage; - if (u) { - const input = u.input_tokens || 0, cached = u.cached_input_tokens || 0, output = u.output_tokens || 0; - if (input + cached + output > 0) { - const usage = { - inputTokens: Math.max(input - cached, 0), - outputTokens: output, - cacheRead: cached, - cacheCreation: 0, - }; - totals.in += usage.inputTokens; totals.out += output; totals.cacheRead += cached; totals.turns += 1; - // Per-turn usage rides the turn's last assistant message (one token_count per turn). - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'assistant' && !messages[i].usage) { messages[i].usage = usage; break; } - } - } - } - } else if (p.type === 'turn_aborted') { - messages.push(withTs({ role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] })); - } - } else if (t === 'response_item') { - const it = p.type; - if (it === 'message') { - const content = p.content; - if (p.role === 'assistant') { - const text = joinedText(content, ['output_text', 'text']); - if (text.trim()) { - const m = { role: 'assistant', content: [{ type: 'text', text }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } else if (p.role === 'user') { - const text = joinedUserText(content); - const skill = skillLoadBlock(text); - if (skill) { - messages.push(withTs({ role: 'user', _meta: true, content: [skill] })); - continue; - } - if (isMetaUserText(text)) continue; - const blocks = []; - if (text.trim()) blocks.push({ type: 'text', text }); - if (Array.isArray(content)) { - for (const b of content) { - if (b && b.type === 'input_image') { const img = imageBlock(b.image_url); if (img) blocks.push(img); } - } - } - if (blocks.length) { - const message = { role: 'user', content: blocks }; - if (isAgentsBootstrap(text)) message._meta = true; - messages.push(withTs(message)); - } - } // system / developer turns: harness plumbing, not conversation - } else if (it === 'reasoning') { - let txt = joinedText(p.summary, ['summary_text', 'text']); - const extra = joinedText(p.content, ['reasoning_text', 'text']); - if (extra.trim()) txt = txt.trim() ? txt + '\n\n' + extra : extra; - if (txt.trim()) { - const m = { role: 'assistant', content: [{ type: 'thinking', thinking: txt }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } else if (it === 'function_call') { - let args = {}; - if (typeof p.arguments === 'string') { try { args = JSON.parse(p.arguments); } catch (_) {} } - else if (p.arguments && typeof p.arguments === 'object') args = p.arguments; - const [tname, input] = mapTool(p.name || 'tool', args); - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: tname, input }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'local_shell_call') { - const cmd = (p.action && p.action.command) || null; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: 'Bash', input: { command: joinArgv(cmd) } }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'custom_tool_call') { - const inputS = typeof p.input === 'string' ? p.input : ''; - const [tname, input] = p.name === 'apply_patch' - ? ['ApplyPatch', { patch: inputS }] - : [p.name || 'tool', { input: inputS }]; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.call_id || p.id || '', name: tname, input }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } else if (it === 'function_call_output' || it === 'custom_tool_call_output') { - const { text, err } = shapeOutput(p.output); - const tr = { type: 'tool_result', tool_use_id: p.call_id || '', content: text }; - if (err) tr.is_error = true; - messages.push(withTs({ role: 'user', content: [tr] })); - } else if (it === 'web_search_call') { - const q = (p.action && p.action.query) || ''; - const m = { role: 'assistant', content: [{ type: 'tool_use', id: p.id || p.call_id || '', name: 'WebSearch', input: { query: q } }] }; - if (model) m.modelActual = model; - messages.push(withTs(m)); - } - } - } - - const firstTs = (messages.find((m) => m.ts) || {}).ts || null; - let lastTs = null; - for (let i = messages.length - 1; i >= 0; i--) if (messages[i].ts) { lastTs = messages[i].ts; break; } - return { - messages, totals, model, firstTs, lastTs, cwd, sessionId, threadId, parentThreadId, - forkedFromId, isSubagent, agentPath, agentNickname, agentRole, agentDepth, gitBranch, version, - }; -} - -/** (cwd, canonical threadId) from a Codex head — used to name an imported store copy. */ -function headIds(recs) { - for (const rec of recs || []) { - if (!rec || typeof rec !== 'object') continue; - const { t, p } = splitLine(rec); - // Every subagent in a tree shares session_id. The FIRST SessionMeta.id is the only safe - // import key; using session_id makes sibling rollouts overwrite/skip one another. - if (t === 'session_meta') return { - cwd: p.cwd || null, - sessionId: p.id || p.thread_id || null, - rootSessionId: p.session_id || p.id || null, - }; - } - return { cwd: null, sessionId: null, rootSessionId: null }; -} - -/* ---------- sidecar customization (~/.ccbud/codex-meta.json): { "": {title?, tagList?, delete?} } ---------- */ - -function ccbudHome() { return process.env.CCBUD_HOME || path.join(os.homedir(), '.ccbud'); } -function sidecarPath() { return path.join(ccbudHome(), 'codex-meta.json'); } - -let sidecarCache = null; // { mtime, map } -function sidecarMtime() { - try { return fs.statSync(sidecarPath()).mtimeMs; } catch (_) { return 0; } -} -function readSidecar() { - const mt = sidecarMtime(); - if (sidecarCache && sidecarCache.mtime === mt) return sidecarCache.map; - let map = {}; - try { - const v = JSON.parse(fs.readFileSync(sidecarPath(), 'utf8')); - if (v && typeof v === 'object' && !Array.isArray(v)) map = v; - } catch (_) {} - sidecarCache = { mtime: mt, map }; - return map; -} -function writeSidecar(map) { - try { - fs.mkdirSync(ccbudHome(), { recursive: true }); - const tmp = sidecarPath() + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(map, null, 2), 'utf8'); - fs.renameSync(tmp, sidecarPath()); - sidecarCache = { mtime: sidecarMtime(), map }; - return true; - } catch (_) { return false; } -} -function stemOf(file) { return path.basename(String(file || ''), '.jsonl'); } -function sidecarMeta(file) { - const c = readSidecar()[stemOf(file)]; - if (!c || typeof c !== 'object') return { title: null, tags: [], deleted: false }; - return { - title: typeof c.title === 'string' && c.title.trim() ? c.title.trim() : null, - tags: Array.isArray(c.tagList) ? c.tagList.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()) : [], - deleted: c.delete === true, - }; -} -function isDeleted(file) { return sidecarMeta(file).deleted; } - -/** - * setCcbud-equivalent for codex sessions: same patch semantics ({title?, tags?, delete?}), - * persisted to the sidecar instead of the rollout file (never mutate another tool's data). - */ -function setMeta(file, patch) { - patch = patch || {}; - const stem = stemOf(file); - if (!stem) return { ok: false, reason: 'empty' }; - const map = Object.assign({}, readSidecar()); - const next = Object.assign({}, map[stem] || {}); - if ('title' in patch) { const t = String(patch.title || '').trim(); if (t) next.title = t; else delete next.title; } - if ('tags' in patch) { - const arr = []; - for (const x of (patch.tags || [])) { const t = typeof x === 'string' ? x.trim() : ''; if (t && arr.indexOf(t) < 0) arr.push(t); } - if (arr.length) next.tagList = arr; else delete next.tagList; - } - if ('delete' in patch) { if (patch.delete) next.delete = true; else delete next.delete; } - if (Object.keys(next).length) map[stem] = next; else delete map[stem]; - return writeSidecar(map) ? { ok: true } : { ok: false, reason: 'write' }; -} - -/** Drop a session's sidecar entry (after its rollout file is deleted forever). */ -function removeMeta(file) { - const stem = stemOf(file); - const map = Object.assign({}, readSidecar()); - if (stem in map) { delete map[stem]; writeSidecar(map); } -} - -/* ---------- list/detail shapes (codex flavors of history.js sessionMeta / getSession) ---------- */ - -function firstUserText(messages) { return require('./history').firstUserText(messages); } -function baseName(p) { - if (!p) return null; - const parts = String(p).split('/').filter(Boolean); - return parts.length ? parts[parts.length - 1] : p; -} - -/** - * List-row meta from already-parsed head records. `dm` is the dir descriptor - * ({ id:'__codex__', … } for the live tree, the imported dir for store snapshots). - */ -// In-file __ccbud__ for imported codex COPIES (our own files; history.setCcbud writes it there). -// The Electron readCcbud carries no delete flag (recycle bin is Tauri-side), hence deleted:false. -function fileCcbud(recs) { - const cc = require('./history').readCcbud(recs); - return { title: cc.title, tags: cc.tags, deleted: false }; -} -// Imported snapshots are marked by their provenance sidecar; live rollouts have none. -function hasImportSidecar(file) { - try { return fs.statSync(String(file).replace(/\.jsonl$/, '.import.json')).isFile(); } catch (_) { return false; } -} - -function sessionMetaFrom(file, recs, dm, st) { - const n = normalize(recs); - // Live rollouts customize via the sidecar (never rewrite another tool's files); imported - // COPIES are our own files, where the in-file __ccbud__ applies. - const cc = hasImportSidecar(file) ? fileCcbud(recs) : sidecarMeta(file); - let transcriptTitle = firstUserText(n.messages) || firstEventUserTitle(recs); - if (!transcriptTitle && (!st || st.size > 131072)) transcriptTitle = scanEventUserTitle(file); - const autoTitle = subagentTitle(n) || transcriptTitle; - const stem = stemOf(file); - const rowScope = (dm && dm.id) || ''; - return { - id: 'codex:' + rowScope + ':' + stem, - file, - source: 'codex', - dirId: dm ? dm.id : null, - dirLabel: dm ? dm.label : null, - sessionId: n.threadId || n.sessionId || stem, - threadId: n.threadId || n.sessionId || stem, - canonicalThreadIdValid: isCanonicalThreadId(n.threadId), - rootSessionId: n.sessionId || n.threadId || stem, - parentThreadId: n.parentThreadId, - forkedFromId: n.forkedFromId, - cwd: n.cwd, - project: baseName(n.cwd), - gitBranch: n.gitBranch, - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - model: n.model, - isSubagent: n.isSubagent, - agentPath: n.agentPath, - agentNickname: n.agentNickname, - agentRole: n.agentRole, - agentDepth: n.agentDepth, - imported: !!(dm && dm.imported), - deleted: cc.deleted || false, - lastActivity: st ? st.mtimeMs : 0, - sizeKB: st ? Math.round(st.size / 1024) : 0, - }; -} - -/** Full-detail shape from already-parsed records (history.getSession routes here). */ -function sessionFromRecs(file, recs) { - const n = normalize(recs); - let imported = null; - try { imported = JSON.parse(fs.readFileSync(String(file).replace(/\.jsonl$/, '.import.json'), 'utf8')); } catch (_) {} - // Same sidecar-vs-in-file split as sessionMetaFrom. - const cc = imported ? fileCcbud(recs) : sidecarMeta(file); - const autoTitle = subagentTitle(n) || firstUserText(n.messages) || firstEventUserTitle(recs); - const stem = stemOf(file); - return { - meta: { - id: 'codex:' + stem, - file, - source: 'codex', - assistant: 'Codex', - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - summary: null, - sessionId: n.threadId || n.sessionId || stem, - threadId: n.threadId || n.sessionId || stem, - canonicalThreadIdValid: isCanonicalThreadId(n.threadId), - rootSessionId: n.sessionId || n.threadId || stem, - parentThreadId: n.parentThreadId, - forkedFromId: n.forkedFromId, - cwd: n.cwd, - project: baseName(n.cwd), - gitBranch: n.gitBranch, - version: n.version, - isSubagent: n.isSubagent, - agentPath: n.agentPath, - agentNickname: n.agentNickname, - agentRole: n.agentRole, - agentDepth: n.agentDepth, - deleted: cc.deleted || false, - imported: !!imported, - importedFrom: imported ? imported.originalPath : null, - importedAt: imported ? imported.importedAt : null, - model: n.model, - totals: n.totals, - messages: n.messages.length, - subagentCount: 0, - firstTs: n.firstTs, - lastTs: n.lastTs, - }, - messages: n.messages, - subagents: {}, - }; -} - -module.exports = { - codexLabel, - sessionsRoot, - rootExists, - walkSessions, - hasImportSidecar, - looksCodex, - normalize, - headIds, - sidecarMeta, - isDeleted, - setMeta, - removeMeta, - sessionMetaFrom, - sessionFromRecs, -}; diff --git a/src/main/countTokens.js b/src/main/countTokens.js deleted file mode 100644 index 6086bd5..0000000 --- a/src/main/countTokens.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -/** - * Local token estimator for the `POST /v1/messages/count_tokens` fallback. - * - * Claude Code calls count_tokens BEFORE sending, to size the context (when to - * auto-compact, etc.). Many Anthropic-compatible providers don't implement the - * endpoint and answer 404, which breaks that accounting. When the gateway can't get - * a real count it estimates one here. - * - * Approach (see research notes): o200k_base (the closest publicly-available tokenizer - * to Claude 3/4 — the official @anthropic-ai/tokenizer is a stale Claude-2 vocab that - * over-counts CJK) for the text, plus a calibrated STRUCTURAL overhead that count_tokens - * adds for message framing / system / tools. We deliberately round UP a little: a slight - * over-count just makes Claude Code compact a touch early, whereas under-counting could - * let a request overflow the real upstream limit. - */ - -let _enc = null; // null = not yet tried, false = unavailable, else a Tiktoken instance -function encoder() { - if (_enc === null) { - try { - const { Tiktoken } = require('js-tiktoken/lite'); - const rank = require('js-tiktoken/ranks/o200k_base'); - _enc = new Tiktoken(rank && rank.default ? rank.default : rank); - } catch (_) { - _enc = false; // fall back to a char heuristic below - } - } - return _enc || null; -} - -function safeJson(v) { - try { return v == null ? '' : JSON.stringify(v); } catch (_) { return ''; } -} - -// Calibrated against the real count_tokens endpoint. o200k text + these overheads land -// a touch above the true count across single/multi-message, +system and +tools requests. -const BASE = 5; // per-request framing (BOS / wrapper) -const PER_MSG = 4; // per-message wrapper (role + delimiters) -const SYS = 4; // system-prompt framing -const TOOLS = 15; // fixed tools→system injection framing (NOT per-tool) -const IMAGE = 1600; // images are size-priced; flat conservative estimate (rarely hit here) -const SAFETY = 1.06; // round a little high, never under-count - -/** - * Estimate the input_tokens for an Anthropic Messages request body. - * Mirrors what count_tokens charges: system + every message's text/tool_use/tool_result - * + the tool definitions, plus structural overhead. - */ -function estimateInputTokens(body) { - const enc = encoder(); - const count = enc - ? (s) => (s ? enc.encode(String(s)).length : 0) - : (s) => (s ? Math.ceil(String(s).length / 4) : 0); // crude fallback if tokenizer missing - - body = body || {}; - let t = 0; - - const sys = body.system; - if (typeof sys === 'string') t += count(sys); - else if (Array.isArray(sys)) for (const b of sys) if (b && b.type === 'text') t += count(b.text); - - const msgs = Array.isArray(body.messages) ? body.messages : []; - for (const m of msgs) { - const c = m && m.content; - if (typeof c === 'string') { t += count(c); continue; } - if (!Array.isArray(c)) continue; - for (const b of c) { - if (!b || typeof b !== 'object') continue; - if (b.type === 'text') t += count(b.text); - else if (b.type === 'tool_use') t += count(b.name) + count(safeJson(b.input)); - else if (b.type === 'tool_result') { - if (typeof b.content === 'string') t += count(b.content); - else if (Array.isArray(b.content)) for (const x of b.content) if (x && x.type === 'text') t += count(x.text); - } else if (b.type === 'image') t += IMAGE; - } - } - - const tools = Array.isArray(body.tools) ? body.tools : []; - for (const tool of tools) { - if (!tool) continue; - t += count(tool.name) + count(tool.description) + count(safeJson(tool.input_schema)); - } - - const overhead = BASE + PER_MSG * msgs.length + (sys ? SYS : 0) + (tools.length ? TOOLS : 0); - return Math.max(1, Math.ceil((t + overhead) * SAFETY)); -} - -// Is the local tokenizer actually loaded (vs the crude char fallback)? For diagnostics/tests. -function tokenizerReady() { - return encoder() != null; -} - -module.exports = { estimateInputTokens, tokenizerReady }; diff --git a/src/main/export-assets/runtime-analytics.js b/src/main/export-assets/runtime-analytics.js new file mode 100644 index 0000000..8fcff38 --- /dev/null +++ b/src/main/export-assets/runtime-analytics.js @@ -0,0 +1,61 @@ +/* ccbud export viewer runtime. Renders window.__CONV__ into a Claude-styled, themeable, + searchable single-file app with a sidebar outline and expandable tools/subagents. */ + +/* ---- usage analytics (Microsoft Clarity) ---- + Runs first (inside the generator's nonce'd script block) so viewer-runtime errors are + captured too; the injected tag is allowed by the export CSP's clarity.ms origins (see + exporthtml.rs / exportHtml.js). Offline viewers just queue into the stub and send + nothing. Only element identifiers ever become event names — message text, paths and + titles are never sent. */ +(function () { + try { + var PROJECT_ID = 'xij8wflxsj'; + window.clarity = window.clarity || function () { (window.clarity.q = window.clarity.q || []).push(arguments); }; + if (!document.getElementById('clarity-script')) { + var s = document.createElement('script'); + s.async = true; s.id = 'clarity-script'; + s.src = 'https://www.clarity.ms/tag/' + PROJECT_ID; + (document.head || document.documentElement).appendChild(s); + } + var track = function (n) { try { window.clarity('event', String(n).slice(0, 250)); } catch (e) {} }; + var tag = function (k, v) { try { if (v != null && v !== '') window.clarity('set', k, String(v).slice(0, 250)); } catch (e) {} }; + var meta = (window.__CONV__ && window.__CONV__.meta) || {}; + tag('surface', 'export'); + tag('assistant', meta.assistant || 'Claude'); + tag('appVersion', window.__CCBUD_VERSION__); // version of the app that generated this export + track('export:open'); + var name = function (el) { + for (var n = el, d = 0; n && n.nodeType === 1 && d < 15; n = n.parentElement, d++) { + if (n.id) return /^m\d+$/.test(n.id) ? '#msg' : '#' + n.id; + var cls = typeof n.className === 'string' ? n.className.trim().split(/\s+/)[0] : ''; + if (cls) return n.tagName.toLowerCase() + '.' + cls; + } + return el && el.nodeType === 1 ? el.tagName.toLowerCase() : 'unknown'; + }; + document.addEventListener('click', function (e) { + if (e.target && e.target.nodeType === 1) track('click:' + name(e.target)); + }, true); + var searched = false; + document.addEventListener('input', function (e) { + if (!searched && e.target && e.target.id === 'q') { searched = true; track('export:search'); } + }, true); + // Error messages can embed local paths or URLs — redact those before tagging. + var scrubError = function (s) { + return String(s == null ? 'unknown' : s) + .replace(/(?:file|https?):\/\/[^\s'")]+/gi, '') + .replace(/(^|[\s'"(=:,])(?:~\/|\/)[^\s'")]+/g, '$1') + .replace(/[A-Za-z]:\\[^\s'")]+/g, '') + .slice(0, 120); + }; + window.addEventListener('error', function (e) { + if (e && e.target && e.target !== window && e.target.nodeType === 1) return; + track('error:js'); tag('lastError', scrubError(e && e.message)); + try { window.clarity('upgrade', 'js-error'); } catch (err) {} + }, true); + window.addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + track('error:unhandled-rejection'); tag('lastError', scrubError(r && r.message ? r.message : r)); + }); + } catch (e) {} +})(); + diff --git a/src/main/export-assets/runtime-messages.js b/src/main/export-assets/runtime-messages.js new file mode 100644 index 0000000..1c3ed53 --- /dev/null +++ b/src/main/export-assets/runtime-messages.js @@ -0,0 +1,168 @@ +/* ccbud export viewer runtime — part 3/4: message/thread rendering (Codex bootstrap folding, + injected-block stripping, blocks, turn meta, thread + meta line). Continues the render + IIFE opened in runtime-render.js. */ + function formatCodexBootstrap(text) { + var source = String(text || ''); + var agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); + if (!agents) return null; + + var env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); + var parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; + var instructions = agents[2].trim(); + if (instructions) { + var lines = instructions.split(/\r?\n/).filter(function (line) { return line.trim(); }); + parts.push(lines.length === 1 + ? '**INSTRUCTIONS:** ' + lines[0].trim() + : '**INSTRUCTIONS:**\n\n' + instructions); + } + + if (env) { + var block = env[1]; + var tag = function (name) { + var match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); + return match ? match[1].trim() : ''; + }; + var attr = function (name, attribute) { + var match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); + return match ? match[1].trim() : ''; + }; + var code = function (value) { + var tick = String.fromCharCode(96); + return value ? tick + value + tick : ''; + }; + var roots = []; + var rootRe = /]*>([\s\S]*?)<\/root>/gi; + var root; + while ((root = rootRe.exec(block)) !== null) { + if (root[1].trim()) roots.push(code(root[1].trim())); + } + var fields = [ + ['environment_context', code(tag('cwd'))], + ['shell', tag('shell')], + ['current_date', tag('current_date')], + ['timezone', tag('timezone')], + ['workspace_roots', roots.join(', ')], + ['permission_profile', attr('permission_profile', 'type')], + ['file_system', attr('file_system', 'type')], + ].filter(function (field) { return field[1]; }); + if (fields.length) { + parts.push(fields.map(function (field) { return '**' + field[0] + ':** ' + field[1]; }).join(' \n')); + } + } + + var rest = source.replace(agents[0], ''); + if (env) rest = rest.replace(env[0], ''); + rest = rest.trim(); + if (rest) parts.push(rest); + return parts.join('\n\n').trim(); + } + function stripInjected(text) { + var source = String(text || ''); + var bootstrap = formatCodexBootstrap(source); + if (bootstrap != null) source = bootstrap; + if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; + return source + .replace(/]*>[\s\S]*?<\/task-notification>/gi, function (block) { + var result = /]*>([\s\S]*?)<\/result>/i.exec(block); + return result ? '\n' + result[1].trim() + '\n' : ''; + }) + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') + .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') + .trim(); + } + function renderBlocks(content, cleanUserText) { + var blocks = Array.isArray(content) ? content : (typeof content === 'string' ? [{ type: 'text', text: content }] : []); + var out = ''; + blocks.forEach(function (b) { + if (!b) return; + if (b.type === 'text') { var text = cleanUserText ? stripInjected(b.text) : b.text; if (text && text.trim()) out += '
' + md(text) + '
'; } + else if (b.type === 'thinking') { if (b.thinking && b.thinking.trim()) { var first = b.thinking.split('\n').filter(function (x) { return x.trim(); })[0] || ''; out += '
💭 思考 · ' + esc(trunc(first, 64)) + '
' + md(b.thinking) + '
'; } } + else if (b.type === 'skill_load') out += renderSkillLoad(b); + else if (b.type === 'tool_use') out += renderTool(b); + else if (b.type === 'image') { var s = b.source || {}; out += s.data ? '' : '
🖼 image' + (s.oversized ? ' (large, omitted)' : '') + '
'; } + }); + return out; + } + function turnMeta(m) { + var bits = []; if (m.model) bits.push(esc(m.model)); + if (m.usage) { + var tokenTotal = (m.usage.in || 0) + (m.usage.out || 0) + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); + if (m.usage.credits == null || tokenTotal > 0) bits.push(fmtTok(m.usage.in) + '↑ ' + fmtTok(m.usage.out) + '↓'); + if (m.usage.credits != null) bits.push(fmtCredits(m.usage.credits) + ' Credits'); + } + if (m.usage && m.usage.cacheRead) bits.push(fmtTok(m.usage.cacheRead) + ' cache'); + return bits.length ? '
' + bits.map(function (b) { return '' + b + ''; }).join('') + '
' : ''; + } + function msgVisible(m) { + var bl = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); + if (m.role === 'user') { var vis = bl.filter(function (b) { return b && (b.type === 'text' || b.type === 'image'); }); if (!vis.length) return false; var hasImage = vis.some(function (b) { return b.type === 'image'; }); var txt = vis.map(function (b) { return b.type === 'text' ? stripInjected(b.text) : ''; }).filter(Boolean).join('\n'); return hasImage || !!txt; } + return bl.some(function (b) { return b && (b.type === 'text' || b.type === 'thinking' || b.type === 'tool_use' || b.type === 'image'); }); + } + // tags for sidebar/filtering + function msgTags(m) { + var t = { tool: 0, sub: 0 }; + (Array.isArray(m.content) ? m.content : []).forEach(function (b) { if (b && b.type === 'tool_use') { t.tool++; if ((D.subagents || {})[b.id]) t.sub++; } }); + return t; + } + function renderThread(msgs) { + var out = ''; + (msgs || []).forEach(function (m) { + var blocks = Array.isArray(m.content) ? m.content : (typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : []); + var skillBlocks = blocks.filter(function (b) { return b && b.type === 'skill_load'; }); + if (skillBlocks.length) out += '
' + skillBlocks.map(renderSkillLoad).join('') + '
'; + var regularBlocks = blocks.filter(function (b) { return !b || b.type !== 'skill_load'; }); + var regular = Object.assign({}, m, { content: regularBlocks }); + if (!msgVisible(regular)) return; + var body = renderBlocks(regularBlocks, m.role === 'user'); if (!body) return; + if (m.role === 'user') out += '
👤 你
' + body + '
'; + else { var tg = msgTags(m); out += '
' + esc(AST) + '
' + body + turnMeta(m) + '
'; } + }); + return out; + } + + // ===== build shell ===== + var meta = D.meta || {}; + var AST = meta.assistant || 'Claude'; // assistant display name (Codex rollouts export with "Codex") + function metaLine() { + var p = []; + if (meta.model) p.push('' + esc(meta.model) + ''); + if (meta.project) p.push(esc(meta.project)); + if (meta.turns) p.push(meta.turns + ' 轮'); + if (meta.inTok != null && meta.tokenUsageAvailable !== false) p.push(fmtTok(meta.inTok) + '↑ ' + fmtTok(meta.outTok) + '↓'); + if (meta.credits != null) p.push(fmtCredits(meta.credits) + ' Credits'); + if (meta.cacheTok) p.push(fmtTok(meta.cacheTok) + ' 缓存'); + if (meta.subagentCount) p.push(meta.subagentCount + ' 子代理'); + return p.join(' · '); + } + var threadHtml = renderThread(D.messages) || '
空对话
'; + var orphanKeys = Object.keys(D.subagents || {}).filter(function (k) { return !USED[k]; }); + var orphanHtml = orphanKeys.length + ? '
🤖其他子代理 (' + orphanKeys.length + ')
下列子代理未在主时间线中找到明确的调用点(可能由工作流派生或调用记录已省略),单独列出以便查看:
' + orphanKeys.map(function (k) { return renderSubagent(D.subagents[k]); }).join('') + '
' + : ''; + var app = document.getElementById('app'); + app.innerHTML = + '
' + + '' + + '

' + esc(meta.title || '对话') + '

' + metaLine() + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + threadHtml + orphanHtml + '
' + + '
' + + '
'; + + var content = app.querySelector('.content'); + var thread = document.getElementById('thread'); + diff --git a/src/main/export-assets/runtime-render.js b/src/main/export-assets/runtime-render.js new file mode 100644 index 0000000..24775e2 --- /dev/null +++ b/src/main/export-assets/runtime-render.js @@ -0,0 +1,101 @@ +/* ccbud export viewer runtime — part 2/4: shared helpers, tool cards and subagent blocks. + The four runtime-*.js parts are concatenated VERBATIM (in name order, see exporthtml.rs) + into one ' - + '' - + '' - + '' - + ''; -} - -/** Convenience: parse a session file and build its standalone HTML in one call. */ -function buildExportHtml(file) { return htmlFromData(buildData(file)); } - -module.exports = { buildExportHtml, buildData, htmlFromData }; diff --git a/src/main/history.js b/src/main/history.js deleted file mode 100644 index e6b1900..0000000 --- a/src/main/history.js +++ /dev/null @@ -1,763 +0,0 @@ -'use strict'; - -/** - * Reads Claude Code's on-disk session history across ONE OR MORE config directories - * (each a `/projects//.jsonl` tree). The default config dir is - * ~/.claude, but Claude Code can run against others (CLAUDE_CONFIG_DIR / --config); the user - * registers those in settings and ccbud aggregates / switches between them. - * - * - getDirs() supplies [{ id, label, projectsDir }]; the watcher watches ALL of them, while - * listSessions/listProjects can be filtered to one (the directory switcher) or 'all'. - * - BROWSE: listProjects()/getSession() — project→session tree + rich message model. - * - WATCH: fs.watch each projects dir, emit 'changed' { files } on growth so the renderer - * live-follows; 'correlate' for each new assistant line (tests / future use). - * - * Test override: CCBUD_HISTORY_DIR points the default single dir at a temp projects tree. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { EventEmitter } = require('events'); -const codex = require('./codex'); - -function defaultDirs() { - const root = process.env.CCBUD_HISTORY_DIR || path.join(os.homedir(), '.claude', 'projects'); - return [{ id: 'default', label: '~/.claude', projectsDir: root }]; -} - -/** Best-effort decode of an encoded project dir name → cwd (lossy fallback; record cwd wins). */ -function decodeDirName(name) { - if (!name) return null; - return '/' + String(name).replace(/^-+/, '').replace(/-/g, '/'); -} - -function baseName(p) { - if (!p) return null; - const parts = String(p).split('/').filter(Boolean); - return parts.length ? parts[parts.length - 1] : p; -} - -function usageOf(u) { - if (!u) return null; - const usage = { - inputTokens: u.input_tokens || 0, - outputTokens: u.output_tokens || 0, - cacheRead: u.cache_read_input_tokens || 0, - cacheCreation: u.cache_creation_input_tokens || 0, - }; - // Qoder records billing credits even when its transcript exposes no token counts. Keep these - // fields optional so the normalized shape for Claude Code sessions remains unchanged. - if (typeof u.credits === 'number' && Number.isFinite(u.credits)) usage.credits = u.credits; - if (typeof u.original_credits === 'number' && Number.isFinite(u.original_credits)) usage.originalCredits = u.original_credits; - if (typeof u.context_usage_ratio === 'number' && Number.isFinite(u.context_usage_ratio)) usage.contextUsageRatio = u.context_usage_ratio; - return usage; -} - -function lineToMessage(rec) { - if (!rec || (rec.type !== 'user' && rec.type !== 'assistant') || !rec.message) return null; - const m = rec.message; - if (!m.role) return null; - const out = { - role: m.role, - content: m.content, - _id: m.id || null, - _ts: rec.timestamp || null, - _uuid: rec.uuid || null, - _parent: rec.parentUuid || null, - _sidechain: !!rec.isSidechain, - _meta: !!rec.isMeta, - }; - if (rec.type === 'assistant') { - out._model = m.model || null; - out._usage = usageOf(m.usage); - out._stopReason = m.stop_reason || null; - } - return out; -} - -function contentText(content) { - if (typeof content === 'string') return content; - if (Array.isArray(content)) return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' '); - return ''; -} - -// A slash-command turn is stored as XML tags, e.g. -// /model fable-5 -// Surface it as a readable "/model fable-5" label instead of leaking the raw tags. -function commandLabel(raw) { - const name = (raw.match(/([^<]*)<\/command-name>/) || [])[1]; - if (!name) return ''; - const args = (raw.match(/([^<]*)<\/command-args>/) || [])[1] || ''; - return (name.trim() + ' ' + args.trim()).trim(); -} - -function firstUserText(messages) { - let fallbackCmd = ''; // first slash-command label, used only if no prose turn exists - for (const m of messages) { - if (!m || m.role !== 'user' || m._meta) continue; - const raw = contentText(m.content).trim(); - if (!raw) continue; - if (raw.startsWith('<')) { if (!fallbackCmd) fallbackCmd = commandLabel(raw); continue; } - const t = raw.replace(/\s+/g, ' '); - if (/^(\[Request interrupted|Caveat:)/.test(t)) continue; - return t.slice(0, 90); - } - // No prose turn: prefer a parsed "/cmd" label over dumping raw XML; empty → renderer - // substitutes a localized "(conversation)". - return fallbackCmd.slice(0, 90); -} - -// Optional per-conversation customization the app writes onto a session line as `__ccbud__` -// (custom title + user tags). It's an extra field on a meta line — invisible to Claude Code itself. -// See setCcbud for the writer. Returns { title|null, tags[] }. -function readCcbud(recs) { - const r = recs.find((x) => x && typeof x === 'object' && x.__ccbud__); - const c = r ? r.__ccbud__ : null; - return { - title: c && typeof c.title === 'string' && c.title.trim() ? c.title.trim() : null, - tags: c && Array.isArray(c.tagList) - ? c.tagList.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim()) - : [], - }; -} - -function parseLines(buf) { - const out = []; - for (const line of buf.split('\n')) { - const s = line.trim(); - if (!s) continue; - try { out.push(JSON.parse(s)); } catch (_) {} - } - return out; -} - -function readChunk(file, size, max) { - let fd = null; - try { - fd = fs.openSync(file, 'r'); - const len = Math.min(size, max); - const chunks = []; - const first = Buffer.alloc(len); - const firstRead = fs.readSync(fd, first, 0, len, 0); - chunks.push(first.subarray(0, firstRead)); - let offset = firstRead; - const firstPrefix = first.subarray(0, Math.min(firstRead, 4096)).toString('utf8'); - const codexMetaMatch = /"type"\s*:\s*"session_meta"/.exec(firstPrefix); - const codexSessionMeta = !!codexMetaMatch && codexMetaMatch.index < 512; - // A Codex SessionMeta can itself exceed the ordinary list window (base instructions and - // dynamic tools live on that line). Complete that first record instead of handing the parser - // a truncated JSON object; this mirrors Codex's own line-oriented metadata reader. - // Only extend when the FIRST record itself exceeds the window. If the window already contains - // a newline, its final partial record can be ignored by parseLines; chasing that line could - // otherwise pull a multi-megabyte image/tool result into every list refresh. - while (codexSessionMeta && offset < size && first.subarray(0, firstRead).indexOf(10) < 0) { - const next = Buffer.alloc(Math.min(65536, size - offset)); - const n = fs.readSync(fd, next, 0, next.length, offset); - if (!n) break; - const part = next.subarray(0, n); - const newline = part.indexOf(10); - if (newline >= 0) { - chunks.push(part.subarray(0, newline + 1)); - break; - } - chunks.push(part); - offset += n; - } - return Buffer.concat(chunks).toString('utf8'); - } catch (_) { return ''; } - finally { if (fd != null) try { fs.closeSync(fd); } catch (_) {} } -} - -// Shape parsed records into the renderer's message model (+ rollup totals / model / span). Shared -// by getSession and the subagent reader so a subagent's timeline renders identically to the main one. -function shapeMessages(recs) { - const messages = []; - const totals = { in: 0, out: 0, cacheRead: 0, cacheCreation: 0, turns: 0 }; - let credits = 0, hasCredits = false; - let model = null, firstTs = null, lastTs = null; - for (const r of recs) { - const lm = lineToMessage(r); - if (!lm || lm._meta) continue; - if (lm._ts) { if (!firstTs) firstTs = lm._ts; lastTs = lm._ts; } - const msg = { role: lm.role, content: lm.content }; - if (lm._sidechain) msg.isSidechain = true; - if (lm._ts) msg.ts = lm._ts; - if (r.type === 'assistant') { - if (lm._model) { msg.modelActual = lm._model; model = lm._model; } - if (lm._usage) msg.usage = lm._usage; - if (lm._stopReason) msg.stopReason = lm._stopReason; - const u = lm._usage; - if (u) { - totals.in += u.inputTokens; totals.out += u.outputTokens; - totals.cacheRead += u.cacheRead; totals.cacheCreation += u.cacheCreation; - if (typeof u.credits === 'number' && Number.isFinite(u.credits)) { - credits += u.credits; - hasCredits = true; - } - totals.turns += 1; - } - } - messages.push(msg); - } - if (hasCredits) { - totals.credits = credits; - // Qoder currently writes real credit usage but zero for all four token counters. Expose that - // distinction explicitly so callers can render an unavailable value instead of a fake zero. - if (totals.in === 0 && totals.out === 0 && totals.cacheRead === 0 && totals.cacheCreation === 0) { - totals.tokenUsageAvailable = false; - } - } - return { messages, totals, model, firstTs, lastTs }; -} - -// A skill-forked subagent transcript opens with a sentinel user line -// "Base directory for this skill: /" — the last path segment names the skill. -// Fallback attribution only: the spawning `Skill` tool_use in the parent thread (applySkillNames) -// is authoritative and overrides this when present. -const SKILL_BASE_DIR_PREFIX = 'Base directory for this skill: '; -function skillFromRecs(recs) { - for (const r of recs) { - if (!r || r.type !== 'user' || !r.message || r.isMeta) continue; - const raw = contentText(r.message.content).trim(); - if (!raw.startsWith(SKILL_BASE_DIR_PREFIX)) return null; // only the opening prompt carries the sentinel - const line = raw.slice(SKILL_BASE_DIR_PREFIX.length).split('\n')[0].trim(); - const segs = line.split(/[\\/]/).filter(Boolean); - return segs.length ? segs[segs.length - 1] : null; - } - return null; -} - -// Primary skill attribution: a subagent spawned by the `Skill` tool is named by the spawning -// tool_use's input.skill (matched by tool_use id — the subagents map key), in whichever thread -// the call lives (main or a nested subagent). Overrides the sentinel fallback from skillFromRecs. -function applySkillNames(mainMessages, subs) { - const keys = Object.keys(subs); - if (!keys.length) return; - const scan = (msgs) => (msgs || []).forEach((m) => { - const blocks = m && Array.isArray(m.content) ? m.content : []; - for (const b of blocks) { - if (!b || b.type !== 'tool_use' || b.name !== 'Skill' || !subs[b.id]) continue; - const s = b.input && typeof b.input.skill === 'string' ? b.input.skill.trim() : ''; - if (s) subs[b.id].skill = s; - } - }); - scan(mainMessages); - for (const k of keys) scan(subs[k].messages); -} - -// Read a session's subagent dialogues — //subagents/agent-.{jsonl,meta.json} -// — keyed by the spawning Task/Agent tool_use id (agent-.meta.json's toolUseId), so the "对话" -// view can nest each subagent's timeline under the call that spawned it. Mirrors the HTML export. -// Returns {} when the session has no subagents directory. -function readSubagents(file) { - const dir = path.join(path.dirname(file), path.basename(file, '.jsonl'), 'subagents'); - let entries; - try { entries = fs.readdirSync(dir); } catch (_) { return {}; } - const byTool = {}; - for (const name of entries) { - if (!/^agent-.*\.jsonl$/.test(name)) continue; - const agentId = name.replace(/^agent-/, '').replace(/\.jsonl$/, ''); - let meta = {}; - try { meta = JSON.parse(fs.readFileSync(path.join(dir, 'agent-' + agentId + '.meta.json'), 'utf8')); } catch (_) {} - let raw; - try { raw = fs.readFileSync(path.join(dir, name), 'utf8'); } catch (_) { continue; } - const recs = parseLines(raw); - const shaped = shapeMessages(recs); - const key = meta.toolUseId || ('agent:' + agentId); - byTool[key] = { - agentId, - file: path.join(dir, name), // absolute path to this subagent's .jsonl (for "copy path") - type: meta.agentType || meta.subagent_type || 'agent', - description: meta.description || '', - skill: skillFromRecs(recs), - count: shaped.messages.length, - totals: shaped.totals, - messages: shaped.messages, - }; - } - return byTool; -} - -// Absolute path to a session's subagents dir (`//subagents`), regardless of existence. -function subagentDir(file) { - return path.join(path.dirname(file), path.basename(file, '.jsonl'), 'subagents'); -} - -// A session's raw subagent sidecar files (`agent-*.jsonl` + `agent-*.meta.json`) as -// [{ name, data:Buffer }], sorted by name. Empty when the session has no subagents. Shared by -// bundle export/import and the replay merge (mirrors src-tauri/src/history.rs read_subagent_files). -function readSubagentFiles(file) { - const dir = subagentDir(file); - let names; - try { names = fs.readdirSync(dir); } catch (_) { return []; } - const out = []; - for (const name of names) { - if (!/^agent-.*\.jsonl$/i.test(name) && !/^agent-.*\.meta\.json$/i.test(name)) continue; - try { - const p = path.join(dir, name); - if (!fs.statSync(p).isFile()) continue; - out.push({ name, data: fs.readFileSync(p) }); - } catch (_) {} - } - out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); - return out; -} - -// Absolute paths of a session's subagent transcripts (`/subagents/agent-*.jsonl`), sorted. -// Empty when the session has no subagents. Powers "Claude 分析": every subagent transcript is -// attached alongside the main session in the Cowork deep link (which honors a repeated `file=` -// param), so the analysis covers subagent runs — not just the main thread. -function subagentTranscriptPaths(file) { - const dir = subagentDir(file); - let names; - try { names = fs.readdirSync(dir); } catch (_) { return []; } - const out = []; - for (const name of names) { - if (!/^agent-.*\.jsonl$/i.test(name)) continue; - const p = path.join(dir, name); - try { if (fs.statSync(p).isFile()) out.push(p); } catch (_) {} - } - out.sort(); - return out; -} - -function createHistoryWatcher(opts) { - const getDirs = (opts && opts.getDirs) || defaultDirs; - const emitter = new EventEmitter(); - const offsets = new Map(); // file -> bytes already tailed - const watchers = []; // [{ poll, w }] - const metaCache = new Map(); // file -> { mtime, size, meta } - let debounce = null; - let started = false; - - function dirs() { try { return getDirs() || []; } catch (e) { console.error('[history] getDirs() failed:', (e && e.message) || e); return []; } } - - // A dir entry's Codex data tree: sibling `sessions/` next to its `projects/` — every work dir - // is probed for BOTH layouts (Claude Code writes `/projects/…`, Codex `/sessions/…`), - // so `~/.codex` is just another configured dir rather than a special case. - function sessionsDirOf(dm) { - const base = dm && (dm.configDir || (dm.projectsDir ? path.dirname(dm.projectsDir) : null)); - return base ? path.join(base, 'sessions') : null; - } - - function eachSessionFile(cb) { - for (const dm of dirs()) { - const root = dm && dm.projectsDir; - if (!root) continue; - let entries; - try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch (_) { entries = []; } - for (const d of entries) { - if (!d.isDirectory()) continue; - const pdir = path.join(root, d.name); - let files; - try { files = fs.readdirSync(pdir, { withFileTypes: true }); } catch (_) { continue; } - for (const f of files) { - if (f.isFile() && f.name.endsWith('.jsonl')) { - cb(path.join(pdir, f.name), d.name, false, dm); - } else if (f.isDirectory() && f.name === 'subagents') { - const sdir = path.join(pdir, f.name); - let sfiles; - try { sfiles = fs.readdirSync(sdir); } catch (_) { continue; } - for (const sf of sfiles) if (sf.endsWith('.jsonl')) cb(path.join(sdir, sf), d.name, true, dm); - } - } - } - // Codex rollouts live in a date-sharded sessions/ tree, so it gets its own walk. - const sdir = sessionsDirOf(dm); - if (sdir) codex.walkSessions(sdir, (file) => cb(file, '', false, dm)); - } - } - - /* ---------- browse ---------- */ - function sessionMeta(file, dirName, isSub, dm) { - let st; - try { st = fs.statSync(file); } catch (_) { return null; } - let entry = metaCache.get(file); - if (!entry || entry.mtime !== st.mtimeMs || entry.size !== st.size) { - const head = readChunk(file, st.size, 131072); - const recs = parseLines(head); - // Codex rollouts (a dir's sessions/ tree, or snapshots imported into the app store) list - // through the codex shaper — the record format shares nothing with Claude's. - if (codex.looksCodex(recs)) { - entry = { mtime: st.mtimeMs, size: st.size, meta: codex.sessionMetaFrom(file, recs, dm, st) }; - metaCache.set(file, entry); - return entry.meta; - } - const metaRec = recs.find((r) => r.cwd) || recs.find((r) => r.sessionId) || {}; - const agentRec = recs.find((r) => r.agentId) || {}; - const msgs = recs.map(lineToMessage).filter(Boolean); - const cc = readCcbud(recs); - const autoTitle = firstUserText(msgs); - let model = null; - for (const r of recs) { if (r.type === 'assistant' && r.message && r.message.model) model = r.message.model; } - const subagent = isSub || !!agentRec.agentId; - const cwd = metaRec.cwd || decodeDirName(dirName); - const baseId = metaRec.sessionId || path.basename(file, '.jsonl'); - const sessId = subagent && agentRec.agentId ? `${baseId}-${agentRec.agentId}` : baseId; - entry = { - mtime: st.mtimeMs, - size: st.size, - meta: { - id: 'disk:' + path.basename(file, '.jsonl') + (subagent ? ':sub' : ''), - file, - source: 'disk', - dirId: dm ? dm.id : 'default', - dirLabel: dm ? dm.label : null, - sessionId: sessId, - cwd, - project: baseName(cwd), - gitBranch: metaRec.gitBranch || null, - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - model, - isSubagent: subagent, - imported: !!(dm && dm.imported), - lastActivity: st.mtimeMs, - sizeKB: Math.round(st.size / 1024), - } - }; - metaCache.set(file, entry); - } - return entry.meta; - } - - function listSessions(activeId, limit) { - const files = []; - const liveFiles = new Set(); - eachSessionFile((file, dirName, isSub, dm) => { - liveFiles.add(file); - if (activeId && activeId !== 'all' && dm && dm.id !== activeId) return; - let st; try { st = fs.statSync(file); } catch (_) { return; } - files.push({ file, dirName, isSub, dm, mtime: st.mtimeMs }); - }); - for (const f of [...metaCache.keys()]) { - if (!liveFiles.has(f)) metaCache.delete(f); - } - files.sort((a, b) => b.mtime - a.mtime || String(b.file).localeCompare(String(a.file))); - // Materialize before applying the limit: true duplicate Codex rollout paths must not consume - // slots and evict distinct threads. `session_id` is intentionally NOT part of this key because - // root + subagents share it; the canonical first SessionMeta.id is exposed as threadId. - const sessions = files.map((s) => sessionMeta(s.file, s.dirName, s.isSub, s.dm)).filter(Boolean); - return limitWithCodexAncestors(dedupeCanonicalCodexSessions(sessions), limit || 400); - } - - function dedupeCanonicalCodexSessions(sessions) { - const out = []; - const positions = new Map(); - const canonicalFile = (session) => { - const threadId = String(session.threadId || ''); - const stem = path.basename(String(session.file || ''), '.jsonl'); - return !!threadId && (stem === threadId || stem.endsWith('-' + threadId)); - }; - const prefer = (candidate, current) => { - // Keep separate configured roots/import stores independent even when they contain the same - // logical transcript. Without Codex's SQLite index in this legacy backend, UpdatedAt - // (rollout mtime) is the closest official fallback; filename/size/path only break ties. - const activityDelta = (candidate.lastActivity || 0) - (current.lastActivity || 0); - if (activityDelta) return activityDelta > 0; - const createdDelta = (candidate.createdAt || 0) - (current.createdAt || 0); - if (createdDelta) return createdDelta > 0; - const canonicalDelta = Number(canonicalFile(candidate)) - Number(canonicalFile(current)); - if (canonicalDelta) return canonicalDelta > 0; - const sizeDelta = (candidate.sizeKB || 0) - (current.sizeKB || 0); - if (sizeDelta) return sizeDelta > 0; - return String(candidate.file || '') > String(current.file || ''); - }; - (sessions || []).forEach((session) => { - if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid || !session.threadId) { - out.push(session); - return; - } - const key = `${session.dirId || ''}\u0000${session.threadId}`; - const existing = positions.get(key); - if (existing == null) { - positions.set(key, out.length); - out.push(session); - } else if (prefer(session, out[existing])) { - out[existing] = session; - } - }); - return out.sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) - || String(b.threadId || b.id || '').localeCompare(String(a.threadId || a.id || ''))); - } - - // The limit is a soft cap for Codex trees: when a recent child makes the cut, include its parent - // chain (and root) even if those older rollout files rank below the cap. This prevents an orphan - // child from appearing without the conversation that spawned it. - function limitWithCodexAncestors(sessions, limit) { - if (sessions.length <= limit) return sessions; - const positions = new Map(); - sessions.forEach((session, index) => { - if (session && session.source === 'codex' && session.canonicalThreadIdValid && session.threadId) { - positions.set(`${session.dirId || ''}\u0000${session.threadId}`, index); - } - }); - const included = new Set(sessions.slice(0, limit).map((_, index) => index)); - const queue = [...included]; - for (let cursor = 0; cursor < queue.length; cursor++) { - const session = sessions[queue[cursor]]; - if (!session || session.source !== 'codex' || !session.canonicalThreadIdValid) continue; - const parentIds = [session.parentThreadId, session.isSubagent ? session.rootSessionId : null] - .filter((id, index, ids) => id != null && ids.indexOf(id) === index); - let parent = null; - for (const parentId of parentIds) { - const found = positions.get(`${session.dirId || ''}\u0000${parentId}`); - if (found != null) { parent = found; break; } - } - if (parent != null && !included.has(parent)) { included.add(parent); queue.push(parent); } - } - return sessions.filter((_, index) => included.has(index)); - } - - function listProjects(activeId, limit) { - const sessions = listSessions(activeId, limit || 600); - const groups = new Map(); - for (const s of sessions) { - const key = s.cwd || '(unknown)'; - if (!groups.has(key)) groups.set(key, { cwd: s.cwd, name: s.project || baseName(key) || '', sessions: [], lastActivity: 0 }); - const g = groups.get(key); - g.sessions.push(s); - if (s.lastActivity > g.lastActivity) g.lastActivity = s.lastActivity; - } - const arr = [...groups.values()]; - arr.forEach((g) => g.sessions.sort((a, b) => b.lastActivity - a.lastActivity)); - arr.sort((a, b) => b.lastActivity - a.lastActivity); - return arr; - } - - /** Per-directory session counts (for the settings list + directory switcher). */ - function dirStats() { - const counts = {}; - // Count the same canonical rows the sidebar can show, not raw rollout files. Distinct Codex - // subagents still count; only true same-thread physical duplicates are collapsed. - for (const session of listSessions('all', Number.MAX_SAFE_INTEGER)) { - if (!session || session.deleted) continue; - const id = session.dirId || 'default'; - counts[id] = (counts[id] || 0) + 1; - } - return dirs().map((dm) => { - // A dir "exists" when EITHER data tree is on disk — ~/.codex has only sessions/. - let exists = false; - try { exists = fs.statSync(dm.projectsDir).isDirectory(); } catch (_) {} - if (!exists) { - const sdir = sessionsDirOf(dm); - try { exists = !!sdir && fs.statSync(sdir).isDirectory(); } catch (_) {} - } - return { id: dm.id, label: dm.label, projectsDir: dm.projectsDir, sessions: counts[dm.id] || 0, exists, imported: !!dm.imported }; - }); - } - - function getSession(file) { - let raw; - try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { return null; } - const recs = parseLines(raw); - if (codex.looksCodex(recs)) return codex.sessionFromRecs(file, recs); - const metaRec = recs.find((r) => r.cwd) || recs.find((r) => r.sessionId) || {}; - const agentRec = recs.find((r) => r.agentId) || {}; - const summaryRec = recs.find((r) => r.type === 'summary' && r.summary); - const cc = readCcbud(recs); - - const shaped = shapeMessages(recs); - const messages = shaped.messages; - const autoTitle = firstUserText(messages); - - const subagent = !!agentRec.agentId; - // Imported transcripts carry a sidecar recording where they came from (see main.importOne). - let imported = null; - try { imported = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, '.import.json'), 'utf8')); } catch (_) {} - // Only a top-level session embeds its child subagent dialogues (a subagent file has no nested - // subagents/ dir of its own), so the renderer can nest them under their spawning Task call. - const subagents = subagent ? {} : readSubagents(file); - applySkillNames(messages, subagents); - const cwd = metaRec.cwd || null; - const baseId = metaRec.sessionId || path.basename(file, '.jsonl'); - const sessId = subagent && agentRec.agentId ? `${baseId}-${agentRec.agentId}` : baseId; - return { - meta: { - id: 'disk:' + path.basename(file, '.jsonl') + (subagent ? ':sub' : ''), - file, - source: 'disk', - title: cc.title || autoTitle, - autoTitle, - tags: cc.tags, - summary: summaryRec ? summaryRec.summary : null, - sessionId: sessId, - cwd, - project: baseName(cwd), - gitBranch: metaRec.gitBranch || null, - version: metaRec.version || null, - isSubagent: subagent, - skill: subagent ? skillFromRecs(recs) : null, // a standalone subagent transcript self-reports via the sentinel - imported: !!imported, - importedFrom: imported ? imported.originalPath : null, - importedAt: imported ? imported.importedAt : null, - model: shaped.model, - totals: shaped.totals, - messages: messages.length, - subagentCount: Object.keys(subagents).length, - firstTs: shaped.firstTs, - lastTs: shaped.lastTs, - }, - messages, - subagents, - }; - } - - // Write per-conversation customization (custom title + tags) onto the FIRST parseable line of a - // session file as a `__ccbud__` field. patch: { title?, tags? } — empty title / empty tags removes - // that key (empty __ccbud__ is dropped entirely). Atomic (tmp + rename, mirrors store.js). Guarded - // to the configured projects dirs so a renderer can never drive an arbitrary-path write. - function setCcbud(file, patch) { - patch = patch || {}; - // Renderer-driven writes are confined to the configured work dirs' data trees (projects/ - // AND sessions/) plus the imports store. - const resolved = path.resolve(file); - const within = dirs().some((dm) => { - if (!dm) return false; - const roots = [dm.projectsDir, sessionsDirOf(dm)].filter(Boolean); - return roots.some((r) => resolved.startsWith(path.resolve(r) + path.sep)); - }); - if (!within) return { ok: false, reason: 'out-of-scope' }; - let raw; - try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { return { ok: false, reason: 'read' }; } - // Live Codex rollouts are another tool's files — their title/tags live in the app-owned - // sidecar instead of being written into the rollout. Imported codex COPIES sit inside our - // store (marked by .import.json) and take the normal in-file path below. - const head = []; - for (const line of raw.split('\n')) { - const s = line.trim(); if (!s) continue; - try { head.push(JSON.parse(s)); } catch (_) {} - if (head.length >= 8) break; - } - if (codex.looksCodex(head) && !codex.hasImportSidecar(file)) { - const r = codex.setMeta(file, patch); - if (r && r.ok) metaCache.delete(file); // sidecar edits don't bump the file's mtime - return r; - } - const lines = raw.split('\n'); - let idx = -1, obj = null; - for (let i = 0; i < lines.length; i++) { - const s = lines[i].trim(); if (!s) continue; - try { obj = JSON.parse(s); idx = i; break; } catch (_) {} - } - if (idx < 0 || !obj || typeof obj !== 'object') return { ok: false, reason: 'empty' }; - const next = Object.assign({}, obj.__ccbud__ || {}); - if ('title' in patch) { const t = (patch.title || '').trim(); if (t) next.title = t; else delete next.title; } - if ('tags' in patch) { - const arr = []; - for (const x of (patch.tags || [])) { const t = typeof x === 'string' ? x.trim() : ''; if (t && arr.indexOf(t) < 0) arr.push(t); } - if (arr.length) next.tagList = arr; else delete next.tagList; - } - if (Object.keys(next).length) obj.__ccbud__ = next; else delete obj.__ccbud__; - lines[idx] = JSON.stringify(obj); - const out = lines.join('\n'); - const tmp = file + '.ccbud.tmp'; - try { fs.writeFileSync(tmp, out, 'utf8'); fs.renameSync(tmp, file); } - catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return { ok: false, reason: 'write' }; } - metaCache.delete(file); // next list re-reads the new title/tags - try { offsets.set(file, Buffer.byteLength(out)); } catch (_) {} // don't let the watcher replay the rewrite as new records - return { ok: true }; - } - - /* ---------- watch / live tail ---------- */ - function tailNew() { - const changed = []; - eachSessionFile((file) => { - let st; - try { st = fs.statSync(file); } catch (_) { return; } - const prev = offsets.get(file); - if (prev === undefined) { - offsets.set(file, st.size); - if (started) changed.push(file); - return; - } - if (st.size <= prev) { offsets.set(file, st.size); if (st.size < prev) changed.push(file); return; } - let chunk = ''; - try { - const fd = fs.openSync(file, 'r'); - const len = st.size - prev; - const b = Buffer.alloc(len); - fs.readSync(fd, b, 0, len, prev); - fs.closeSync(fd); - chunk = b.toString('utf8'); - } catch (_) { offsets.set(file, st.size); return; } - offsets.set(file, st.size); - for (const rec of parseLines(chunk)) { - if (rec.type === 'assistant' && rec.message && rec.message.id) { - const sid = rec.sessionId || null; - const sessId = sid && rec.agentId ? `${sid}-${rec.agentId}` : sid; - emitter.emit('correlate', { messageId: rec.message.id, sessionId: sessId, cwd: rec.cwd, gitBranch: rec.gitBranch }); - } - emitter.emit('record', { file, rec }); - } - changed.push(file); - }); - if (changed.length) emitter.emit('changed', { files: changed }); - } - - function watchDir(root) { - try { - const w = fs.watch(root, { recursive: true }, () => { clearTimeout(debounce); debounce = setTimeout(tailNew, 250); }); - return { poll: false, w }; - } catch (_) { - const iv = setInterval(tailNew, 2000); - if (iv.unref) iv.unref(); - return { poll: true, w: iv }; - } - } - function clearWatchers() { - for (const x of watchers) { try { if (x.poll) clearInterval(x.w); else x.w.close(); } catch (_) {} } - watchers.length = 0; - } - function primeOffsets() { - const live = new Set(); - eachSessionFile((file) => { live.add(file); if (!offsets.has(file)) { try { offsets.set(file, fs.statSync(file).size); } catch (_) {} } }); - // drop offsets for files whose directory was removed, so a later re-add re-primes cleanly - for (const f of [...offsets.keys()]) if (!live.has(f)) offsets.delete(f); - } - function syncWatches() { - clearWatchers(); - for (const dm of dirs()) { - // Watch both data trees of every work dir (projects/ = Claude, sessions/ = Codex). - for (const root of [dm.projectsDir, sessionsDirOf(dm)]) { - if (!root) continue; - let exists = false; - try { exists = fs.statSync(root).isDirectory(); } catch (_) {} - if (exists) watchers.push(watchDir(root)); - } - } - primeOffsets(); - } - - function start() { - if (started) return; - started = true; - syncWatches(); - } - function stop() { - started = false; - clearTimeout(debounce); - debounce = null; - clearWatchers(); - } - /** Re-establish watches after the configured directory list changes. */ - function refresh() { if (started) syncWatches(); } - - return { - on: emitter.on.bind(emitter), - off: emitter.off.bind(emitter), - start, - stop, - refresh, - tailNew, - listSessions, - listProjects, - dirStats, - getSession, - setCcbud, - }; -} - -module.exports = { createHistoryWatcher, lineToMessage, firstUserText, readCcbud, decodeDirName, defaultDirs, subagentDir, readSubagentFiles, subagentTranscriptPaths, skillFromRecs, applySkillNames }; diff --git a/src/main/hotpaths.js b/src/main/hotpaths.js deleted file mode 100644 index 3f4c618..0000000 --- a/src/main/hotpaths.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -/* - * Shared on-disk layout + state for the hot-update system. Used by BOTH the bootstrap - * loader (src/main/bootstrap.js — runs before the real app) and the updater (updater.js). - * Keeping the schema in one tiny, dependency-free module is what lets the staged bundle's - * copy of these files agree byte-for-byte with the packaged shell's copy. - * - * Layout (under /hot): - * hot/state.json — pointer + rollback bookkeeping (see schema below) - * hot// — an extracted hot bundle; contains src/main/main.js, etc. - * - * state.json schema: - * { - * active: { version, dir } | null, // the live hot bundle (dir is relative to hot/) - * pending: { version, dir } | null, // staged, promoted to active on next launch - * previous: { version, dir } | null, // last known-good active, for rollback - * trying: string | null // version we promoted but haven't confirmed booted ok - * } - */ - -const fs = require('fs'); -const path = require('path'); - -function hotRoot(userData) { - return path.join(userData, 'hot'); -} -function stateFile(userData) { - return path.join(hotRoot(userData), 'state.json'); -} -function bundleDir(userData, dirName) { - return path.join(hotRoot(userData), dirName); -} -// The entry the bootstrap requires for a given bundle root (packaged root OR a hot bundle dir). -function mainEntry(root) { - return path.join(root, 'src', 'main', 'main.js'); -} - -function readState(userData) { - try { - const raw = fs.readFileSync(stateFile(userData), 'utf8'); - const s = JSON.parse(raw); - if (s && typeof s === 'object') { - return { - active: s.active || null, - pending: s.pending || null, - previous: s.previous || null, - trying: typeof s.trying === 'string' ? s.trying : null, - }; - } - } catch (_) {} - return { active: null, pending: null, previous: null, trying: null }; -} - -function writeState(userData, state) { - const dir = hotRoot(userData); - try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (_) {} - const file = stateFile(userData); - const tmp = file + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); - fs.renameSync(tmp, file); - return state; -} - -module.exports = { hotRoot, stateFile, bundleDir, mainEntry, readState, writeState }; diff --git a/src/main/icon.png b/src/main/icon.png deleted file mode 100644 index 93058b7..0000000 Binary files a/src/main/icon.png and /dev/null differ diff --git a/src/main/iconTemplate.png b/src/main/iconTemplate.png deleted file mode 100644 index 0d7eaf0..0000000 Binary files a/src/main/iconTemplate.png and /dev/null differ diff --git a/src/main/iconTemplate@2x.png b/src/main/iconTemplate@2x.png deleted file mode 100644 index 07ad27a..0000000 Binary files a/src/main/iconTemplate@2x.png and /dev/null differ diff --git a/src/main/insights.js b/src/main/insights.js deleted file mode 100644 index f63acea..0000000 --- a/src/main/insights.js +++ /dev/null @@ -1,314 +0,0 @@ -'use strict'; - -/** - * Usage analytics computed from on-disk history (.jsonl) — aggregation semantics ported from - * ccusage (github.com/ccusage/ccusage), scoped to the two agents ccbud fronts. Per work dir: - * - * Claude Code `projects/`+`**` (recursive, any depth — nested session dirs and subagent - * transcripts included by construction): every line whose `message.usage` carries numeric - * input/output tokens counts (no `type=="assistant"` gate); lines without a parseable - * timestamp are DROPPED; cache-creation prefers the nested ephemeral breakdown; `` - * keeps tokens but gets no model attribution; `usage.speed=="fast"` appends `-fast`; global - * de-dup by (message.id, requestId) with a sidechain fallback on message.id alone. - * - * Codex `sessions/` + `archived_sessions/` (archived copy of the same relative path loses): - * `token_count` events — prefer `info.last_token_usage`, else diff consecutive - * `total_token_usage` snapshots (baseline always advances); `thread_spawn` subagent files skip - * the leading parent-history replay burst; identical (timestamp, model, tokens) events across - * files de-dup; model from payload/info, else last `turn_context`, else "gpt-5"; input is - * INCLUSIVE of cached (cached splits into cacheRead, remainder into input). - * - * - getDirs() supplies the ACTIVE set of `projects` directories; getSessionDirs() the matching - * Codex work dirs' roots are derived from them in main.js. - * - Per-file parse results are cached by (mtime,size); only changed files re-parse. De-dup runs - * globally on every build (it must see all files at once). - */ - -const fs = require('fs'); -const path = require('path'); -const { queryUsage, rangeTokens, bump } = require('./usage'); - -/* ---------------- Claude Code ---------------- */ - -function parseClaudeUsage(raw) { - const recs = []; - for (const line of raw.split('\n')) { - const s = line.trim(); - if (!s || !s.includes('"usage"')) continue; - let r; - try { r = JSON.parse(s); } catch (_) { continue; } - const m = r.message; - const u = m && m.usage; - if (!u || typeof u.input_tokens !== 'number' || typeof u.output_tokens !== 'number') continue; - const inputTokens = u.input_tokens; - const outputTokens = u.output_tokens; - const cacheRead = u.cache_read_input_tokens || 0; - // nested ephemeral breakdown wins over the flat cache_creation_input_tokens - const cacheCreation = u.cache_creation && typeof u.cache_creation === 'object' - ? (u.cache_creation.ephemeral_5m_input_tokens || 0) + (u.cache_creation.ephemeral_1h_input_tokens || 0) - : (u.cache_creation_input_tokens || 0); - if (inputTokens + outputTokens + cacheRead + cacheCreation <= 0) continue; // zero rows carry no info - const ts = r.timestamp ? Date.parse(r.timestamp) : NaN; - if (isNaN(ts)) continue; // undated lines are dropped, never guessed - const fast = u.speed === 'fast'; - let model = typeof m.model === 'string' && m.model && m.model !== '' ? m.model : null; - if (model && fast) model += '-fast'; - recs.push({ - id: m.id || null, - requestId: r.requestId || null, - sidechain: r.isSidechain === true, - ts, model, inputTokens, outputTokens, cacheRead, cacheCreation, - }); - } - return recs; -} - -const recTotal = (r) => r.inputTokens + r.outputTokens + r.cacheRead + r.cacheCreation; - -/** Global de-dup, ccusage semantics: key (message.id, requestId); no id → always kept; an exact - * miss falls back to the id-only bucket when either side is a sidechain (a replay reuses the - * parent's message.id under a new requestId). Non-sidechain wins, then higher token total. */ -// Message ids older ccbud gateway builds stamped on EVERY translated response — known -// non-unique, never usable as a de-dup key. -const DEGENERATE_IDS = new Set(['msg_ccbud', 'chatcmpl-ccbud', 'resp_ccbud']); - -function dedupClaude(recs) { - const kept = []; - const byExact = new Map(); - const byId = new Map(); - for (const cand of recs) { - if (!cand.id || DEGENERATE_IDS.has(cand.id)) { kept.push(cand); continue; } - const exact = `${cand.id}\u0000${cand.requestId || ''}`; - let i = byExact.get(exact); - if (i === undefined) { - const j = byId.get(cand.id); - if (j !== undefined && (cand.sidechain || kept[j].sidechain)) i = j; - } - if (i !== undefined) { - const cur = kept[i]; - if ((cur.sidechain && !cand.sidechain) || (cur.sidechain === cand.sidechain && recTotal(cand) > recTotal(cur))) { - kept[i] = cand; - } - byExact.set(exact, i); - } else { - const idx = kept.length; - byExact.set(exact, idx); - if (!byId.has(cand.id)) byId.set(cand.id, idx); - kept.push(cand); - } - } - return kept; -} - -/* ---------------- Codex ---------------- */ - -function codexUsageOf(v) { - if (!v || typeof v !== 'object') return null; - const g = (...keys) => { for (const k of keys) if (typeof v[k] === 'number') return v[k]; return 0; }; - const input = g('input_tokens', 'prompt_tokens', 'input'); - const cached = g('cached_input_tokens', 'cache_read_input_tokens', 'cached_tokens'); - const output = g('output_tokens', 'completion_tokens', 'output'); - const reasoning = g('reasoning_output_tokens', 'reasoning_tokens'); - let total = typeof v.total_tokens === 'number' ? v.total_tokens : null; - if (total === null || (total === 0 && input + output + reasoning > 0)) total = input + output + reasoning; - return { input, cached, output, reasoning, total }; -} - -const codexSub = (cur, prev) => ({ - input: Math.max(0, cur.input - (prev ? prev.input : 0)), - cached: Math.max(0, cur.cached - (prev ? prev.cached : 0)), - output: Math.max(0, cur.output - (prev ? prev.output : 0)), - reasoning: Math.max(0, cur.reasoning - (prev ? prev.reasoning : 0)), - total: Math.max(0, cur.total - (prev ? prev.total : 0)), -}); - -function codexModelOf(v) { - if (!v || typeof v !== 'object') return null; - const m = v.model || v.model_name || (v.metadata && v.metadata.model); - return typeof m === 'string' && m ? m : null; -} - -/** Parse one Codex rollout's content into per-turn usage events (ccusage semantics). */ -function parseCodexUsage(raw) { - const events = []; - const lines = raw.split('\n'); - // thread_spawn subagent files replay the parent history as a leading burst sharing one - // timestamp-second — find that second so the burst is skipped (baseline still advances). - let replaySecond = null; - if (raw.slice(0, 16384).includes('thread_spawn')) { - let first = null; - for (const line of lines) { - const t = tokenCountOf(line); - if (!t || !t.info || (!t.info.last_token_usage && !t.info.total_token_usage)) continue; - const second = String(t.ts).slice(0, 19); - if (first === null) { first = second; continue; } - replaySecond = first === second ? second : null; - break; - } - } - let skipReplay = replaySecond !== null; - let currentModel = null; - let prevTotals = null; - for (const line of lines) { - const s = line.trim(); - if (!s) continue; - if (s.includes('turn_context')) { - let r; - try { r = JSON.parse(s); } catch (_) { r = null; } - if (r && r.type === 'turn_context') { - const m = codexModelOf(r.payload); - if (m) currentModel = m; - continue; - } - } - const t = tokenCountOf(s); - if (!t) continue; - const info = t.info && typeof t.info === 'object' ? t.info : null; - const total = info ? codexUsageOf(info.total_token_usage) : null; - const last = info ? codexUsageOf(info.last_token_usage) : null; - if (skipReplay) { - if (String(t.ts).slice(0, 19) === replaySecond) { - if (total) prevTotals = total; - continue; - } - skipReplay = false; - } - const usage = last || (total ? codexSub(total, prevTotals) : null); - if (total) prevTotals = total; - if (!usage || usage.input + usage.cached + usage.output + usage.reasoning === 0) continue; - const ts = Date.parse(t.ts); - if (isNaN(ts)) continue; - usage.cached = Math.min(usage.cached, usage.input); // input is INCLUSIVE of cached - const model = codexModelOf(t.payload) || codexModelOf(info) || currentModel || 'gpt-5'; - events.push({ ts, model, ...usage }); - } - return events; -} - -function tokenCountOf(line) { - const s = line.trim(); - if (!s || !s.includes('token_count')) return null; - let r; - try { r = JSON.parse(s); } catch (_) { return null; } - if (r.type !== 'event_msg' || !r.payload || r.payload.type !== 'token_count') return null; - if (typeof r.timestamp !== 'string') return null; - return { ts: r.timestamp, payload: r.payload, info: r.payload.info }; -} - -/* ---------------- discovery + build ---------------- */ - -const MAX_WALK_DEPTH = 8; // symlink-loop guard - -function collectJsonl(dir, depth, out) { - if (depth > MAX_WALK_DEPTH) return; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } - for (const e of entries) { - const p = path.join(dir, e.name); - if (e.isDirectory()) collectJsonl(p, depth + 1, out); - else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p); - } -} - -function createInsights(opts) { - const getDirs = (opts && opts.getDirs) || (() => []); - const getSessionDirs = (opts && opts.getSessionDirs) || (() => []); - const fileCache = new Map(); // absolute file path -> { mtime, size, recs } - let memo = null, memoAt = 0; // short-TTL cache so back-to-back query()/rangeTokens() share one scan - - // Claude: every *.jsonl under each projects dir, any depth. - function claudeFiles() { - const files = []; - for (const root of getDirs() || []) collectJsonl(root, 0, files); - files.sort(); - return files; - } - - // Codex: sessions/ then archived_sessions/ of each work dir; an archived copy of the same - // relative path loses to the active sessions/ copy. - function codexFiles() { - const out = []; - for (const sessionsDir of getSessionDirs() || []) { - const root = path.dirname(sessionsDir); - const seenRel = new Set(); - for (const sub of ['sessions', 'archived_sessions']) { - const dir = path.join(root, sub); - const files = []; - collectJsonl(dir, 0, files); - files.sort(); - for (const f of files) { - const rel = path.relative(dir, f); - if (!seenRel.has(rel)) { seenRel.add(rel); out.push(f); } - } - } - } - return out; - } - - async function loadRecs(file, parse) { - let st; - try { st = await fs.promises.stat(file); } catch (_) { return null; } - let entry = fileCache.get(file); - if (!entry || entry.mtime !== st.mtimeMs || entry.size !== st.size) { - let raw; - try { raw = await fs.promises.readFile(file, 'utf8'); } catch (_) { return null; } - entry = { mtime: st.mtimeMs, size: st.size, recs: parse(raw) }; - fileCache.set(file, entry); - } - return entry.recs; - } - - async function buildData() { - const data = { days: {} }; - const live = new Set(); - - const claude = []; - for (const file of claudeFiles()) { - live.add(file); - const recs = await loadRecs(file, parseClaudeUsage); - if (recs) claude.push(...recs); - } - for (const r of dedupClaude(claude)) bump(data, r); - - const seen = new Set(); // (ts, model, tokens) — resumed/forked session copies collapse - for (const file of codexFiles()) { - live.add(file); - const events = await loadRecs(file, parseCodexUsage); - if (!events) continue; - for (const e of events) { - const key = `${e.ts}|${e.model}|${e.input}|${e.cached}|${e.output}|${e.reasoning}|${e.total}`; - if (seen.has(key)) continue; - seen.add(key); - bump(data, { - ts: e.ts, - model: e.model, - inputTokens: Math.max(0, e.input - e.cached), - outputTokens: e.output, - cacheRead: e.cached, - cacheCreation: 0, - }); - } - } - - // evict cache entries for files that disappeared / dirs deselected - for (const f of [...fileCache.keys()]) if (!live.has(f)) fileCache.delete(f); - return data; - } - - async function buildDataCached() { - const now = Date.now(); - if (memo && now - memoAt < 1500) return memo; - memo = await buildData(); - memoAt = now; - return memo; - } - - return { - query: async (range, now) => queryUsage(await buildDataCached(), range, now), - rangeTokens: async (range, now) => rangeTokens(await buildDataCached(), range, now), - invalidate: (file) => { if (file) fileCache.delete(file); else fileCache.clear(); memo = null; }, - _buildData: buildData, - }; -} - -module.exports = { createInsights, parseClaudeUsage, parseCodexUsage, dedupClaude }; diff --git a/src/main/main.js b/src/main/main.js deleted file mode 100644 index aa1db00..0000000 --- a/src/main/main.js +++ /dev/null @@ -1,1019 +0,0 @@ -'use strict'; - -const { app, BrowserWindow, ipcMain, shell, clipboard, Tray, Menu, nativeImage, dialog } = require('electron'); -const fs = require('fs'); -const path = require('path'); -const { createStore } = require('./store'); -const { createGateway } = require('./proxy'); -const claude = require('./claude'); -const claudeDesktop = require('./claudeDesktop'); -const updater = require('./updater'); -const os = require('os'); -const { formatTokens } = require('./usage'); -const { createHistoryWatcher, readSubagentFiles, subagentTranscriptPaths } = require('./history'); -const zipStore = require('./zipStore'); -const { createInsights } = require('./insights'); -const { createMonitorStore } = require('./monitor'); -const { DICT } = require('../shared/i18n-dict'); - -// Main-process translator: reads the chosen language from config, falls back en → key. -function mt(key, params) { - const lang = (store && store.get().language) || 'en'; - const d = DICT[lang] || DICT.en; - let s = d[key] != null ? d[key] : (DICT.en[key] != null ? DICT.en[key] : key); - if (params) s = s.replace(/\{(\w+)\}/g, (_, k) => (params[k] != null ? params[k] : '{' + k + '}')); - return s; -} -// Map a system locale (app.getLocale()) to the nearest supported UI language. -function mapLocale(loc) { - loc = String(loc || '').toLowerCase(); - if (loc.startsWith('zh')) return (/-(tw|hk|mo)\b/.test(loc) || loc.includes('hant')) ? 'zh-TW' : 'zh'; - if (loc.startsWith('ja')) return 'ja'; - if (loc.startsWith('ko')) return 'ko'; - return 'en'; -} - -let mainWindow = null; -let popover = null; -let tray = null; -let store = null; -let gateway = null; -let insights = null; -let history = null; -let monitor = null; -let lastStartError = null; -// Bounded ring buffer of gateway lifecycle/error events, so the monitor's "网关日志" panel can -// backfill on open (the events fire once — e.g. "listening on …" at boot — and aren't replayed -// otherwise). Each entry is stamped with a monotonic seq so the renderer dedupes replay vs live. -const gatewayLogs = []; -let gatewayLogSeq = 0; -const MAX_GATEWAY_LOGS = 80; -let isQuitting = false; -let lastPopoverHide = 0; -let titleTimer = 0; -let historyDirty = new Set(); -let historyTimer = null; -let requestLogPath = null; - -let requestCountSinceTruncate = 0; -function appendRequestLog(r) { - if (!requestLogPath) return; - const agent = r.agentId ? 'sub' : 'main'; - const line = [ - new Date().toISOString(), - agent, - r.requestedModel || '-', - '→', - r.outgoingModel || '-', - r.status, - (r.sessionId || '').slice(0, 8), - ].join(' ') + '\n'; - fs.appendFile(requestLogPath, line, (err) => { - if (err) return; - requestCountSinceTruncate++; - if (requestCountSinceTruncate >= 50) { - requestCountSinceTruncate = 0; - fs.readFile(requestLogPath, 'utf8', (err, data) => { - if (err) return; - const lines = data.split('\n'); - if (lines.length > 600) { - fs.writeFile(requestLogPath, lines.slice(-500).join('\n'), 'utf8', () => {}); - } - }); - } - }); -} - -// Single-instance lock: a second launch must NOT try to bind the same port. -const gotLock = app.requestSingleInstanceLock(); -if (!gotLock) { - app.quit(); -} else { - app.on('second-instance', () => showWindow()); -} - -function broadcast(channel, payload) { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, payload); -} - -// Record a gateway log event into the ring buffer (stamped with seq + ts) and broadcast it live. -function pushGatewayLog(l) { - const entry = Object.assign({ seq: ++gatewayLogSeq, ts: Date.now() }, l); - gatewayLogs.push(entry); - while (gatewayLogs.length > MAX_GATEWAY_LOGS) gatewayLogs.shift(); - broadcast('gateway:log', entry); - return entry; -} - -// Coalesce on-disk history change notifications into ~200ms batches before hitting IPC, -// so a burst of file-watch events becomes one renderer refresh. -function markHistoryDirty(files) { - (files || []).forEach((f) => historyDirty.add(f)); - if (historyTimer) return; - historyTimer = setTimeout(() => { - const changed = [...historyDirty]; - historyDirty.clear(); - historyTimer = null; - broadcast('history:changed', { files: changed }); - }, 200); -} - -function currentToken() { - const c = store.get(); - return c.requireToken && c.gatewayToken ? c.gatewayToken : 'ccbud-local'; -} - -/* ---------- history / usage directories ---------- */ -function expandPath(p) { - p = String(p || '').trim(); - if (p === '~') return os.homedir(); - if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2)); - return p; -} -function dirLabel(raw) { - const home = os.homedir(); - const exp = expandPath(raw); - if (exp === path.join(home, '.claude')) return '~/.claude'; - if (exp === home + path.sep || exp.startsWith(home + path.sep)) return '~/' + path.relative(home, exp); - return String(raw); -} -// All configured config dirs → { id(raw path), label, configDir, projectsDir }. -function configDirs() { - const list = (store && store.get().historyDirs) || ['~/.claude']; - return list.map((raw) => { - const exp = expandPath(raw); - return { id: raw, label: dirLabel(raw), configDir: exp, projectsDir: path.join(exp, 'projects') }; - }); -} -// All user settings + data live under ~/.ccbud so they survive an app uninstall/reinstall and are -// trivial to back up or reuse. (The hot-update bundle stays under userData — it's tied to the -// installed shell version, not portable.) Override with CCBUD_HOME for tests / custom locations. -const CCBUD_HOME = process.env.CCBUD_HOME || path.join(os.homedir(), '.ccbud'); - -// The app-managed import store, laid out exactly like a native config dir's projects/ tree so the -// whole history pipeline (list/group/subagents/getSession/watch/count) handles imports unchanged. -const IMPORTED_ID = '__imported__'; -function importsRoot() { return path.join(CCBUD_HOME, 'imports'); } -function importedDir() { - return { id: IMPORTED_ID, label: mt('conv.imported'), configDir: importsRoot(), projectsDir: path.join(importsRoot(), 'projects'), imported: true }; -} -// One-time migration: when a Codex install exists (its sessions tree is on disk), add its -// config dir (`~/.codex`, CODEX_HOME-aware) to historyDirs so Codex conversations appear in -// 对话 like any other work dir. The codexDirAutoAdded flag makes this run once — a user who -// later REMOVES the dir isn't fighting an auto-re-add. Mirrors store.rs ensure_codex_dir. -function ensureCodexDir() { - const codex = require('./codex'); - const cfg = store.get(); - if (cfg.codexDirAutoAdded || !codex.rootExists()) return; - const dirs = (cfg.historyDirs || []).slice(); - const label = codex.codexLabel(); - if (!dirs.includes(label)) dirs.push(label); - store.save(Object.assign(JSON.parse(JSON.stringify(cfg)), { historyDirs: dirs, codexDirAutoAdded: true })); -} -// History reader sees user dirs + the import store; usage (activeProjectsDirs) deliberately -// keeps to the Claude projects/ trees, so other tools' tokens never pollute your usage stats. -function historyDirsList() { return configDirs().concat([importedDir()]); } -// Active selection ('all' or one dir id) → list of projects dirs for the usage engine. -function activeProjectsDirs() { - const active = (store && store.get().historyActive) || 'all'; - const all = configDirs(); - const sel = active === 'all' ? all : all.filter((d) => d.id === active); - return (sel.length ? sel : all).map((d) => d.projectsDir); -} - -function statusPayload() { - const port = store ? store.get().port : null; - return Object.assign( - {}, - gateway ? gateway.status() : { running: false, port: null }, - { lastStartError, connected: store ? claude.isConnected(port) : false, claudePath: claude.settingsPath(), gatewayEnabled: store ? store.get().gatewayEnabled !== false : true } - ); -} - -function genId() { - return 'p_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); -} - -/* ---------- one-click connect / disconnect ---------- */ -// Config-file action only — the gateway service has its own switch (gateway:setEnabled). -async function doConnect() { - const cfg = store.get(); - if (!cfg.providers.length) return { ok: false, message: mt('err.noProvider') }; - try { - claude.connect(cfg.port, currentToken(), store); - } catch (e) { - return { ok: false, message: mt('err.writeConfig', { msg: e.message }) }; - } - updateTray(); - broadcast('gateway:status', statusPayload()); - return { ok: true }; -} - -async function doDisconnect() { - try { - claude.disconnect(store); - } catch (e) { - return { ok: false, message: mt('err.restoreConfig', { msg: e.message }) }; - } - updateTray(); - broadcast('gateway:status', statusPayload()); - return { ok: true }; -} - -// Independent gateway-service switch (parity with lib.rs gateway_set_enabled). -async function setGatewayEnabled(on) { - store.set({ gatewayEnabled: !!on }); - if (on) { - try { await gateway.start(store.get().port); lastStartError = null; } - catch (e) { - lastStartError = mt('err.portFailed', { port: store.get().port, msg: e.message }); - broadcast('gateway:status', statusPayload()); - return { ok: false, reason: 'portFailed', message: lastStartError }; - } - } else { - await gateway.stop(); - } - updateTray(); - broadcast('gateway:status', statusPayload()); - return { ok: true }; -} - -async function restartServer() { - const cfg = store.get(); - await gateway.stop(); - try { - await gateway.start(cfg.port); - lastStartError = null; - } catch (e) { - lastStartError = mt('err.portFailed', { port: cfg.port, msg: e.message }); - pushGatewayLog({ level: 'error', msg: lastStartError }); - } - broadcast('gateway:status', statusPayload()); -} - -// Raw HTTPS POST that can skip TLS verification (Node fetch can't, and undici isn't a dep). -// Only used for the provider Test button when insecure TLS is enabled; mirrors the proxy's -// `rejectUnauthorized:false` so a self-signed/MITM chain doesn't make Test falsely fail. -function rawPostJson(urlStr, { headers, body, timeoutMs, insecure }) { - return new Promise((resolve, reject) => { - let u; - try { u = new URL(urlStr); } catch (e) { reject(e); return; } - const lib = u.protocol === 'http:' ? require('http') : require('https'); - const opts = { - protocol: u.protocol, hostname: u.hostname, - port: u.port || (u.protocol === 'https:' ? 443 : 80), - path: u.pathname + u.search, method: 'POST', - headers: Object.assign({ 'accept-encoding': 'identity' }, headers), // avoid gzip we'd have to decode - }; - if (insecure && u.protocol === 'https:') opts.rejectUnauthorized = false; - const r = lib.request(opts, (resp) => { - const chunks = []; - resp.on('data', (c) => chunks.push(c)); - resp.on('end', () => resolve({ status: resp.statusCode, text: Buffer.concat(chunks).toString('utf8') })); - }); - const timer = setTimeout(() => { const e = new Error('timeout'); e.timeout = true; r.destroy(e); }, timeoutMs || 30000); - if (timer.unref) timer.unref(); - r.on('close', () => clearTimeout(timer)); - r.on('error', reject); - if (body) r.write(body); - r.end(); - }); -} - -async function testProvider(provider) { - const model = provider.defaultModel || (provider.models && provider.models[0] && provider.models[0].upstream) || ''; - if (!provider.baseUrl) return { ok: false, message: mt('err.baseUrlEmpty') }; - let url; - try { - const base = new URL(provider.baseUrl); - url = base.protocol + '//' + base.host + base.pathname.replace(/\/+$/, '') + '/v1/messages'; - } catch (e) { - return { ok: false, message: mt('err.baseUrlInvalid') }; - } - const insecure = !!(store && store.get().insecureSkipVerify); - const headers = { - 'content-type': 'application/json', - authorization: 'Bearer ' + (provider.authToken || ''), - 'x-api-key': provider.authToken || '', - 'anthropic-version': '2023-06-01', - }; - const body = JSON.stringify({ model: model || 'claude-3-5-haiku-20241022', max_tokens: 16, messages: [{ role: 'user', content: 'ping' }] }); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 30000); - try { - let status, text; - if (insecure) { - const resp = await rawPostJson(url, { headers, body, timeoutMs: 30000, insecure: true }); - status = resp.status; text = resp.text; - } else { - const r = await fetch(url, { method: 'POST', signal: controller.signal, headers, body }); - status = r.status; text = await r.text(); - } - let json = null; - try { json = JSON.parse(text); } catch (_) {} - const httpOk = status >= 200 && status < 300; - if (httpOk && json && json.type === 'message') return { ok: true, status, model: json.model, message: mt('err.testOk', { model: json.model }) }; - const msg = (json && json.error && json.error.message) || text.slice(0, 200) || `HTTP ${status}`; - return { ok: false, status, message: msg }; - } catch (e) { - return { ok: false, message: e.name === 'AbortError' || e.timeout ? mt('err.timeout') : e.message }; - } finally { - clearTimeout(timer); - } -} - -function registerIpc() { - ipcMain.handle('config:get', () => store.get()); - - ipcMain.handle('config:save', async (_e, next) => { - const prevPort = store.get().port; - const nextPort = Number(next && next.port) || prevPort; - const wasConnected = claude.isConnected(prevPort); - - if (nextPort !== prevPort) { - // bind the NEW port before committing it, so a bad port never locks the user out - await gateway.stop(); - try { - await gateway.start(nextPort); - lastStartError = null; - } catch (e) { - lastStartError = mt('err.portFailed', { port: nextPort, msg: e.message }); - try { await gateway.start(prevPort); } catch (_) {} - broadcast('gateway:status', statusPayload()); - pushGatewayLog({ level: 'error', msg: lastStartError }); - throw new Error(lastStartError); - } - } - const prevDirs = JSON.stringify(store.get().historyDirs); - const saved = store.save(next); - applyOpenAtLogin(saved); - // keep Claude Code settings in sync if currently connected (port / token changes) - if (wasConnected) { try { claude.connect(saved.port, currentToken(), store); } catch (_) {} } - // history dirs changed → re-watch + recompute usage from the new set - if (JSON.stringify(saved.historyDirs) !== prevDirs) { - if (history) try { history.refresh(); } catch (_) {} - if (insights) insights.invalidate(); - broadcast('history:changed', { files: [] }); - } - updateTray(); - broadcast('gateway:status', statusPayload()); - return saved; - }); - - ipcMain.handle('provider:upsert', async (_e, provider) => { - const cfg = JSON.parse(JSON.stringify(store.get())); - if (provider.id) { - const i = cfg.providers.findIndex((p) => p.id === provider.id); - if (i >= 0) cfg.providers[i] = provider; else cfg.providers.push(provider); - } else { - provider.id = genId(); - cfg.providers.push(provider); - if (!cfg.activeProviderId) cfg.activeProviderId = provider.id; - } - const saved = store.save(cfg); - updateTray(); - return saved; - }); - - ipcMain.handle('provider:delete', async (_e, id) => { - const cfg = JSON.parse(JSON.stringify(store.get())); - cfg.providers = cfg.providers.filter((p) => p.id !== id); - if (cfg.activeProviderId === id) cfg.activeProviderId = cfg.providers[0] ? cfg.providers[0].id : null; - const saved = store.save(cfg); - updateTray(); - return saved; - }); - - ipcMain.handle('provider:setActive', async (_e, id) => { - const cfg = JSON.parse(JSON.stringify(store.get())); - cfg.activeProviderId = id; - const saved = store.save(cfg); - updateTray(); - broadcast('gateway:status', statusPayload()); - return saved; - }); - - ipcMain.handle('provider:test', async (_e, provider) => testProvider(provider)); - - ipcMain.handle('claude:connect', async () => doConnect()); - ipcMain.handle('claude:disconnect', async () => doDisconnect()); - ipcMain.handle('gateway:setEnabled', async (_e, on) => setGatewayEnabled(on)); - - // One-click Claude Desktop ("Third-Party Inference") integration — delivered as a macOS - // Configuration Profile the user approves once (install) / removes via admin prompt (restore). - ipcMain.handle('claudeDesktop:status', () => claudeDesktop.status(store.get().port)); - ipcMain.handle('claudeDesktop:connect', async () => { - const cfg = store.get(); - if (!claudeDesktop.appInstalled()) return { ok: false, reason: 'notInstalled' }; - if (!cfg.providers.length) return { ok: false, reason: 'noProvider' }; - // Claude Desktop must be able to reach the gateway → ensure it's listening first. - if (!(gateway && gateway.status() && gateway.status().running)) { - try { await gateway.start(cfg.port); lastStartError = null; } - catch (e) { - lastStartError = mt('err.portFailed', { port: cfg.port, msg: e.message }); - broadcast('gateway:status', statusPayload()); - return { ok: false, reason: 'gateway', message: lastStartError }; - } - updateTray(); - broadcast('gateway:status', statusPayload()); - } - return claudeDesktop.connect(cfg.port, currentToken()); - }); - ipcMain.handle('claudeDesktop:disconnect', async () => { - const res = await claudeDesktop.disconnect(); - broadcast('gateway:status', statusPayload()); - return res; - }); - - // Open a past conversation's .jsonl in Claude Desktop for replay/analysis via the official - // `claude://` deep link: a new Cowork chat with the file attached and a prompt prefilled. The user - // reviews and presses send. Cowork's `file=` param supports attaching a local absolute path (Claude - // prompts for permission on first use). Cross-platform (macOS/Windows) — no UI automation needed. - ipcMain.handle('claudeDesktop:replay', async (_e, file) => { - if (!file) return { ok: false, reason: 'noFile' }; - if (process.platform === 'darwin' && !claudeDesktop.appInstalled()) return { ok: false, reason: 'notInstalled' }; - const prompt = mt('desktop.replayPrompt').slice(0, 13000); // q is truncated ~14k by Claude - // Attach the main session AND every subagent transcript (they live in a separate subagents/ dir), - // each as its own `file=` — the Cowork deep link honors repeated `file=` — so the analysis covers - // subagent runs, not just the main thread. - const files = [file].concat(subagentTranscriptPaths(file)); - const url = `claude://cowork/new?q=${encodeURIComponent(prompt)}` - + files.map((f) => `&file=${encodeURIComponent(f)}`).join(''); - try { await shell.openExternal(url); return { ok: true }; } - catch (e) { return { ok: false, reason: 'failed', message: e && e.message }; } - }); - - ipcMain.handle('server:status', () => statusPayload()); - - ipcMain.handle('usage:get', (_e, range) => (insights ? insights.query(range || '7d') : { range, heatmap: [], byModel: [], byProvider: [] })); - - // Monitor inspector: full captured exchange (headers + bodies) for one forwarded request. - ipcMain.handle('monitor:get', (_e, id) => (monitor ? monitor.get(id) : null)); - ipcMain.handle('monitor:clear', () => { if (monitor) monitor.clear(); return true; }); - ipcMain.handle('gateway:logs', () => gatewayLogs.slice()); - ipcMain.handle('gateway:logs:clear', () => { gatewayLogs.length = 0; return true; }); - - // On-disk conversation history across the configured Claude config dirs. - ipcMain.handle('history:projects', () => (history ? history.listProjects(store.get().historyActive) : [])); - ipcMain.handle('history:list', () => (history ? history.listSessions(store.get().historyActive) : [])); - ipcMain.handle('history:get', (_e, file) => (history ? history.getSession(file) : null)); - ipcMain.handle('history:dirs', () => ({ dirs: history ? history.dirStats() : [], active: store.get().historyActive })); - ipcMain.handle('history:pickDir', async () => { - const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; - let res; - try { - // showHiddenFiles → dot-directories like ~/.claude are visible by default. - res = await dialog.showOpenDialog(win, { - title: mt('dialog.pickTitle'), - message: mt('dialog.pickMessage'), - defaultPath: os.homedir(), - buttonLabel: mt('dialog.pickButton'), - properties: ['openDirectory', 'showHiddenFiles', 'createDirectory'], - }); - } catch (e) { - return { canceled: true, error: e && e.message }; - } - if (res.canceled || !res.filePaths || !res.filePaths.length) return { canceled: true }; - let picked = res.filePaths[0]; - // If the user drilled into the projects/ (Claude) or sessions/ (Codex) data dir itself, - // store its parent (the config dir). - try { - const base = path.basename(picked); - if ((base === 'projects' && !fs.existsSync(path.join(picked, 'projects'))) || - (base === 'sessions' && !fs.existsSync(path.join(picked, 'sessions')))) { - picked = path.dirname(picked); - } - } catch (_) {} - return { canceled: false, path: picked }; - }); - ipcMain.handle('history:setActive', (_e, id) => { - const cfg = JSON.parse(JSON.stringify(store.get())); - cfg.historyActive = id || 'all'; - const saved = store.save(cfg); - if (insights) insights.invalidate(); - updateTrayTitle(); - broadcast('history:changed', { files: [], active: saved.historyActive }); - return { active: saved.historyActive }; - }); - - // ---- import: copy someone else's .jsonl into the app-managed store (snapshot; the original may - // be deleted/moved without consequence). Laid out like a native projects/ tree so the rest of - // the pipeline renders it identically; a sidecar .import.json records provenance. ---- - function encodeCwd(cwd) { - // Mirror Claude Code's lossy dir encoding (decodeDirName is the inverse): '/foo/bar' → '-foo-bar'. - return cwd ? String(cwd).replace(/[/\\]/g, '-') : '-imported'; - } - // Snapshot a transcript (already read into `raw`) + its subagent sidecars into the import store, - // laid out like a native projects/ tree + a provenance sidecar. `subFiles`: [{ name, data }] to - // drop under `/subagents/` (names are basename-reduced + pattern-checked). Shared by the - // plain-.jsonl and .zip-bundle import paths. - function writeImported(raw, originalPath, originalName, subFiles, out) { - const codex = require('./codex'); - const recs = []; - for (const line of String(raw).split('\n')) { const s = line.trim(); if (!s) continue; try { recs.push(JSON.parse(s)); } catch (_) {} } - const isCodex = codex.looksCodex(recs); - const hasMsg = recs.some((r) => r && (r.type === 'user' || r.type === 'assistant') && r.message); - if (!hasMsg && !isCodex) { out.failed++; return; } // not a Claude Code / Codex transcript - // Codex rollouts keep cwd/session id inside the session_meta payload, not on the records. - const ids = isCodex ? codex.headIds(recs) : (recs.find((r) => r && r.cwd) || recs.find((r) => r && r.sessionId) || {}); - const baseId = ids.sessionId || path.basename(originalName, path.extname(originalName)) || 'import'; - const destDir = path.join(importsRoot(), 'projects', encodeCwd(ids.cwd)); - const destFile = path.join(destDir, baseId + '.jsonl'); - if (fs.existsSync(destFile)) { out.skipped++; return; } // same session already imported - try { - fs.mkdirSync(destDir, { recursive: true }); - fs.writeFileSync(destFile, raw); - if (subFiles && subFiles.length) { - const subDir = path.join(destDir, baseId, 'subagents'); - fs.mkdirSync(subDir, { recursive: true }); - for (const f of subFiles) { - const safe = path.basename(f.name); // strip any dir component so the write can't escape subDir - if (/^agent-.*\.jsonl$/i.test(safe) || /^agent-.*\.meta\.json$/i.test(safe)) fs.writeFileSync(path.join(subDir, safe), f.data); - } - } - fs.writeFileSync(destFile.replace(/\.jsonl$/, '.import.json'), - JSON.stringify({ originalPath, originalName, sessionId: baseId, importedAt: Date.now() }, null, 2), 'utf8'); - out.imported++; - } catch (e) { out.failed++; } - } - // Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. - function importOne(src, out) { - let raw; - try { raw = fs.readFileSync(src, 'utf8'); } catch (e) { out.failed++; return; } - writeImported(raw, src, path.basename(src), readSubagentFiles(src), out); - } - // Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout. - function importZip(src, out) { - let buf; - try { buf = fs.readFileSync(src); } catch (e) { out.failed++; return; } - const { main, subagents } = zipStore.splitBundle(zipStore.readZip(buf)); - if (!main) { out.failed++; return; } - writeImported(main.data.toString('utf8'), src, path.basename(src), subagents, out); - } - // Route by extension: .zip → bundle import, .jsonl → plain import, anything else → failed. - function importAny(src, out) { - if (/\.zip$/i.test(src)) importZip(src, out); - else if (/\.jsonl$/i.test(src)) importOne(src, out); - else out.failed++; - } - ipcMain.handle('history:import', async () => { - const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; - let res; - try { - res = await dialog.showOpenDialog(win, { - title: mt('conv.importTitle'), - defaultPath: app.getPath('downloads'), - buttonLabel: mt('conv.importButton'), - properties: ['openFile', 'multiSelections', 'showHiddenFiles'], - filters: [{ name: 'Conversation', extensions: ['jsonl', 'zip'] }], - }); - } catch (e) { return { canceled: true, error: e && e.message }; } - if (res.canceled || !res.filePaths || !res.filePaths.length) return { canceled: true }; - const out = { imported: 0, skipped: 0, failed: 0 }; - for (const src of res.filePaths) importAny(src, out); - broadcast('history:changed', { files: [], active: store.get().historyActive }); - return out; - }); - // Import by absolute path(s) — drives the drag-and-drop entry point. importAny routes .zip bundles - // and .jsonl transcripts, validating each is a real Claude Code session (has user/assistant message - // records) before copying it in, so anything else just lands in `failed`. Mirrors history:import. - ipcMain.handle('history:importPaths', (_e, paths) => { - const out = { imported: 0, skipped: 0, failed: 0 }; - for (const src of (Array.isArray(paths) ? paths : [])) { - if (typeof src === 'string') importAny(src, out); - else out.failed++; - } - if (out.imported || out.skipped || out.failed) broadcast('history:changed', { files: [], active: store.get().historyActive }); - return out; - }); - ipcMain.handle('history:removeImport', async (_e, file) => { - if (!file) return { ok: false }; - const root = path.resolve(importsRoot()); - const f = path.resolve(file); - // Hard safety: only ever delete inside our own import store. - if (f !== root && !f.startsWith(root + path.sep)) return { ok: false, error: 'outside import store' }; - // Confirm via a native message box (localized buttons — window.confirm can't set them to Chinese). - const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; - try { - const r = await dialog.showMessageBox(win, { - type: 'warning', - buttons: [mt('modal.cancel'), mt('conv.removeImport')], - defaultId: 1, cancelId: 0, - message: mt('conv.removeImportConfirm'), - }); - if (r.response !== 1) return { ok: false, canceled: true }; - } catch (e) { return { ok: false, error: e && e.message }; } - try { - const dir = path.dirname(f), base = path.basename(f, '.jsonl'); - fs.rmSync(f, { force: true }); - fs.rmSync(path.join(dir, base + '.import.json'), { force: true }); - fs.rmSync(path.join(dir, base), { recursive: true, force: true }); // subagents/ - } catch (e) { return { ok: false, error: e && e.message }; } - broadcast('history:changed', { files: [], active: store.get().historyActive }); - return { ok: true }; - }); - - // Set per-conversation customization (custom title + user tags) — persisted as a `__ccbud__` - // field on the session file's first line. Broadcast so the open list refreshes immediately. - ipcMain.handle('history:setMeta', (_e, file, patch) => { - if (!history || !file) return { ok: false }; - const r = history.setCcbud(file, patch || {}); - if (r && r.ok) broadcast('history:changed', { files: [file] }); - return r; - }); - - // Export a conversation: raw .jsonl (verbatim source) or a self-contained .html the - // renderer assembled (inlined styles + rendered timeline). Both go through a save dialog. - function saveDialogPath(defName, ext, extLabel) { - const win = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; - return dialog.showSaveDialog(win, { - title: mt('dialog.exportTitle'), - defaultPath: path.join(app.getPath('downloads'), defName), - filters: [{ name: extLabel, extensions: [ext] }], - }); - } - // Default export name: --., both timestamps as YYMMDDHHmm - // (local time). Earlier the JSONL kept the on-disk basename and the HTML used the first user - // message — both were collision-prone when exporting many conversations from the same project. - function exportBaseName(file) { - const fmt = (d) => { - const p = (n) => String(n).padStart(2, '0'); - return String(d.getFullYear()).slice(-2) + p(d.getMonth() + 1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()); - }; - const sanitize = (s) => String(s || '').replace(/[\/\\:*?"<>|\n\r]+/g, '_').replace(/\s+/g, '_').replace(/^[_.\-]+|[_.\-]+$/g, '').slice(0, 60); - try { - const exportHtml = require('./exportHtml'); - const data = exportHtml.buildData(file); - const project = sanitize(data.meta.project) || 'conversation'; - const convTs = data.meta.firstTs ? new Date(data.meta.firstTs) : null; - const convPart = convTs && !isNaN(convTs.getTime()) ? fmt(convTs) : 'unknown'; - return project + '-' + convPart + '-' + fmt(new Date()); - } catch (_) { - return path.basename(file, '.jsonl'); - } - } - ipcMain.handle('history:exportRaw', async (_e, file) => { - if (!file) return { canceled: true, error: 'no file' }; - // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); - // a plain session stays a verbatim .jsonl. history:importPaths accepts either. - const subFiles = readSubagentFiles(file); - if (subFiles.length) { - let zipBuf; - try { - const entries = [{ name: path.basename(file), data: fs.readFileSync(file) }] - .concat(subFiles.map((s) => ({ name: 'subagents/' + s.name, data: s.data }))); - zipBuf = zipStore.buildZip(entries); - } catch (e) { return { canceled: true, error: e && e.message }; } - let res; - try { res = await saveDialogPath(exportBaseName(file) + '.zip', 'zip', 'ZIP'); } catch (e) { return { canceled: true, error: e && e.message }; } - if (res.canceled || !res.filePath) return { canceled: true }; - try { fs.writeFileSync(res.filePath, zipBuf); } catch (e) { return { canceled: true, error: e && e.message }; } - return { canceled: false, path: res.filePath, bundled: true }; - } - let data; - try { data = fs.readFileSync(file, 'utf8'); } catch (e) { return { canceled: true, error: e && e.message }; } - let res; - try { res = await saveDialogPath(exportBaseName(file) + '.jsonl', 'jsonl', 'JSONL'); } catch (e) { return { canceled: true, error: e && e.message }; } - if (res.canceled || !res.filePath) return { canceled: true }; - try { fs.writeFileSync(res.filePath, data, 'utf8'); } catch (e) { return { canceled: true, error: e && e.message }; } - return { canceled: false, path: res.filePath, bundled: false }; - }); - // Build the standalone Claude-styled viewer (+ embedded subagent dialogues, read from - // disk here since the renderer never loads them) and save it. - ipcMain.handle('history:exportHtml', async (_e, file) => { - if (!file) return { canceled: true, error: 'no file' }; - let html; - try { - const exportHtml = require('./exportHtml'); - html = exportHtml.htmlFromData(exportHtml.buildData(file)); - } catch (e) { return { canceled: true, error: e && e.message }; } - let res; - try { res = await saveDialogPath(exportBaseName(file) + '.html', 'html', 'HTML'); } catch (e) { return { canceled: true, error: e && e.message }; } - if (res.canceled || !res.filePath) return { canceled: true }; - try { fs.writeFileSync(res.filePath, html, 'utf8'); } catch (e) { return { canceled: true, error: e && e.message }; } - // Open the freshly-exported viewer in the user's default browser so they don't have to go - // hunting for it in the file system (issue #7). - try { shell.openPath(res.filePath); } catch (_) {} - return { canceled: false, path: res.filePath }; - }); - - ipcMain.handle('app:openMain', () => { showWindow(); return true; }); - ipcMain.handle('app:quit', () => { app.quit(); return true; }); - ipcMain.handle('window:settingsMode', (_e, on) => { setSettingsWindowMode(!!on); return true; }); - // Per-view window minimum width (renderer drives it on view switch): 对话 needs 1300 for its - // 3-column layout; other views can go down to ~900 so a wide window doesn't leave side gaps. - ipcMain.handle('window:viewMinWidth', (_e, w) => { - const win = mainWindow; - if (!win || win.isDestroyed()) return false; - try { win.setMinimumSize(Math.max(600, (w | 0) || 900), 600); } catch (_) {} - return true; - }); - - ipcMain.handle('util:copy', (_e, text) => { clipboard.writeText(String(text || '')); return true; }); - ipcMain.handle('util:openExternal', (_e, url) => { - try { - const u = new URL(String(url || '')); - if (u.protocol === 'https:' || u.protocol === 'http:') { shell.openExternal(u.href); return true; } - } catch (_) {} - return false; - }); - - // In-app updates. `update:state` is the cached snapshot (versions + last check); `update:check` - // hits GitHub; `update:download` stages a hot bundle; `update:apply` relaunches into it. - ipcMain.handle('update:state', () => updater.publicState()); - ipcMain.handle('update:check', async () => updater.checkForUpdates({ manual: true })); - ipcMain.handle('update:download', async () => updater.downloadAndStageHot()); - ipcMain.handle('update:apply', () => { updater.relaunchToApply(); return true; }); - ipcMain.handle('update:setAuto', (_e, patch) => { - const cfg = JSON.parse(JSON.stringify(store.get())); - cfg.autoUpdate = Object.assign({}, cfg.autoUpdate, patch || {}); - return store.save(cfg).autoUpdate; - }); -} - -// Auto-update: on launch (after a short delay) and once a day, check GitHub. When a release -// qualifies for a hot (JS-only) update and autoDownload is on, stage it silently — it applies on -// the next launch (we never force a relaunch). Full (native) updates only surface in the UI. -let updateTimer = 0; -async function runAutoUpdate() { - try { - if (!(store.get().autoUpdate || {}).check) return; - const st = await updater.checkForUpdates({ auto: true }); - if (st && st.ok && st.mode === 'hot' && (store.get().autoUpdate || {}).autoDownload && !(st.pending && st.pending.staged)) { - const r = await updater.downloadAndStageHot(); - if (r && r.ok) broadcast('update:staged', { version: r.version }); - } - } catch (_) {} -} -function scheduleAutoUpdate() { - const kick = () => { runAutoUpdate(); }; - setTimeout(kick, 8000); // give the gateway/tray time to settle first - updateTimer = setInterval(kick, 24 * 60 * 60 * 1000); - if (updateTimer && updateTimer.unref) updateTimer.unref(); -} - -function applyOpenAtLogin(cfg) { - try { app.setLoginItemSettings({ openAtLogin: !!(cfg && cfg.openAtLogin) }); } catch (_) {} -} - -/* ---------- tray ---------- */ -function buildTrayMenu() { - const cfg = store.get(); - const connected = claude.isConnected(cfg.port); - const ap = cfg.providers.find((p) => p.id === cfg.activeProviderId); - return Menu.buildFromTemplate([ - { label: connected ? (ap ? mt('tray.connectedWith', { name: ap.name }) : mt('status.connected')) : mt('tray.disconnected'), enabled: false }, - { type: 'separator' }, - { label: mt('tray.openMain'), click: () => showWindow() }, - connected - ? { label: mt('tray.disconnect'), click: () => doDisconnect() } - : { label: mt('tray.connect'), click: () => doConnect() }, - { type: 'separator' }, - { label: mt('tray.checkUpdates'), click: () => { showWindow(); setTimeout(() => broadcast('update:openPane'), 250); } }, - { label: mt('tray.quit'), click: () => app.quit() }, - ]); -} -async function updateTrayTitle() { - if (!tray || process.platform !== 'darwin') return; - const tu = (store.get().trayUsage) || {}; - if (tu.enabled && insights) { - try { - const tokens = await insights.rangeTokens(tu.range || '7d'); - if (tray) { - tray.setTitle(' ' + formatTokens(tokens)); - } - } catch (_) { - if (tray) { - tray.setTitle(''); - } - } - } else { - tray.setTitle(''); - } -} -function updateTray() { - updateTrayTitle(); -} - -/* ---------- tray popover (rich usage panel) ---------- */ -function createPopover() { - popover = new BrowserWindow({ - width: 424, - height: 344, - show: false, - frame: false, - resizable: false, - movable: false, - transparent: true, - skipTaskbar: true, - alwaysOnTop: true, - fullscreenable: false, - backgroundColor: '#00000000', - webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false }, - }); - // Show on whatever Space/desktop the user is currently on (and over fullscreen apps), instead of - // yanking them to the Space where the main window lives when the menu-bar icon is clicked. - try { popover.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } catch (_) {} - popover.loadFile(path.join(__dirname, '..', 'renderer', 'popover.html')); - popover.on('blur', () => hidePopover()); -} -function positionPopover() { - const { screen } = require('electron'); - const b = tray.getBounds(); - const wb = popover.getBounds(); - const area = screen.getDisplayMatching(b).workArea; - let x = Math.round(b.x + b.width / 2 - wb.width / 2); - x = Math.max(area.x + 4, Math.min(x, area.x + area.width - wb.width - 4)); - const y = process.platform === 'darwin' ? Math.round(b.y + b.height + 2) : Math.round(area.y + 4); - popover.setPosition(x, y, false); -} -function hidePopover() { - if (popover && popover.isVisible()) { popover.hide(); lastPopoverHide = Date.now(); } -} -function togglePopover() { - if (!popover || popover.isDestroyed()) createPopover(); - if (popover.isVisible()) { hidePopover(); return; } - if (Date.now() - lastPopoverHide < 250) return; // debounce click-after-blur - positionPopover(); - popover.show(); - popover.webContents.send('popover:show'); -} - -// macOS Dock visibility follows the main window: shown while a window is open, hidden when it's -// closed so ccbud drops to a menu-bar-only background app (the tray icon stays). Guarded/no-op off -// macOS. The Dock icon *image* is set once at startup; these just toggle its presence. -function showDock() { if (process.platform === 'darwin' && app.dock) { try { app.dock.show(); } catch (_) {} } } -function hideDock() { if (process.platform === 'darwin' && app.dock) { try { app.dock.hide(); } catch (_) {} } } - -function showWindow() { - showDock(); - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } else { - createWindow(); - } -} - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1400, - height: 920, - // Per-view minimum width: the renderer raises this to 1300 for the 3-column 对话 view and - // drops it to 900 for the others (window:viewMinWidth). Startup view is 服务 (non-对话) → 900. - minWidth: 900, - minHeight: 600, - titleBarStyle: 'hidden', - trafficLightPosition: { x: 20, y: 20 }, - vibrancy: 'under-window', - visualEffectState: 'active', - backgroundColor: '#00000000', - title: 'CC Buddy — Coding CLI Buddy', - webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false }, - }); - mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); - showDock(); // a visible main window means ccbud should appear in the Dock - mainWindow.on('closed', () => { - mainWindow = null; - // Closing the window (red ×) returns ccbud to the menu bar only — hide the Dock icon. It comes - // back when the window is reopened (tray → Open Main, or Dock/activate). Skip during quit. - if (!isQuitting) hideDock(); - }); -} - -// All views — including Settings — now share ONE freely-resizable window with a unified minimum -// size (set in createWindow). Settings is no longer a fixed-size special case; this stays as a -// no-op so the renderer's settings-mode IPC call remains valid without changing behavior. -function setSettingsWindowMode(_on) { /* no-op: settings no longer locks window size */ } - -if (gotLock) { - app.whenReady().then(async () => { - const userData = app.getPath('userData'); - // Settings + user data now live under ~/.ccbud (portable across uninstall/reinstall). On first run - // there, migrate from the legacy locations: the app's userData, and the older "clawdy"-named one — - // so existing providers/settings/imports survive the move. - try { - fs.mkdirSync(CCBUD_HOME, { recursive: true, mode: 0o700 }); - if (!fs.existsSync(path.join(CCBUD_HOME, 'config.json'))) { - for (const old of [userData, path.join(path.dirname(userData), 'clawdy')]) { - if (old === CCBUD_HOME || !fs.existsSync(path.join(old, 'config.json'))) continue; - for (const name of ['config.json', 'requests.log']) { - const from = path.join(old, name); - if (fs.existsSync(from)) { try { fs.copyFileSync(from, path.join(CCBUD_HOME, name)); } catch (_) {} } - } - const oldImports = path.join(old, 'imports'); - if (fs.existsSync(oldImports)) { try { fs.cpSync(oldImports, path.join(CCBUD_HOME, 'imports'), { recursive: true }); } catch (_) {} } - break; - } - } - } catch (_) {} - requestLogPath = path.join(CCBUD_HOME, 'requests.log'); - store = createStore(CCBUD_HOME); - // First run: pick the UI language from the system locale (then it's user-controlled). - if (!store.get().language) { - try { store.save(Object.assign({}, store.get(), { language: mapLocale(app.getLocale()) })); } catch (_) {} - } - // A detected Codex install joins historyDirs as a regular work dir (one-time, before the - // history watcher starts so its trees get watched). - try { ensureCodexDir(); } catch (_) {} - monitor = createMonitorStore({ max: 100 }); - gateway = createGateway({ getConfig: () => store.get() }); - gateway.on('log', (l) => pushGatewayLog(l)); - gateway.on('request', (r) => { - appendRequestLog(r); - broadcast('gateway:request', r); - }); - // Full request/response capture (bounded, auth-redacted) for the monitor inspector. - gateway.on('exchange', (ex) => monitor.record(ex)); - - // Usage analytics computed from on-disk history (.jsonl) across the active config dirs — - // both the Claude projects/ tree and each dir's sibling Codex sessions/ tree. - insights = createInsights({ - getDirs: () => activeProjectsDirs(), - getSessionDirs: () => activeProjectsDirs().map((d) => path.join(path.dirname(d), 'sessions')), - }); - - // Watch Claude Code's on-disk session history across ALL configured dirs; the "对话" - // view reads it directly and live-follows active sessions via the 'changed' broadcast. - history = createHistoryWatcher({ getDirs: () => historyDirsList() }); - history.on('changed', (p) => { - const files = (p && p.files) || []; - files.forEach((f) => insights && insights.invalidate(f)); - markHistoryDirty(files); - updateTrayTitle(); - }); - try { history.start(); } catch (_) {} - - registerIpc(); - - // Hot-update plumbing: confirm this boot succeeded (so a freshly-applied bundle isn't rolled - // back) and kick off background update checks. - updater.init({ - userData, - getConfig: () => store.get(), - broadcast, - log: (msg) => pushGatewayLog({ level: 'info', msg: '[update] ' + msg }), - }); - setTimeout(() => updater.confirmBootSuccess(), 4000); - scheduleAutoUpdate(); - - if (store.get().openAtLogin) applyOpenAtLogin(store.get()); - - // The gateway is an independent service with its own switch (default on). - if (store.get().gatewayEnabled !== false) { - try { await gateway.start(store.get().port); lastStartError = null; } - catch (e) { lastStartError = mt('err.portFailed', { port: store.get().port, msg: e.message }); } - } - - try { - let img; - if (process.platform === 'darwin') { - img = nativeImage.createFromPath(path.join(__dirname, 'iconTemplate.png')).resize({ width: 18, height: 18 }); - img.setTemplateImage(true); - } else { - img = nativeImage.createFromPath(path.join(__dirname, 'icon.png')).resize({ width: 18, height: 18 }); - } - tray = new Tray(img); - tray.setToolTip('CC Buddy — Coding CLI Buddy'); - // Wire the handlers right after creating the Tray so a later failure (e.g. popover) can't - // leave the menu-bar icon inert. The popover is best-effort. - tray.on('click', () => togglePopover()); - tray.on('right-click', () => tray.popUpContextMenu(buildTrayMenu())); - try { createPopover(); } catch (_) {} - updateTrayTitle(); - } catch (_) { /* tray optional */ } - - // refresh the menu-bar token count periodically (range may roll over by day) - titleTimer = setInterval(() => updateTrayTitle(), 60000); - if (titleTimer && titleTimer.unref) titleTimer.unref(); - - createWindow(); - // Dock-icon click. Use showWindow() (not a getAllWindows().length check): the tray popover is also - // a BrowserWindow, so after the main window is closed the count is still ≥1 and the old check did - // nothing — leaving the Dock icon dead until you used the tray menu. showWindow() re-creates/shows it. - app.on('activate', () => showWindow()); - - // macOS: set the Dock icon image once. Dock *visibility* follows the window (showDock/hideDock); - // done after the tray + window are up so it never interferes with their setup. - if (process.platform === 'darwin' && app.dock) { - try { - const dockImg = nativeImage.createFromPath(path.join(__dirname, 'icon.png')); - if (dockImg && !dockImg.isEmpty()) app.dock.setIcon(dockImg); - } catch (_) {} - } - }); -} - -// Keep running in the tray after the window is closed (the gateway must stay up while -// Claude Code is connected). Quit explicitly via the tray menu or Cmd+Q. -app.on('window-all-closed', () => {}); - -app.on('before-quit', (e) => { - if (isQuitting || !gateway) return; - isQuitting = true; - e.preventDefault(); - if (historyTimer) { clearTimeout(historyTimer); historyTimer = null; } - try { if (history) history.stop(); } catch (_) {} - Promise.resolve(gateway.stop()).finally(() => app.exit(0)); -}); diff --git a/src/main/monitor.js b/src/main/monitor.js deleted file mode 100644 index 12588fc..0000000 --- a/src/main/monitor.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -/** - * In-memory ring buffer of the gateway's most recent HTTP exchanges, so the monitor view - * can open any forwarded request and inspect its full request/response headers + bodies. - * - * Live debugging tool, not history — kept in memory only (cleared on quit / 清空), bounded - * to `max` entries. Bodies are already capped + auth headers redacted by the proxy before - * they reach here, so this store just holds and indexes them by id. - */ - -function createMonitorStore(opts) { - const max = (opts && opts.max) || 100; - const order = []; // ids, oldest first - const byId = new Map(); - - function record(ex) { - if (!ex || ex.id == null) return; - if (!byId.has(ex.id)) order.push(ex.id); - byId.set(ex.id, ex); - while (order.length > max) { - const old = order.shift(); - byId.delete(old); - } - } - - function get(id) { - if (id == null) return null; - return byId.get(id) || byId.get(Number(id)) || byId.get(String(id)) || null; - } - - function clear() { - order.length = 0; - byId.clear(); - } - - return { record, get, clear, size: () => order.length }; -} - -module.exports = { createMonitorStore }; diff --git a/src/main/preload.js b/src/main/preload.js deleted file mode 100644 index 67c4fcd..0000000 --- a/src/main/preload.js +++ /dev/null @@ -1,76 +0,0 @@ -'use strict'; - -const { contextBridge, ipcRenderer, webUtils } = require('electron'); - -contextBridge.exposeInMainWorld('ccbud', { - getConfig: () => ipcRenderer.invoke('config:get'), - saveConfig: (cfg) => ipcRenderer.invoke('config:save', cfg), - - upsertProvider: (p) => ipcRenderer.invoke('provider:upsert', p), - deleteProvider: (id) => ipcRenderer.invoke('provider:delete', id), - setActive: (id) => ipcRenderer.invoke('provider:setActive', id), - testProvider: (p) => ipcRenderer.invoke('provider:test', p), - - // one-click Claude Code integration - connect: () => ipcRenderer.invoke('claude:connect'), - disconnect: () => ipcRenderer.invoke('claude:disconnect'), - - // one-click Claude Desktop ("Third-Party Inference") integration - desktopStatus: () => ipcRenderer.invoke('claudeDesktop:status'), - desktopConnect: () => ipcRenderer.invoke('claudeDesktop:connect'), - desktopDisconnect: () => ipcRenderer.invoke('claudeDesktop:disconnect'), - desktopReplay: (file) => ipcRenderer.invoke('claudeDesktop:replay', file), - - serverStatus: () => ipcRenderer.invoke('server:status'), - - // usage panel - usageGet: (range) => ipcRenderer.invoke('usage:get', range), - - // monitor inspector — full captured request/response for a forwarded request - monitorGet: (id) => ipcRenderer.invoke('monitor:get', id), - gatewaySetEnabled: (on) => ipcRenderer.invoke('gateway:setEnabled', on), - monitorClear: () => ipcRenderer.invoke('monitor:clear'), - - // gateway log buffer — backfill the "网关日志" panel on open (events aren't replayed otherwise) - logsGet: () => ipcRenderer.invoke('gateway:logs'), - logsClear: () => ipcRenderer.invoke('gateway:logs:clear'), - openMain: () => ipcRenderer.invoke('app:openMain'), - quitApp: () => ipcRenderer.invoke('app:quit'), - setSettingsMode: (on) => ipcRenderer.invoke('window:settingsMode', on), - setViewMinWidth: (w) => ipcRenderer.invoke('window:viewMinWidth', w), - - // conversation history (reads the configured Claude config dirs' projects/*.jsonl) - historyProjects: () => ipcRenderer.invoke('history:projects'), - historyList: () => ipcRenderer.invoke('history:list'), - historyGet: (file) => ipcRenderer.invoke('history:get', file), - historyDirs: () => ipcRenderer.invoke('history:dirs'), - historyPickDir: () => ipcRenderer.invoke('history:pickDir'), - historySetActive: (id) => ipcRenderer.invoke('history:setActive', id), - historyImport: () => ipcRenderer.invoke('history:import'), - historyImportPaths: (paths) => ipcRenderer.invoke('history:importPaths', paths), - // Resolve a dragged File to its absolute path (Electron 32+ removed File.path → use webUtils). - pathForFile: (file) => { try { return webUtils.getPathForFile(file); } catch (_) { return (file && file.path) || ''; } }, - historyRemoveImport: (file) => ipcRenderer.invoke('history:removeImport', file), - historySetMeta: (file, patch) => ipcRenderer.invoke('history:setMeta', file, patch), - historyExportRaw: (file) => ipcRenderer.invoke('history:exportRaw', file), - historyExportHtml: (payload) => ipcRenderer.invoke('history:exportHtml', payload), - onHistoryChanged: (cb) => ipcRenderer.on('history:changed', (_e, p) => cb(p)), - - copy: (t) => ipcRenderer.invoke('util:copy', t), - openExternal: (u) => ipcRenderer.invoke('util:openExternal', u), - - // in-app updates - updateState: () => ipcRenderer.invoke('update:state'), - updateCheck: () => ipcRenderer.invoke('update:check'), - updateDownload: () => ipcRenderer.invoke('update:download'), - updateApply: () => ipcRenderer.invoke('update:apply'), - updateSetAuto: (patch) => ipcRenderer.invoke('update:setAuto', patch), - onUpdateState: (cb) => ipcRenderer.on('update:state', (_e, s) => cb(s)), - onUpdateStaged: (cb) => ipcRenderer.on('update:staged', (_e, s) => cb(s)), - onUpdateOpenPane: (cb) => ipcRenderer.on('update:openPane', () => cb()), - - onLog: (cb) => ipcRenderer.on('gateway:log', (_e, l) => cb(l)), - onRequest: (cb) => ipcRenderer.on('gateway:request', (_e, r) => cb(r)), - onStatus: (cb) => ipcRenderer.on('gateway:status', (_e, s) => cb(s)), - onPopoverShow: (cb) => ipcRenderer.on('popover:show', () => cb()), -}); diff --git a/src/main/proxy.js b/src/main/proxy.js deleted file mode 100644 index 4954d2b..0000000 --- a/src/main/proxy.js +++ /dev/null @@ -1,864 +0,0 @@ -'use strict'; - -/** - * ccbud Gateway — pure Node proxy core (no Electron dependency, fully testable). - * - * Responsibilities: - * - Listen on 127.0.0.1: - * - Forward every request to the matched upstream provider (baseUrl + token) - * - Replace the client Authorization/x-api-key with the upstream's real token - * - Resolve model routing: - * * explicit alias (alias -> upstream), routed to the owning provider - * * pass-through of a provider's real model - * * automatic mapping of Claude default model names to the active provider - * - Rewrite the response `model` field back to what the client asked for - * (covers both buffered JSON and streaming SSE message_start) - */ - -const http = require('http'); -const https = require('https'); -const zlib = require('zlib'); -const { estimateInputTokens } = require('./countTokens'); -const { CLAUDE_TIER_MODELS } = require('./claudeModels'); -const { URL } = require('url'); -const { Transform, Writable, pipeline } = require('stream'); -const { EventEmitter } = require('events'); - -function errorBody(message, type) { - return JSON.stringify({ - type: 'error', - error: { type: type || 'api_error', message }, - }); -} - -function respondJson(res, status, obj) { - const buf = Buffer.from(typeof obj === 'string' ? obj : JSON.stringify(obj), 'utf8'); - try { - res.writeHead(status, { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(buf), - }); - res.end(buf); - } catch (_) { - /* socket already gone */ - } -} - -/** Stable Codex identities advertised to OpenAI-family clients (mirror gateway.rs). */ -const CODEX_TIER_MODELS = [{ name: 'gpt-5.4' }, { name: 'gpt-5.4-mini' }]; - -/** Which coding-agent family a model name belongs to: 'claude' | 'codex' | 'other'. - * Claude Code sends claude-*, Codex sends gpt-*; each names its tiers differently. */ -function modelFamily(name) { - const n = (name || '').toLowerCase(); - if (/^claude[-_]/.test(n)) return 'claude'; - if (/^gpt[-_]/.test(n)) return 'codex'; - return 'other'; -} -/** Claude fast/light tier = haiku models; fable/opus/sonnet (and other claude-*) → primary. */ -function isClaudeFast(name) { return /haiku/i.test(name || ''); } -/** Stable gpt-5.4 and legacy sol/terra aliases → primary; other foreign gpt-* keep the fast fallback. */ -function isCodexPrimary(name) { - const lower = (name || '').toLowerCase(); - if (lower === 'gpt-5.4') return true; - const segments = lower.split(/[-_]/); - return !segments.some((s) => ['mini', 'nano', 'luna', 'spark'].includes(s)) - && segments.some((s) => s === 'sol' || s === 'terra'); -} -/** True if the request is from a Codex/OpenAI-family client — by client identity - * (User-Agent, or Codex's `originator` header), not the auth scheme. */ -function clientIsCodex(headers) { - const h = headers || {}; - const ua = String(h['user-agent'] || '').toLowerCase(); - const orig = String(h['originator'] || '').toLowerCase(); - return ua.includes('codex') || orig.includes('codex'); -} - -/** - * Decide how to route a request and translate its model name. - * Returns { provider, outgoingModel, clientFacingModel } or null. - * - * Unified rule (issue #10): EVERY request goes to the single active provider — we no - * longer hop to whichever provider happens to own a matching alias. Against that active - * provider the requested model id is resolved, in order: - * - * 1. a Custom alias of the active provider -> map alias -> the user's upstream name - * 2. the active provider's PRIMARY / LIGHTWEIGHT -> passthrough untouched - * 3. a model the provider really has -> passthrough untouched - * ("really has" = the upstream side of a configured alias, or present in the - * provider's live /v1/models list captured into `knownModels`) - * 4. any other unconfigured id (default mapping on): - * · Claude's main tiers (opus/sonnet/mythos/fable, …) -> PRIMARY - * · everything else unmatched (haiku, foreign names) -> LIGHTWEIGHT - * With per-provider default mapping turned off, the name is forwarded untouched. - * - * clientFacingModel === outgoingModel => pure passthrough (do NOT touch the response) - * clientFacingModel !== outgoingModel => we changed the model, so rewrite the response back - * - * @param {Set} [knownModels] real upstream model ids for the active provider. - */ -function resolveRouting(requestedModel, config, knownModels) { - const providers = (config && config.providers) || []; - if (providers.length === 0) return null; - - const active = providers.find((p) => p.id === config.activeProviderId) || providers[0]; - if (!active) return null; - - const pass = (m) => ({ provider: active, outgoingModel: m, clientFacingModel: m }); - - // No model on the request (e.g. a non-/v1/messages call) -> forward as-is. - if (!requestedModel) return { provider: active, outgoingModel: null, clientFacingModel: null }; - - const primary = active.defaultModel || ''; - const light = active.smallFastModel || ''; - - // 1) Custom alias of the ACTIVE provider -> rewrite to the user's upstream model. - for (const m of active.models || []) { - if (m && m.alias && m.alias === requestedModel && m.upstream) { - return { provider: active, outgoingModel: m.upstream, clientFacingModel: requestedModel }; - } - } - - // 2) Already the provider's PRIMARY or LIGHTWEIGHT model -> passthrough. - if (requestedModel === primary || requestedModel === light) return pass(requestedModel); - - // 3) A model the active provider really has -> passthrough. - for (const m of active.models || []) { - if (m && m.upstream === requestedModel) return pass(requestedModel); - } - if (knownModels && typeof knownModels.has === 'function' && knownModels.has(requestedModel)) { - return pass(requestedModel); - } - - // Codex connects with the sentinel model "gpt-5.5-ccbud" — a name Codex's model-family - // detection accepts (gpt-5.5 prefix), so it doesn't warn about an unknown model. Route the - // sentinel to the active provider's PRIMARY model (never the lightweight fallback). - if (requestedModel.endsWith('-ccbud')) { - const target = primary || light; - if (target) return { provider: active, outgoingModel: target, clientFacingModel: requestedModel }; - } - - // 4) Unconfigured id. With default mapping off, forward untouched (escape hatch). - if (active.mapDefaultModels === false) return pass(requestedModel); - - // Otherwise map onto the active provider's own models: Claude's main tiers -> PRIMARY, - // everything else unmatched (Claude small tiers + any foreign name) -> LIGHTWEIGHT. - const big = primary || light; - const small = light || primary; - // Classify by family: Claude and Codex name their primary vs fast tiers differently. - let target; - const fam = modelFamily(requestedModel); - if (fam === 'claude') target = isClaudeFast(requestedModel) ? small : big; - else if (fam === 'codex') target = isCodexPrimary(requestedModel) ? big : small; - else target = small; // unknown foreign model -> route to the known-good lightweight model - if (target) return { provider: active, outgoingModel: target, clientFacingModel: requestedModel }; - - // Nothing configured to map onto -> last-resort passthrough. - return pass(requestedModel); -} - -/** - * How long to wait before retrying an upstream 429. Honors a `Retry-After` header - * (delta-seconds or an HTTP-date) when present, otherwise exponential backoff from - * `base` (attempt 0,1,2 -> base, 2x, 4x). Always clamped so a hostile/huge value - * can't stall the request indefinitely. - */ -function retryDelay(retryAfter, attempt, base) { - const cap = 30000; - if (retryAfter != null) { - const s = String(retryAfter).trim(); - if (/^\d+$/.test(s)) return Math.min(parseInt(s, 10) * 1000, cap); - const when = Date.parse(s); - if (!Number.isNaN(when)) return Math.min(Math.max(when - Date.now(), 0), cap); - } - return Math.min((base || 500) * Math.pow(2, attempt), 8000); -} - -// Headers whose VALUES must never surface in the monitor inspector (real upstream key etc). -const REDACT_RE = /^(authorization|x-api-key|cookie|set-cookie|proxy-authorization|x-goog-api-key)$/i; -function redactHeaders(h) { - const o = {}; - for (const k of Object.keys(h || {})) o[k] = REDACT_RE.test(k) ? '••••••(已隐藏)' : h[k]; - return o; -} -// Cap a captured body so the in-memory inspector stays bounded; keep the true byte count. -// Request bodies get a generous cap so a full Claude Code request (entire history) is shown -// un-truncated for debugging; response/SSE bodies stay bounded since streams can be huge. -const REQ_CAP = 4 * 1024 * 1024; -const RES_CAP = 2 * 1024 * 1024; -function capText(buf, cap) { - const limit = cap || REQ_CAP; - if (!buf || !buf.length) return { text: '', bytes: 0, truncated: 0 }; - const total = buf.length; - if (total <= limit) return { text: buf.toString('utf8'), bytes: total, truncated: 0 }; - // Slicing at a byte boundary can split a multi-byte char → drop the trailing replacement char. - return { text: buf.slice(0, limit).toString('utf8').replace(/�+$/, ''), bytes: total, truncated: total - limit }; -} - -/* ---- /v1/models augmentation ---- - * Some providers don't implement /v1/models, and even those that do never list the user's - * configured aliases. So when a provider HAS the endpoint we pass its list through and ADD - * the alias models; when it doesn't (404 / unreachable) we synthesize the list from aliases. */ -function modelEntry(id) { - return { type: 'model', id, display_name: id, created_at: '2025-01-01T00:00:00Z' }; -} -function aliasModelEntries(config) { - const out = []; - const seen = new Set(); - for (const p of (config && config.providers) || []) { - for (const m of p.models || []) { - if (m && m.alias && !seen.has(m.alias)) { seen.add(m.alias); out.push(modelEntry(m.alias)); } - } - } - return out; -} -// Standard claude-* tier names, so Claude Desktop's Gateway picker has Anthropic-keyword names that -// also appear in /v1/models; the gateway tier-maps them onto the active provider. Harmless to other -// clients (Claude Code routes by tier; these names aren't fed to provider routing — see recordModels, -// which only learns from the provider's OWN /v1/models, not this synthesized list). -/** Default tier models for the requesting client's family (Codex → gpt, Claude → claude). */ -function tierEntries(isCodex) { - return (isCodex ? CODEX_TIER_MODELS : CLAUDE_TIER_MODELS).map((m) => modelEntry(m.name)); -} -function mergeModels(upstream, config, isCodex) { - const data = Array.isArray(upstream && upstream.data) ? upstream.data.slice() : []; - const have = new Set(data.map((m) => m && m.id)); - const adds = aliasModelEntries(config).concat(tierEntries(isCodex)).filter((a) => { - if (have.has(a.id)) return false; have.add(a.id); return true; - }); - const merged = Object.assign({}, upstream || {}); - merged.data = adds.concat(data); // aliases + tier defaults first so they stand out - return merged; -} -function synthesizeModels(config, isCodex) { - let out = aliasModelEntries(config); - if (!out.length) { - // no aliases configured → fall back to the active provider's real models so it isn't empty - const providers = (config && config.providers) || []; - const active = providers.find((p) => p.id === config.activeProviderId) || providers[0]; - const seen = new Set(); - for (const id of [active && active.defaultModel, active && active.smallFastModel]) { - if (id && !seen.has(id)) { seen.add(id); out.push(modelEntry(id)); } - } - } - // Advertise the requesting family's default tier names (Claude Desktop's picker, Codex). - const have = new Set(out.map((m) => m.id)); - for (const e of tierEntries(isCodex)) if (!have.has(e.id)) { have.add(e.id); out.push(e); } - return { data: out, has_more: false, first_id: out[0] ? out[0].id : null, last_id: out.length ? out[out.length - 1].id : null }; -} - -/** Normalize an Anthropic `usage` object into our token shape. */ -function extractUsage(u) { - if (!u) return null; - return { - inputTokens: u.input_tokens || 0, - outputTokens: u.output_tokens || 0, - cacheRead: u.cache_read_input_tokens || 0, - cacheCreation: u.cache_creation_input_tokens || 0, - }; -} - -/** - * SSE transform: passes the stream through unchanged except optionally rewriting the - * `model` field value (when `model` is non-null), while ALSO sniffing token usage from - * `message_start` (input/cache) and `message_delta` (cumulative output). Calls onUsage(u) - * at end-of-stream if any usage was seen. Line-buffered so JSON fields are never split. - */ -function createSseTransform(model, opts) { - const onUsage = typeof opts === 'function' ? opts : opts && opts.onUsage; - const replacement = model != null ? String(model).replace(/\$/g, '$$$$') : null; - const re = /("model"\s*:\s*")[^"]*(")/g; - let buffer = ''; - const usage = { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 }; - let saw = false; - - function absorbUsage(obj) { - if (obj && obj.type === 'message_start' && obj.message && obj.message.usage) { - const u = obj.message.usage; - usage.inputTokens += u.input_tokens || 0; - usage.cacheRead += u.cache_read_input_tokens || 0; - usage.cacheCreation += u.cache_creation_input_tokens || 0; - saw = true; - } else if (obj && obj.type === 'message_delta' && obj.usage) { - if (typeof obj.usage.output_tokens === 'number') usage.outputTokens = obj.usage.output_tokens; - saw = true; - } - } - function parseData(line) { - const i = line.indexOf('{'); - if (i < 0) return null; - try { return JSON.parse(line.slice(i)); } catch (_) { return null; } - } - function handleLine(line) { - // Sniff usage from the upstream payload BEFORE rewriting model names for the client. - if (line.indexOf('"usage"') !== -1) { - const obj = parseData(line); - if (obj) absorbUsage(obj); - } - if (replacement != null && line.indexOf('"model"') !== -1) line = line.replace(re, `$1${replacement}$2`); - return line; - } - - return new Transform({ - transform(chunk, _enc, cb) { - buffer += chunk.toString('utf8'); - let out = ''; - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - out += handleLine(buffer.slice(0, idx + 1)); - buffer = buffer.slice(idx + 1); - } - cb(null, out); - }, - flush(cb) { - const line = buffer ? handleLine(buffer) : ''; - buffer = ''; - if (saw && typeof onUsage === 'function') onUsage(usage); - cb(null, line); - }, - }); -} - -function createGateway({ getConfig }) { - const emitter = new EventEmitter(); - let server = null; - let currentPort = null; - let exchangeSeq = 0; - // Real upstream model ids per provider, captured opportunistically from any /v1/models - // response we proxy (Claude Code probes that endpoint on startup). Lets routing recognize - // a model the provider genuinely has — e.g. a not-yet-configured `glm-5.2` — and pass it - // through instead of remapping it. Best-effort: empty until the first models probe. - const modelsCache = new Map(); // providerId -> Set - function recordModels(providerId, list) { - if (!providerId || !Array.isArray(list)) return; - const ids = new Set(); - for (const m of list) { if (m && typeof m.id === 'string' && m.id) ids.add(m.id); } - if (ids.size) modelsCache.set(providerId, ids); - } - - function log(level, msg, extra) { - emitter.emit('log', Object.assign({ level, msg }, extra || {})); - } - - async function handle(req, res, startedAt) { - const config = getConfig() || {}; - - // Optional local access token (defense in depth; we already bind to localhost). - if (config.requireToken && config.gatewayToken) { - const auth = req.headers['authorization'] || ''; - const presented = auth.replace(/^Bearer\s+/i, '') || req.headers['x-api-key'] || ''; - if (presented !== config.gatewayToken) { - respondJson(res, 401, JSON.parse(errorBody('CC Buddy: invalid gateway token', 'authentication_error'))); - return; - } - } - - const isJson = (req.headers['content-type'] || '').includes('application/json'); - let parsed = null; - let requestedModel = null; - if (req.body && req.body.length && isJson) { - try { - parsed = JSON.parse(req.body.toString('utf8')); - if (parsed && typeof parsed.model === 'string') requestedModel = parsed.model; - } catch (_) { - parsed = null; - } - } - - // Look up the active provider's captured model list so routing can recognize a model - // the provider really has (mirrors resolveRouting's own active-provider selection). - const providersList = config.providers || []; - const activeForCache = providersList.find((p) => p.id === config.activeProviderId) || providersList[0]; - const knownModels = activeForCache ? modelsCache.get(activeForCache.id) : null; - - const routing = resolveRouting(requestedModel, config, knownModels); - if (!routing || !routing.provider) { - respondJson(res, 502, JSON.parse(errorBody('CC Buddy: no provider configured. Add one in the app.', 'api_error'))); - log('warn', 'request rejected: no provider configured'); - return; - } - const provider = routing.provider; - - let outBody = req.body || Buffer.alloc(0); - if (parsed && routing.outgoingModel && routing.outgoingModel !== requestedModel) { - parsed.model = routing.outgoingModel; - outBody = Buffer.from(JSON.stringify(parsed), 'utf8'); - } - const needRewriteResponse = - routing.clientFacingModel != null && - routing.outgoingModel != null && - routing.clientFacingModel !== routing.outgoingModel; - - // Session/agent ids (for the request log + usage attribution). - const sessionId = req.headers['x-claude-code-session-id'] || req.headers['x-claude-session-id'] || req.headers['x-session-id'] || req.headers['anthropic-client-session-id'] || null; - const agentId = req.headers['x-claude-code-agent-id'] || null; - - // GET /v1/models — pass the upstream list through but augment with the user's aliases, - // or synthesize it from aliases when the provider has no (working) models endpoint. - const reqPath = req.url.split('?')[0]; - const isModelsList = req.method === 'GET' && /\/v1\/models\/?$/.test(reqPath); - // Codex and Claude both GET /v1/models — tell them apart by client identity so each - // gets its own family's default model list. - const isCodex = clientIsCodex(req.headers); - // Claude Desktop/Code probes the endpoint with `HEAD /` as a liveness check. Some - // Anthropic-compatible upstreams don't implement it and answer 404, which makes the - // client treat the endpoint as down. We still forward the probe honestly, but if it - // 404s we substitute a 200 from the gateway (see the upstream-response handler). - const isHeadRoot = req.method === 'HEAD' && reqPath === '/'; - // Claude Code calls POST /v1/messages/count_tokens before sending, to size context. - // Many providers don't implement it (404) → forward honestly, estimate locally on miss. - const isCountTokens = req.method === 'POST' && /\/v1\/messages\/count_tokens\/?$/.test(reqPath); - - let target; - try { - const base = new URL(provider.baseUrl); - const basePath = base.pathname.replace(/\/+$/, ''); - // Collapse a repeated path prefix: base ".../v1" + inbound "/v1/responses" must - // not become ".../v1/v1/responses" (bites openai-* providers/sidecar plugins on - // same-protocol passthrough). Segment-aware so "/v1" won't eat "/v1beta". - let inbound = req.url; // path + query - if (basePath && basePath !== '/' && (reqPath === basePath || reqPath.startsWith(basePath + '/'))) { - inbound = req.url.slice(basePath.length); - } - target = new URL(base.protocol + '//' + base.host + basePath + inbound); - } catch (e) { - respondJson(res, 502, JSON.parse(errorBody('CC Buddy: invalid provider baseUrl: ' + provider.baseUrl, 'api_error'))); - return; - } - - const headers = Object.assign({}, req.headers); - delete headers['host']; - delete headers['content-length']; - delete headers['authorization']; - delete headers['x-api-key']; - delete headers['accept-encoding']; - // do not leak local-client state / hop-by-hop headers to the third-party upstream - delete headers['cookie']; - delete headers['proxy-authorization']; - delete headers['connection']; - delete headers['proxy-connection']; - delete headers['transfer-encoding']; - headers['host'] = target.host; - headers['accept-encoding'] = 'identity'; - if (provider.authToken) { - headers['authorization'] = 'Bearer ' + provider.authToken; - headers['x-api-key'] = provider.authToken; - } - if (outBody.length) headers['content-length'] = Buffer.byteLength(outBody); - - // Bounded, redacted capture of the full exchange so the monitor can inspect any request - // (headers + bodies). Emitted once on completion as 'exchange'; the lightweight 'request' - // event carries the same id so a list row can fetch its detail on click. - const exId = ++exchangeSeq; - const exchange = { - id: exId, - ts: Date.now(), - method: req.method, - path: req.url.split('?')[0], - url: target.href, - provider: provider.name || provider.id, - requestedModel, - outgoingModel: routing.outgoingModel, - clientFacingModel: routing.clientFacingModel, - rewritten: needRewriteResponse, - sessionId, - agentId, - reqHeaders: redactHeaders(headers), - reqBody: capText(outBody && outBody.length ? outBody : (req.body || Buffer.alloc(0)), REQ_CAP), - }; - let exchangeDone = false; - function emitExchange(status, resHeaders, capObj, errMsg) { - if (exchangeDone) return; - exchangeDone = true; - exchange.status = status; - exchange.ms = Date.now() - startedAt; - exchange.error = errMsg || null; - exchange.resHeaders = resHeaders ? redactHeaders(resHeaders) : {}; - exchange.resBody = capObj || { text: '', bytes: 0, truncated: 0 }; - emitter.emit('exchange', exchange); - } - - const lib = target.protocol === 'http:' ? http : https; - // Issue #12 — optionally skip TLS verification (self-signed / corporate MITM chains). - const insecure = !!config.insecureSkipVerify && target.protocol === 'https:'; - // Issue #13 — retry upstream 429s a few times before surfacing them to the client. - const rc = config.retry429 || {}; - const retryEnabled = rc.enabled !== false; - const retryMax = Number.isFinite(rc.max) ? rc.max : 3; - const retryBase = Number.isFinite(rc.baseMs) ? rc.baseMs : 500; - - // One upstream attempt. Re-invoked (with the same buffered body) on a retryable 429. - function sendUpstream(attempt) { - const opts = { - protocol: target.protocol, - hostname: target.hostname, - port: target.port || (target.protocol === 'https:' ? 443 : 80), - path: target.pathname + target.search, - method: req.method, - headers, - }; - if (insecure) opts.rejectUnauthorized = false; - const upReq = lib.request(opts, (upRes) => { - // 429: the upstream rate-limited us (common with low-concurrency providers). Drain - // it and retry after a short wait; only once attempts run out does the 429 reach the - // client. Safe to retry — a rate-limited request was never processed upstream. - if (retryEnabled && upRes.statusCode === 429 && attempt < retryMax && !res.headersSent) { - const delay = retryDelay(upRes.headers['retry-after'], attempt, retryBase); - upRes.resume(); - log('warn', `upstream 429 — retry ${attempt + 1}/${retryMax} in ${delay}ms (${provider.name || provider.id})`); - const t = setTimeout(() => sendUpstream(attempt + 1), delay); - if (t.unref) t.unref(); - return; - } - const ct = upRes.headers['content-type'] || ''; - const outHeaders = Object.assign({}, upRes.headers); - delete outHeaders['content-length']; - delete outHeaders['transfer-encoding']; - // hop-by-hop / state-bearing headers must not cross back to the local client - delete outHeaders['connection']; - delete outHeaders['keep-alive']; - delete outHeaders['proxy-authenticate']; - delete outHeaders['proxy-connection']; - delete outHeaders['set-cookie']; - - // Decompress if the upstream ignored our `accept-encoding: identity`. - const enc = String(upRes.headers['content-encoding'] || '').trim().toLowerCase(); - const stages = [upRes]; - if (enc === 'gzip' || enc === 'x-gzip') stages.push(zlib.createGunzip()); - else if (enc === 'deflate') stages.push(zlib.createInflate()); - else if (enc === 'br') stages.push(zlib.createBrotliDecompress()); - delete outHeaders['content-encoding']; // body is always identity downstream now - - let logged = false; - let capturedUsage = null; - const finishLog = (errMsg, statusOverride) => { - if (logged) return; - logged = true; - const u = capturedUsage || {}; - emitter.emit('request', { - id: exId, - method: req.method, - path: req.url.split('?')[0], - provider: provider.name || provider.id, - requestedModel, - outgoingModel: routing.outgoingModel, - clientFacingModel: routing.clientFacingModel, - rewritten: needRewriteResponse, - sessionId, - agentId, - status: statusOverride != null ? statusOverride : upRes.statusCode, - ms: Date.now() - startedAt, - error: errMsg, - inputTokens: u.inputTokens || 0, - outputTokens: u.outputTokens || 0, - cacheRead: u.cacheRead || 0, - cacheCreation: u.cacheCreation || 0, - }); - }; - - // `HEAD /` liveness probe that the upstream rejected with 404: answer 200 from the - // gateway (no body) so the client sees the endpoint as healthy. We flag the bypass - // in the response headers so it's never mistaken for a genuine upstream 200. - if (isHeadRoot && upRes.statusCode === 404) { - upRes.resume(); // drain the upstream so its socket can be freed/reused - const fbHeaders = Object.assign({}, outHeaders); - fbHeaders['content-length'] = '0'; - fbHeaders['x-ccbud-fallback'] = 'head-root-404-to-200'; - fbHeaders['x-ccbud-upstream-status'] = '404'; - res.writeHead(200, fbHeaders); - res.end(); - log('info', 'HEAD / fallback: upstream 404 → gateway 200 (' + (provider.name || provider.id) + ')'); - emitExchange(200, fbHeaders, { text: '', bytes: 0, truncated: 0 }); - finishLog(null, 200); - return; - } - - // Streaming SSE: pass through (rewriting model if needed) while sniffing usage, - // and tee a capped copy of the downstream bytes for the monitor inspector. - if (ct.includes('text/event-stream')) { - res.writeHead(upRes.statusCode, outHeaders); - const t = createSseTransform(needRewriteResponse ? routing.clientFacingModel : null, (u) => { capturedUsage = u; }); - const resChunks = []; - let resCapLen = 0; - let resTotal = 0; - const tap = new Transform({ - transform(chunk, _enc, cb) { - resTotal += chunk.length; - if (resCapLen < RES_CAP) { - const room = RES_CAP - resCapLen; - const piece = chunk.length <= room ? chunk : chunk.slice(0, room); - resChunks.push(piece); - resCapLen += piece.length; - } - cb(null, chunk); - }, - }); - pipeline(...stages, t, tap, res, (err) => { - const capped = resTotal > RES_CAP; - emitExchange(upRes.statusCode, outHeaders, { - text: Buffer.concat(resChunks).toString('utf8').replace(capped ? /�+$/ : /(?!)/, ''), - bytes: resTotal, - truncated: capped ? resTotal - RES_CAP : 0, - }, err && err.message); - finishLog(err && err.message); - }); - return; - } - - // Everything else: buffer, then read usage from JSON and rewrite the model if needed. - const cs = []; - const collector = new Writable({ write(chunk, _enc, cb) { cs.push(chunk); cb(); } }); - pipeline(...stages, collector, (err) => { - if (err) { - if (isModelsList && !res.headersSent) { - const mbuf = Buffer.from(JSON.stringify(synthesizeModels(config, isCodex)), 'utf8'); - outHeaders['content-type'] = 'application/json'; - outHeaders['content-length'] = Buffer.byteLength(mbuf); - res.writeHead(200, outHeaders); - res.end(mbuf); - emitExchange(200, outHeaders, capText(mbuf, RES_CAP)); - finishLog(); - return; - } - if (!res.headersSent) respondJson(res, 502, JSON.parse(errorBody('CC Buddy upstream stream error: ' + err.message, 'api_error'))); - else { try { res.destroy(); } catch (_) {} } - emitExchange(upRes.statusCode, outHeaders, capText(Buffer.concat(cs), RES_CAP), err.message); - finishLog(err.message); - return; - } - let buf = Buffer.concat(cs); - // count_tokens: pass the upstream's real number through when it implements the - // endpoint; otherwise (404 / non-JSON / missing input_tokens) estimate locally so - // Claude Code's context sizing keeps working. Flagged in headers; never under-counted. - if (isCountTokens) { - let upstreamOk = null; - if (upRes.statusCode >= 200 && upRes.statusCode < 300) { - try { const o = JSON.parse(buf.toString('utf8')); if (o && typeof o.input_tokens === 'number') upstreamOk = o; } catch (_) {} - } - if (upstreamOk) { - outHeaders['x-ccbud-tokens'] = 'upstream'; - outHeaders['content-length'] = Buffer.byteLength(buf); - res.writeHead(200, outHeaders); - res.end(buf); - emitExchange(200, outHeaders, capText(buf, RES_CAP)); - finishLog(); - return; - } - const est = estimateInputTokens(parsed || {}); - const ebuf = Buffer.from(JSON.stringify({ input_tokens: est }), 'utf8'); - const eh = Object.assign({}, outHeaders); - eh['content-type'] = 'application/json'; - eh['content-length'] = Buffer.byteLength(ebuf); - eh['x-ccbud-tokens'] = 'estimated'; - eh['x-ccbud-upstream-status'] = String(upRes.statusCode); - res.writeHead(200, eh); - res.end(ebuf); - log('info', `count_tokens estimated locally (upstream ${upRes.statusCode}): ${est}`); - emitExchange(200, eh, capText(ebuf, RES_CAP)); - finishLog(null, 200); - return; - } - // /v1/models: merge aliases into a working upstream list, else synthesize from aliases. - if (isModelsList) { - let upstreamObj = null; - if (upRes.statusCode >= 200 && upRes.statusCode < 300) { - try { const o = JSON.parse(buf.toString('utf8')); if (o && Array.isArray(o.data)) upstreamObj = o; } catch (_) {} - } - if (upstreamObj) recordModels(provider.id, upstreamObj.data); // feed real-model routing - const result = upstreamObj ? mergeModels(upstreamObj, config, isCodex) : synthesizeModels(config, isCodex); - buf = Buffer.from(JSON.stringify(result), 'utf8'); - outHeaders['content-type'] = 'application/json'; - outHeaders['content-length'] = Buffer.byteLength(buf); - res.writeHead(200, outHeaders); - res.end(buf); - emitExchange(200, outHeaders, capText(buf, RES_CAP)); - finishLog(); - return; - } - if (ct.includes('application/json')) { - try { - const o = JSON.parse(buf.toString('utf8')); - if (o) { - if (o.usage) capturedUsage = extractUsage(o.usage); - if (needRewriteResponse && typeof o.model === 'string') { - o.model = routing.clientFacingModel; - buf = Buffer.from(JSON.stringify(o), 'utf8'); - } - } - } catch (_) { /* leave as-is */ } - } - outHeaders['content-length'] = Buffer.byteLength(buf); - res.writeHead(upRes.statusCode, outHeaders); - res.end(buf); - emitExchange(upRes.statusCode, outHeaders, capText(buf, RES_CAP)); - finishLog(); - }); - } - ); - - upReq.on('error', (err) => { - // Provider unreachable / no models endpoint → still answer /v1/models from the aliases. - if (isModelsList && !res.headersSent) { - const mbuf = Buffer.from(JSON.stringify(synthesizeModels(config, isCodex)), 'utf8'); - try { - res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(mbuf) }); - res.end(mbuf); - } catch (_) {} - log('info', '/v1/models synthesized from aliases (upstream unreachable: ' + err.message + ')'); - emitExchange(200, { 'content-type': 'application/json' }, capText(mbuf, RES_CAP)); - emitter.emit('request', { - id: exId, method: req.method, path: reqPath, provider: provider.name || provider.id, - requestedModel, outgoingModel: routing.outgoingModel, clientFacingModel: routing.clientFacingModel, - rewritten: needRewriteResponse, sessionId, agentId, status: 200, ms: Date.now() - startedAt, error: null, - }); - return; - } - // count_tokens with the provider unreachable → estimate locally instead of erroring, - // so Claude Code still gets a usable number. - if (isCountTokens && !res.headersSent) { - const est = estimateInputTokens(parsed || {}); - const ebuf = Buffer.from(JSON.stringify({ input_tokens: est }), 'utf8'); - const eh = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(ebuf), 'x-ccbud-tokens': 'estimated', 'x-ccbud-upstream-status': 'error' }; - try { res.writeHead(200, eh); res.end(ebuf); } catch (_) {} - log('info', 'count_tokens estimated locally (upstream unreachable: ' + err.message + '): ' + est); - emitExchange(200, eh, capText(ebuf, RES_CAP)); - emitter.emit('request', { - id: exId, method: req.method, path: reqPath, provider: provider.name || provider.id, - requestedModel, outgoingModel: routing.outgoingModel, clientFacingModel: routing.clientFacingModel, - rewritten: needRewriteResponse, sessionId, agentId, status: 200, ms: Date.now() - startedAt, error: null, - }); - return; - } - if (!res.headersSent) { - respondJson(res, 502, JSON.parse(errorBody('CC Buddy upstream error: ' + err.message, 'api_error'))); - } else { - try { - res.destroy(); - } catch (_) {} - } - log('error', 'upstream error: ' + err.message, { provider: provider.name }); - emitExchange(502, null, null, err.message); - emitter.emit('request', { - id: exId, - method: req.method, - path: req.url.split('?')[0], - provider: provider.name || provider.id, - requestedModel, - outgoingModel: routing.outgoingModel, - clientFacingModel: routing.clientFacingModel, - rewritten: needRewriteResponse, - sessionId, - agentId, - status: 502, - ms: Date.now() - startedAt, - error: err.message, - }); - }); - - if (outBody.length) upReq.write(outBody); - upReq.end(); - } - - sendUpstream(0); - } - - function onRequest(req, res) { - const startedAt = Date.now(); - const chunks = []; - req.on('data', (c) => chunks.push(c)); - req.on('end', () => { - req.body = Buffer.concat(chunks); - Promise.resolve() - .then(() => handle(req, res, startedAt)) - .catch((e) => { - try { - if (!res.headersSent) respondJson(res, 500, JSON.parse(errorBody('CC Buddy internal error: ' + (e && e.message ? e.message : e), 'api_error'))); - else res.destroy(); - } catch (_) {} - }); - }); - req.on('error', () => { - try { - res.end(); - } catch (_) {} - }); - } - - function _start(port) { - return new Promise((resolve, reject) => { - if (server) return resolve(currentPort); - const srv = http.createServer(onRequest); - // One-shot handler for bind failures only; removed once listening so a later - // runtime error cannot corrupt lifecycle state (null out a live server). - const onBindError = (e) => { - server = null; - reject(e); - }; - srv.once('error', onBindError); - srv.listen(port, '127.0.0.1', () => { - srv.removeListener('error', onBindError); - srv.on('error', (e) => log('error', 'gateway server error: ' + (e && e.message ? e.message : e))); - server = srv; - currentPort = srv.address().port; - log('info', `gateway listening on http://127.0.0.1:${currentPort}`); - resolve(currentPort); - }); - }); - } - - function _stop() { - return new Promise((resolve) => { - if (!server) return resolve(); - const srv = server; - let done = false; - const finish = () => { - if (done) return; - done = true; - clearTimeout(timer); - server = null; - currentPort = null; - log('info', 'gateway stopped'); - resolve(); - }; - srv.close(finish); - // Free idle keep-alive sockets, and force-close active (streaming) sockets so - // close() can actually complete instead of hanging on a long-lived SSE stream. - if (typeof srv.closeIdleConnections === 'function') srv.closeIdleConnections(); - if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections(); - // Bounded fallback so stop() always resolves (older runtimes / lingering sockets). - const timer = setTimeout(finish, 2000); - if (timer.unref) timer.unref(); - }); - } - - // Serialize all lifecycle ops so start/stop never interleave (no double-bind / - // EADDRINUSE clobber, no stale early-return) regardless of which IPC path calls them. - let lifecycleChain = Promise.resolve(); - function serialize(fn) { - const run = lifecycleChain.then(fn, fn); - lifecycleChain = run.catch(() => {}); - return run; - } - function start(port) { - return serialize(() => _start(port)); - } - function stop() { - return serialize(() => _stop()); - } - - function status() { - return { running: !!server, port: currentPort }; - } - - return { - on: emitter.on.bind(emitter), - off: emitter.off.bind(emitter), - start, - stop, - status, - // exported for testing - _resolveRouting: (m, c, k) => resolveRouting(m, c, k), - }; -} - -module.exports = { createGateway, resolveRouting, createSseTransform, extractUsage, retryDelay }; diff --git a/src/main/store.js b/src/main/store.js deleted file mode 100644 index 4e55c39..0000000 --- a/src/main/store.js +++ /dev/null @@ -1,142 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -function defaultConfig() { - return { - port: 8788, - activeProviderId: null, - requireToken: false, - gatewayToken: '', - gatewayEnabled: true, - openAtLogin: false, - claudeBackup: null, // snapshot of the user's Claude settings before we connected - trayUsage: { enabled: false, range: '7d' }, // show token usage in the menu bar - language: null, // ui language ('en'|'zh'|'zh-TW'|'ja'|'ko'); null = derive from system on first run - historyDirs: ['~/.claude'], // Claude config dirs to read history/usage from (each has projects/) - historyActive: 'all', // which configured dir the conversation/usage views show ('all' or a path) - connectTargets: ['claude'], // which coding CLIs 一键接入 wires to the gateway (subset of claude/codex) - // Auto-retry upstream 429s before surfacing them to the client (gives low-concurrency - // providers a moment to recover instead of failing the request outright). - retry429: { enabled: true, max: 3, baseMs: 500 }, - // Skip TLS certificate verification on upstream HTTPS requests. Off by default; turn on - // only when a corporate proxy / self-signed chain breaks otherwise-valid connections. - insecureSkipVerify: false, - // In-app updates. `check`: auto-check GitHub for new releases on launch + daily. - // `autoDownload`: when a release qualifies for a hot (JS-only) update, fetch + stage it - // automatically (applied on next launch). Full (native) updates are never auto-applied. - autoUpdate: { check: true, autoDownload: true }, - providers: [], - }; -} - -function normalize(cfg) { - const c = Object.assign(defaultConfig(), cfg || {}); - c.providers = Array.isArray(c.providers) ? c.providers : []; - c.providers = c.providers.map((p) => { - const np = { - id: p.id, - name: p.name || 'Unnamed', - baseUrl: p.baseUrl || '', - authToken: p.authToken || '', - defaultModel: p.defaultModel || '', - smallFastModel: p.smallFastModel || '', - mapDefaultModels: p.mapDefaultModels !== false, - models: Array.isArray(p.models) - ? p.models - .filter((m) => m && (m.alias || m.upstream)) - .map((m) => ({ alias: m.alias || '', upstream: m.upstream || '' })) - : [], - }; - // Optional per-provider custom icon — an emoji or a data:/http(s)/assets URL. - // Preserve it: rebuilding the object without this field is exactly why custom - // icons silently reverted to the brand/default logo on save. - if (typeof p.icon === 'string' && p.icon.trim()) np.icon = p.icon.trim(); - return np; - }); - if (!c.providers.find((p) => p.id === c.activeProviderId)) { - c.activeProviderId = c.providers.length ? c.providers[0].id : null; - } - c.port = Number(c.port) || 8788; - c.requireToken = !!c.requireToken; - c.gatewayEnabled = c.gatewayEnabled !== false; - c.gatewayToken = c.gatewayToken || ''; - c.openAtLogin = !!c.openAtLogin; - c.claudeBackup = c.claudeBackup || null; - const tu = c.trayUsage || {}; - c.trayUsage = { enabled: !!tu.enabled, range: ['1d', '7d', '30d', 'all'].includes(tu.range) ? tu.range : '7d' }; - // 429 auto-retry: clamp to sane bounds so a bad config can't wedge the gateway. - const rr = c.retry429 || {}; - c.retry429 = { - enabled: rr.enabled !== false, - max: Number.isFinite(rr.max) && rr.max >= 0 ? Math.min(Math.floor(rr.max), 10) : 3, - baseMs: Number.isFinite(rr.baseMs) && rr.baseMs >= 0 ? Math.min(Math.floor(rr.baseMs), 10000) : 500, - }; - c.insecureSkipVerify = !!c.insecureSkipVerify; - const au = c.autoUpdate || {}; - c.autoUpdate = { check: au.check !== false, autoDownload: au.autoDownload !== false }; - // language: keep null (= "not yet chosen", main.js fills it from the system locale on first run) - c.language = ['en', 'zh', 'zh-TW', 'ja', 'ko'].includes(c.language) ? c.language : null; - // History/usage directories: trimmed, trailing-slash-normalized (so '~/.claude' and - // '~/.claude/' don't both survive as phantom duplicates), unique, non-empty default. - let dirs = Array.isArray(c.historyDirs) - ? c.historyDirs.map((d) => String(d || '').trim().replace(/(.)[/\\]+$/, '$1')).filter(Boolean) - : []; - dirs = [...new Set(dirs)]; - if (!dirs.includes('~/.claude')) { - dirs.unshift('~/.claude'); - } - c.historyDirs = dirs; - // '__imported__' is the synthetic, app-managed store of imported transcripts (not a user dir, so - // not in historyDirs) — keep it valid as an active selection so the "导入" filter persists. - // '__codex__' is the retired synthetic Codex bucket — map it onto the real ~/.codex dir entry. - // connectTargets: subset of {claude, codex}, deduped. Empty is valid (all disconnected); only a - // fresh config gets ['claude'] via defaultConfig — an explicit [] is preserved. - c.connectTargets = [...new Set((Array.isArray(c.connectTargets) ? c.connectTargets : []) - .filter((t) => t === 'claude' || t === 'codex'))]; - if (c.historyActive === '__codex__') c.historyActive = require('./codex').codexLabel(); - c.historyActive = c.historyActive === 'all' || c.historyActive === '__imported__' || dirs.includes(c.historyActive) ? c.historyActive : 'all'; - return c; -} - -function createStore(dir) { - const file = path.join(dir, 'config.json'); - let cfg = defaultConfig(); - - function load() { - try { - cfg = normalize(JSON.parse(fs.readFileSync(file, 'utf8'))); - } catch (_) { - cfg = defaultConfig(); - } - return cfg; - } - - function save(next) { - const normalized = normalize(next); - try { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } catch (_) {} - // Atomic: write to a temp file then rename, so a crash mid-write never - // produces a torn config.json. Only commit to in-memory cfg after success. - const tmp = file + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(normalized, null, 2), { mode: 0o600 }); - fs.renameSync(tmp, file); - // writeFileSync's mode is ignored for an already-existing file; chmod covers that. - try { - fs.chmodSync(file, 0o600); - } catch (_) {} - cfg = normalized; - return cfg; - } - - function get() { - return cfg; - } - - load(); - return { get, load, save, file }; -} - -module.exports = { createStore, defaultConfig, normalize }; diff --git a/src/main/updater.js b/src/main/updater.js deleted file mode 100644 index d4c3917..0000000 --- a/src/main/updater.js +++ /dev/null @@ -1,309 +0,0 @@ -'use strict'; - -/* - * In-app updater. Two tiers: - * - HOT : a JS-only bundle (gzipped JSON of the src/ tree) downloaded from the latest GitHub - * release, SHA-256 (+ optional Ed25519) verified, extracted under /hot// - * and activated by bootstrap.js on the next launch. No reinstall / signing needed. - * - FULL : the new version needs a newer native shell (Electron bump, bundled binaries…), gated - * by the manifest's minShellVersion → we point the user at the installer / `brew upgrade`. - * - * The release publishes two extra assets (see scripts/build-hotupdate.js): - * hotupdate-manifest.json { version, minShellVersion, sha256, signature?, bundle, notes } - * ccbud-hotupdate-.json.gz the bundle the manifest points at - */ - -const { app } = require('electron'); -const fs = require('fs'); -const path = require('path'); -const https = require('https'); -const zlib = require('zlib'); -const crypto = require('crypto'); -const hp = require('./hotpaths'); - -const REPO = 'ccbud/ccbud'; -const MANIFEST_ASSET = 'hotupdate-manifest.json'; -// Optional: paste an Ed25519 public key (SPKI PEM) here to REQUIRE signed bundles. Empty = -// SHA-256-only verification (the manifest is fetched over GitHub HTTPS). See scripts/gen-update-keys.js. -const UPDATE_PUBLIC_KEY = ''; - -let ctx = { userData: null, getConfig: () => ({}), broadcast: () => {}, log: () => {} }; -let lastCheck = null; // cached result of the most recent checkForUpdates() -let checking = false; -let staging = false; - -function init(opts) { - ctx = Object.assign(ctx, opts || {}); -} - -/* ---------- semver-ish compare (x.y.z, prerelease ignored) ---------- */ -function parseVer(v) { - return String(v || '0') - .replace(/^v/i, '') - .split('-')[0] - .split('.') - .map((n) => parseInt(n, 10) || 0); -} -function cmpVer(a, b) { - const pa = parseVer(a); - const pb = parseVer(b); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const d = (pa[i] || 0) - (pb[i] || 0); - if (d) return d < 0 ? -1 : 1; - } - return 0; -} - -/* ---------- version helpers ---------- */ -function shellVersion() { - try { return app.getVersion(); } catch (_) { return '0.0.0'; } -} -function runningVersion() { - // After a hot update the live JS may be newer than the installed shell. - try { - const st = hp.readState(ctx.userData); - if (st.active && st.active.version) return st.active.version; - } catch (_) {} - return shellVersion(); -} -function installMethod() { - try { if (!app.isPackaged) return 'dev'; } catch (_) {} - if (process.platform === 'linux' && process.env.APPIMAGE) return 'appimage'; - if (process.platform === 'darwin') return 'mac'; - if (process.platform === 'win32') return 'win'; - return 'linux'; -} - -/* ---------- network (follows redirects; strict TLS) ---------- */ -function httpsGet(url, redirects) { - redirects = redirects || 0; - return new Promise((resolve, reject) => { - if (redirects > 5) { reject(new Error('too many redirects')); return; } - const req = https.get(url, { headers: { 'User-Agent': 'ccbud-updater', Accept: 'application/octet-stream, application/json;q=0.9, */*;q=0.5' } }, (res) => { - const code = res.statusCode || 0; - if (code >= 300 && code < 400 && res.headers.location) { - res.resume(); - const next = new URL(res.headers.location, url).href; - resolve(httpsGet(next, redirects + 1)); - return; - } - if (code < 200 || code >= 300) { - res.resume(); - reject(new Error('HTTP ' + code + ' for ' + url)); - return; - } - const chunks = []; - res.on('data', (c) => chunks.push(c)); - res.on('end', () => resolve(Buffer.concat(chunks))); - }); - req.on('error', reject); - req.setTimeout(30000, () => req.destroy(new Error('timeout'))); - }); -} -async function getJson(url) { - const buf = await httpsGet(url); - return JSON.parse(buf.toString('utf8')); -} - -function findAsset(release, name) { - return (release.assets || []).find((a) => a.name === name) || null; -} -// Best-effort: the platform installer asset for a FULL update (so the UI can deep-link it). -function findInstaller(release, version) { - const assets = release.assets || []; - const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; - const want = (ext) => assets.find((a) => a.name.includes('-' + arch + '.') && a.name.endsWith(ext)) - || assets.find((a) => a.name.endsWith(ext)); - let a = null; - if (process.platform === 'darwin') a = want('.dmg'); - else if (process.platform === 'win32') a = want('.exe'); - else if (process.env.APPIMAGE) a = want('.AppImage'); - else a = want('.deb') || want('.AppImage'); - return a ? a.browser_download_url : null; -} - -/* ---------- check ---------- */ -async function checkForUpdates(opts) { - opts = opts || {}; - if (checking) return lastCheck || { ok: false, error: 'busy' }; - checking = true; - const sv = shellVersion(); - const rv = runningVersion(); - try { - const release = await getJson('https://api.github.com/repos/' + REPO + '/releases/latest'); - const tag = release.tag_name || ''; - let manifest = null; - const ma = findAsset(release, MANIFEST_ASSET); - if (ma) { - try { manifest = await getJson(ma.browser_download_url); } catch (_) { manifest = null; } - } - const latestVersion = (manifest && manifest.version) || tag.replace(/^v/i, '') || sv; - const hasUpdate = cmpVer(latestVersion, rv) > 0; - - let bundleUrl = null; - if (manifest && manifest.bundle) { - const ba = findAsset(release, manifest.bundle); - bundleUrl = ba ? ba.browser_download_url : null; - } - const minShell = (manifest && manifest.minShellVersion) || '0.0.0'; - const hotEligible = !!(hasUpdate && bundleUrl && manifest.sha256 && cmpVer(sv, minShell) >= 0); - - const mode = !hasUpdate ? 'none' : hotEligible ? 'hot' : 'full'; - lastCheck = { - ok: true, - checkedAt: Date.now(), - shellVersion: sv, - runningVersion: rv, - latestVersion, - mode, - minShellVersion: minShell, - notes: (manifest && manifest.notes) || release.body || '', - releaseUrl: release.html_url || ('https://github.com/' + REPO + '/releases/latest'), - installerUrl: hasUpdate ? findInstaller(release, latestVersion) : null, - brewCommand: 'brew upgrade --cask ccbud', - installMethod: installMethod(), - // hot-only payload (kept server-private to the renderer; used by downloadAndStageHot) - _bundleUrl: bundleUrl, - _sha256: manifest && manifest.sha256, - _signature: manifest && manifest.signature, - }; - } catch (e) { - lastCheck = { ok: false, error: (e && e.message) || String(e), checkedAt: Date.now(), shellVersion: sv, runningVersion: rv, installMethod: installMethod() }; - } finally { - checking = false; - } - ctx.broadcast('update:state', publicState()); - return publicState(); -} - -/* ---------- download + stage a hot bundle ---------- */ -function verifyBundle(buf) { - if (!lastCheck || !lastCheck._sha256) throw new Error('no manifest checksum'); - const got = crypto.createHash('sha256').update(buf).digest('hex'); - if (got.toLowerCase() !== String(lastCheck._sha256).toLowerCase()) throw new Error('checksum mismatch'); - if (UPDATE_PUBLIC_KEY) { - if (!lastCheck._signature) throw new Error('missing signature'); - const ok = crypto.verify(null, buf, UPDATE_PUBLIC_KEY, Buffer.from(lastCheck._signature, 'base64')); - if (!ok) throw new Error('signature verification failed'); - } -} -function safeName(v) { - return String(v || '0').replace(/[^A-Za-z0-9._-]/g, '_'); -} - -async function downloadAndStageHot() { - if (staging) return { ok: false, error: 'busy' }; - if (!lastCheck || lastCheck.mode !== 'hot' || !lastCheck._bundleUrl) return { ok: false, error: 'no hot update' }; - staging = true; - const version = lastCheck.latestVersion; - try { - ctx.log('downloading hot update ' + version); - const gz = await httpsGet(lastCheck._bundleUrl); - verifyBundle(gz); - const json = JSON.parse(zlib.gunzipSync(gz).toString('utf8')); - if (!json || !json.files || typeof json.files !== 'object') throw new Error('bad bundle'); - - const root = hp.hotRoot(ctx.userData); - const stageDir = path.join(root, '.staging-' + safeName(version)); - try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch (_) {} - fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 }); - - const stageResolved = path.resolve(stageDir); - for (const rel of Object.keys(json.files)) { - // Bundle paths are posix ('/'); split + rejoin so this is correct on Windows too, and - // reject path traversal — only paths under the staging dir are allowed. - const parts = String(rel).split(/[\\/]/).filter((p) => p && p !== '.'); - if (parts.includes('..')) throw new Error('unsafe path in bundle: ' + rel); - const dest = path.join(stageDir, ...parts); - if (path.resolve(dest) !== stageResolved && !path.resolve(dest).startsWith(stageResolved + path.sep)) { - throw new Error('unsafe path in bundle: ' + rel); - } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.writeFileSync(dest, Buffer.from(json.files[rel], 'base64')); - } - if (!fs.existsSync(hp.mainEntry(stageDir))) throw new Error('bundle missing main entry'); - - const finalDir = hp.bundleDir(ctx.userData, safeName(version)); - try { fs.rmSync(finalDir, { recursive: true, force: true }); } catch (_) {} - fs.renameSync(stageDir, finalDir); - - const st = hp.readState(ctx.userData); - st.pending = { version, dir: safeName(version) }; - hp.writeState(ctx.userData, st); - ctx.log('hot update ' + version + ' staged — will apply on next launch'); - ctx.broadcast('update:state', publicState()); - return { ok: true, version }; - } catch (e) { - ctx.log('hot update failed: ' + ((e && e.message) || e)); - return { ok: false, error: (e && e.message) || String(e) }; - } finally { - staging = false; - } -} - -function relaunchToApply() { - try { app.relaunch(); } catch (_) {} - try { app.exit(0); } catch (_) {} -} - -// Called by main.js once the app has booted cleanly, so a "trying" hot bundle is confirmed -// good and rollback won't fire on the next launch. Also prunes stale bundle dirs. -function confirmBootSuccess() { - try { - const st = hp.readState(ctx.userData); - let changed = false; - if (st.trying && st.active && st.trying === st.active.version) { - st.trying = null; - changed = true; - } - if (st.previous) { - try { fs.rmSync(hp.bundleDir(ctx.userData, st.previous.dir), { recursive: true, force: true }); } catch (_) {} - st.previous = null; - changed = true; - } - if (changed) hp.writeState(ctx.userData, st); - // Prune any leftover bundle dirs that aren't the active one. - const keep = st.active && st.active.dir; - const root = hp.hotRoot(ctx.userData); - for (const name of fs.existsSync(root) ? fs.readdirSync(root) : []) { - const full = path.join(root, name); - if (name === 'state.json' || name === 'state.json.tmp') continue; - try { - if (!fs.statSync(full).isDirectory()) continue; - if (name !== keep) fs.rmSync(full, { recursive: true, force: true }); - } catch (_) {} - } - } catch (_) {} -} - -/* ---------- state for the renderer ---------- */ -function pendingState() { - try { - const st = hp.readState(ctx.userData); - return st.pending ? { staged: true, version: st.pending.version } : { staged: false }; - } catch (_) { return { staged: false }; } -} -// Strip the internal _-prefixed fields before handing the check result to the renderer. -function publicState() { - const base = { - shellVersion: shellVersion(), - runningVersion: runningVersion(), - installMethod: installMethod(), - pending: pendingState(), - }; - if (!lastCheck) return Object.assign(base, { mode: 'unknown' }); - const c = {}; - for (const k of Object.keys(lastCheck)) if (k[0] !== '_') c[k] = lastCheck[k]; - return Object.assign(base, c); -} - -module.exports = { - init, - checkForUpdates, - downloadAndStageHot, - relaunchToApply, - confirmBootSuccess, - publicState, - installMethod, - cmpVer, -}; diff --git a/src/main/usage.js b/src/main/usage.js deleted file mode 100644 index 825fdcd..0000000 --- a/src/main/usage.js +++ /dev/null @@ -1,171 +0,0 @@ -'use strict'; - -/** - * Token-usage aggregation over per-day buckets, exposing stats for time ranges - * (1d / 7d / 30d / all) for the menu-bar display and tray panel. - * - * The bucket logic is pure (operates on a `{ days: {} }` object), so it is shared by: - * - createUsageStore: live recording of gateway request/response usage (legacy path) - * - insights.js: usage computed from on-disk Claude Code history (.jsonl), the primary source - */ - -const fs = require('fs'); -const path = require('path'); - -const DAY = 86400000; -const HEATMAP_WEEKS = 26; -const pad = (n) => String(n).padStart(2, '0'); -const keyOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; -const startOfDay = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); }; -const msOfKey = (k) => { const [y, m, d] = k.split('-').map(Number); return new Date(y, m - 1, d).getTime(); }; - -function topKey(map) { - let best = null, bestV = -1; - for (const k in map) if (map[k] > bestV) { bestV = map[k]; best = k; } - return best; -} - -/** Add one usage contribution into a `{ days: {} }` aggregate (pure helper, shared). */ -function bump(data, ev) { - const ts = ev.ts != null ? ev.ts : Date.now(); // explicit: a null ts must not silently become "today" - const inp = ev.inputTokens || 0, out = ev.outputTokens || 0, cr = ev.cacheRead || 0, cc = ev.cacheCreation || 0; - const total = inp + out + cr + cc; - const k = keyOf(ts); - const day = data.days[k] || (data.days[k] = { tokens: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0, requests: 0, models: {}, providers: {}, hours: {} }); - day.requests++; - day.tokens += total; - day.input += inp; - day.output += out; - day.cacheRead += cr; - day.cacheCreation += cc; - // tokens with no model attribution (e.g. turns) still count in the totals above - const model = ev.model || ev.requestedModel || ev.outgoingModel; - if (model) day.models[model] = (day.models[model] || 0) + total; - if (ev.provider) day.providers[ev.provider] = (day.providers[ev.provider] || 0) + total; - const h = new Date(ts).getHours(); - day.hours[h] = (day.hours[h] || 0) + total; -} - -function rangeKeys(data, range, now) { - const all = Object.keys(data.days).sort(); - if (range === 'all') return all; - const n = range === '1d' ? 1 : range === '30d' ? 30 : 7; - const cut = startOfDay((now || Date.now()) - (n - 1) * DAY); - return all.filter((k) => msOfKey(k) >= cut); -} - -function rangeTokens(data, range, now) { - return rangeKeys(data, range, now).reduce((s, k) => s + data.days[k].tokens, 0); -} - -function streaks(data, now) { - const active = new Set(Object.keys(data.days).filter((k) => data.days[k].requests > 0)); - let longest = 0, run = 0, prev = null; - for (const k of [...active].sort()) { - const t = msOfKey(k); - run = prev !== null && t - prev === DAY ? run + 1 : 1; - prev = t; - if (run > longest) longest = run; - } - let cur = 0; - let t = startOfDay(now || Date.now()); - if (!active.has(keyOf(t))) t -= DAY; - while (active.has(keyOf(t))) { cur++; t -= DAY; } - return { current: cur, longest }; -} - -function buildHeatmap(data, weeks, now) { - const today = startOfDay(now || Date.now()); - const span = weeks * 7; - let start = today - (span - 1) * DAY; - start -= new Date(start).getDay() * DAY; // snap to Sunday so row = weekday - const cells = []; - let max = 1; - for (let t = start; t <= today; t += DAY) { - const d = data.days[keyOf(t)]; - const tok = d ? d.tokens : 0; - if (tok > max) max = tok; - cells.push({ date: keyOf(t), tokens: tok }); - } - for (const c of cells) { - const r = c.tokens / max; - c.level = c.tokens === 0 ? 0 : r > 0.66 ? 4 : r > 0.33 ? 3 : r > 0.1 ? 2 : 1; - } - return cells; -} - -/** Build the full stats payload (the shape the tray popover renders). Pure over `data`. */ -function queryUsage(data, range, now) { - const keys = rangeKeys(data, range, now); - let tokens = 0, input = 0, output = 0, cacheRead = 0, cacheCreation = 0, requests = 0; - const models = {}, providers = {}, hours = {}; - for (const k of keys) { - const d = data.days[k]; - tokens += d.tokens; input += d.input; output += d.output; - cacheRead += d.cacheRead || 0; cacheCreation += d.cacheCreation || 0; requests += d.requests; - for (const m in d.models) models[m] = (models[m] || 0) + d.models[m]; - for (const p in d.providers) providers[p] = (providers[p] || 0) + d.providers[p]; - for (const h in d.hours) hours[h] = (hours[h] || 0) + d.hours[h]; - } - const activeDays = keys.filter((k) => data.days[k].requests > 0).length; - const st = streaks(data, now); - return { - range, - tokens, input, output, cacheRead, cacheCreation, requests, activeDays, - peakHour: hours && Object.keys(hours).length ? Number(topKey(hours)) : null, - favoriteModel: topKey(models), - favoriteProvider: topKey(providers), - byModel: Object.entries(models).sort((a, b) => b[1] - a[1]).map(([model, t]) => ({ model, tokens: t, pct: tokens ? t / tokens : 0 })), - byProvider: Object.entries(providers).sort((a, b) => b[1] - a[1]).map(([provider, t]) => ({ provider, tokens: t, pct: tokens ? t / tokens : 0 })), - currentStreak: st.current, - longestStreak: st.longest, - heatmap: buildHeatmap(data, HEATMAP_WEEKS, now), - }; -} - -/** Live recording store (gateway path). Kept for compatibility; the primary usage source - * is now insights.js (computed from on-disk history). */ -function createUsageStore(dir) { - const file = path.join(dir, 'usage.json'); - let data = { days: {} }; - let timer = null; - - try { - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); - if (raw && raw.days) data = raw; - } catch (_) {} - - function scheduleSave() { - if (timer) return; - timer = setTimeout(() => { - timer = null; - try { - fs.mkdirSync(dir, { recursive: true }); - const tmp = file + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(data)); - fs.renameSync(tmp, file); - } catch (_) {} - }, 1500); - if (timer.unref) timer.unref(); - } - - function record(ev) { bump(data, ev); scheduleSave(); } - - return { - record, - query: (range, now) => queryUsage(data, range, now), - rangeTokens: (range, now) => rangeTokens(data, range, now), - _data: () => data, - }; -} - -/** Format a token count compactly: 950 → "950", 12345 → "12.3K", 1.3e9 → "1.3B". */ -function formatTokens(n) { - n = n || 0; - if (n < 1000) return String(n); - if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; - if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '') + 'M'; - return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; -} - -module.exports = { createUsageStore, formatTokens, queryUsage, rangeTokens, bump, keyOf }; diff --git a/src/main/zipStore.js b/src/main/zipStore.js deleted file mode 100644 index ef50345..0000000 --- a/src/main/zipStore.js +++ /dev/null @@ -1,169 +0,0 @@ -'use strict'; - -/** - * Minimal ZIP reader/writer for conversation bundles — no external deps. - * - * A conversation with subagents exports as a .zip whose FIRST level is the main session .jsonl - * and whose `subagents/` directory holds the per-subagent files (`agent-.jsonl` + - * `agent-.meta.json`). Re-importing that .zip restores the same on-disk relationship. This - * module only implements the slice of the ZIP spec that round-trip needs: - * - write: STORE or raw-DEFLATE per entry (whichever is smaller), no zip64, no data descriptors. - * - read: parse via the central directory (so zips repacked by the OS — which use data - * descriptors — still read), handling STORE (0) and DEFLATE (8). - * Kept in lockstep with the Rust port in src-tauri/src/ziputil.rs. - */ - -const zlib = require('zlib'); - -const CRC_TABLE = (() => { - const t = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); - t[n] = c >>> 0; - } - return t; -})(); - -function crc32(buf) { - let c = 0xffffffff; - for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -} - -function toBuf(data) { - return Buffer.isBuffer(data) ? data : Buffer.from(data == null ? '' : String(data), 'utf8'); -} - -// entries: [{ name, data }] where data is a Buffer or a string. Returns a Buffer (the .zip bytes). -function buildZip(entries) { - const local = []; // local file header + name + payload, per entry - const central = []; // central directory records - let offset = 0; // running offset of the next local header - for (const e of entries) { - const nameBuf = Buffer.from(e.name, 'utf8'); - const data = toBuf(e.data); - const crc = crc32(data); - const deflated = zlib.deflateRawSync(data); - let method = 0, payload = data; - if (deflated.length < data.length) { method = 8; payload = deflated; } - - const lh = Buffer.alloc(30); - lh.writeUInt32LE(0x04034b50, 0); // local file header signature - lh.writeUInt16LE(20, 4); // version needed - lh.writeUInt16LE(0, 6); // general purpose flags - lh.writeUInt16LE(method, 8); // compression method - lh.writeUInt16LE(0, 10); // mod time - lh.writeUInt16LE(0x21, 12); // mod date = 1980-01-01 (cosmetic) - lh.writeUInt32LE(crc, 14); - lh.writeUInt32LE(payload.length, 18); - lh.writeUInt32LE(data.length, 22); - lh.writeUInt16LE(nameBuf.length, 26); - lh.writeUInt16LE(0, 28); // extra length - local.push(lh, nameBuf, payload); - - const ch = Buffer.alloc(46); - ch.writeUInt32LE(0x02014b50, 0); // central directory header signature - ch.writeUInt16LE(20, 4); // version made by - ch.writeUInt16LE(20, 6); // version needed - ch.writeUInt16LE(0, 8); // flags - ch.writeUInt16LE(method, 10); - ch.writeUInt16LE(0, 12); // mod time - ch.writeUInt16LE(0x21, 14); // mod date - ch.writeUInt32LE(crc, 16); - ch.writeUInt32LE(payload.length, 20); - ch.writeUInt32LE(data.length, 24); - ch.writeUInt16LE(nameBuf.length, 28); - ch.writeUInt16LE(0, 30); // extra length - ch.writeUInt16LE(0, 32); // comment length - ch.writeUInt16LE(0, 34); // disk number start - ch.writeUInt16LE(0, 36); // internal attrs - ch.writeUInt32LE(0, 38); // external attrs - ch.writeUInt32LE(offset, 42); // relative offset of local header - central.push(ch, nameBuf); - - offset += lh.length + nameBuf.length + payload.length; - } - const centralStart = offset; - const centralBuf = Buffer.concat(central); - const eocd = Buffer.alloc(22); - eocd.writeUInt32LE(0x06054b50, 0); // end of central directory signature - eocd.writeUInt16LE(0, 4); // this disk - eocd.writeUInt16LE(0, 6); // disk with central dir - eocd.writeUInt16LE(entries.length, 8); // entries on this disk - eocd.writeUInt16LE(entries.length, 10); // total entries - eocd.writeUInt32LE(centralBuf.length, 12); - eocd.writeUInt32LE(centralStart, 16); // offset of start of central directory - eocd.writeUInt16LE(0, 20); // comment length - return Buffer.concat([...local, centralBuf, eocd]); -} - -// Parse a .zip Buffer → [{ name, data(Buffer) }]. Best-effort: unreadable/unsupported entries are -// skipped rather than throwing, so one bad member can't sink an otherwise-valid import. -function readZip(buf) { - const out = []; - if (!Buffer.isBuffer(buf) || buf.length < 22) return out; - // Locate the End Of Central Directory record by scanning backwards for its signature. - let eocd = -1; - const minScan = Math.max(0, buf.length - 22 - 65535); - for (let i = buf.length - 22; i >= minScan; i--) { - if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } - } - if (eocd < 0) return out; - const count = buf.readUInt16LE(eocd + 10); - let p = buf.readUInt32LE(eocd + 16); // central directory offset - for (let i = 0; i < count; i++) { - if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) break; - const method = buf.readUInt16LE(p + 10); - const compSize = buf.readUInt32LE(p + 20); - const nameLen = buf.readUInt16LE(p + 28); - const extraLen = buf.readUInt16LE(p + 30); - const commentLen = buf.readUInt16LE(p + 32); - const localOff = buf.readUInt32LE(p + 42); - const name = buf.toString('utf8', p + 46, p + 46 + nameLen); - // The local header repeats name/extra lengths; trust it for the data offset. - if (localOff + 30 <= buf.length && buf.readUInt32LE(localOff) === 0x04034b50) { - const lhName = buf.readUInt16LE(localOff + 26); - const lhExtra = buf.readUInt16LE(localOff + 28); - const dataStart = localOff + 30 + lhName + lhExtra; - const dataEnd = dataStart + compSize; - if (dataEnd <= buf.length) { - const payload = buf.subarray(dataStart, dataEnd); - let data = null; - if (method === 0) data = Buffer.from(payload); - else if (method === 8) { try { data = zlib.inflateRawSync(payload); } catch (_) { data = null; } } - if (data) out.push({ name, data }); - } - } - p += 46 + nameLen + extraLen + commentLen; - } - return out; -} - -const norm = (n) => String(n).replace(/\\/g, '/').replace(/^\.\//, ''); -const inSubagents = (n) => norm(n).split('/').includes('subagents'); -const depth = (n) => (norm(n).match(/\//g) || []).length; -const baseName = (n) => norm(n).split('/').filter(Boolean).pop() || ''; - -// Split a bundle's entries into { main, subagents } following the export layout: the main session -// is the shallowest top-level *.jsonl (never under a subagents/ segment); subagents are the -// agent-*.jsonl / agent-*.meta.json files under any subagents/ directory. Tolerant of an extra -// wrapping folder (e.g. a user who zipped the containing directory). -function splitBundle(entries) { - let main = null; - for (const e of entries) { - if (!/\.jsonl$/i.test(e.name) || inSubagents(e.name)) continue; - if (!main || depth(e.name) < depth(main.name)) main = { name: baseName(e.name), data: e.data }; - } - const subagents = []; - for (const e of entries) { - if (!inSubagents(e.name)) continue; - const base = baseName(e.name); - if (/^agent-.*\.jsonl$/i.test(base) || /^agent-.*\.meta\.json$/i.test(base)) { - subagents.push({ name: base, data: e.data }); - } - } - return { main, subagents }; -} - -module.exports = { buildZip, readZip, splitBundle, crc32 }; diff --git a/src/renderer/analytics-events.js b/src/renderer/analytics-events.js new file mode 100644 index 0000000..4a98100 --- /dev/null +++ b/src/renderer/analytics-events.js @@ -0,0 +1,158 @@ +'use strict'; +/* + * Usage analytics — DOM event layer. Loaded (deferred) right after analytics.js, which owns the + * Clarity queue, identity and boot, and publishes window.ccTrack / window.ccTag for this file. + * + * Privacy: only element identifiers, i18n keys and enum-ish dataset values ever become event + * names — free text, input values and content-bearing dataset payloads (paths, session ids, + * urls) never leave the app. + */ +(function () { + var track = window.ccTrack, tag = window.ccTag; + if (!track || !tag) return; + + /* ---------- interaction descriptors ---------- */ + // Enum-ish dataset keys whose VALUES are safe to report (fixed UI vocabulary). + var ENUM_KEYS = ['view', 'settings', 'tab', 'range', 'hrange', 'preset', 'copy', 'export', 'icon']; + // Content-bearing dataset keys: report the bare key name, never the value. + var NAME_KEYS = ['proj', 'file', 'id', 'target', 'act', 'tip']; + + function classToken(n) { + return typeof n.className === 'string' ? n.className.trim().split(/\s+/)[0] : ''; + } + function descriptor(start) { + for (var n = start, depth = 0; n && n.nodeType === 1 && depth < 15; n = n.parentElement, depth++) { + if (n.id) return '#' + n.id; + var d = n.dataset; + if (d) { + for (var i = 0; i < ENUM_KEYS.length; i++) if (d[ENUM_KEYS[i]]) return ENUM_KEYS[i] + '=' + d[ENUM_KEYS[i]]; + if (d.i18n) return 'i18n:' + d.i18n; + if (d.i18nTitle) return 'i18n:' + d.i18nTitle; + // Dynamic list items (provider cards, sessions, stream rows): label them by + // their semantic class token, never by the id/path payload they carry. + for (var j = 0; j < NAME_KEYS.length; j++) if (d[NAME_KEYS[j]] != null) return classToken(n) || 'data-' + NAME_KEYS[j]; + } + } + var el = start && start.closest ? start.closest('button, a, summary, label, [role="button"], input, select') : null; + var probe = el || start; + if (probe && probe.nodeType === 1) { + var cls = classToken(probe); + return probe.tagName.toLowerCase() + (cls ? '.' + cls : ''); + } + return 'unknown'; + } + + // Semantic funnel layer: element id → business event (fires alongside the raw click). + var FUNNEL = { + btnConnect: 'connect-toggle', + popConnect: 'connect-toggle', + btnAdd: 'provider-add', + btnAddEmpty: 'provider-add', + btnSave: 'provider-save', + btnTest: 'provider-test', + btnUpdateCheck: 'update-check', + btnUpdateDownload: 'update-download', + btnUpdateApply: 'update-apply', + btnUpdateOpen: 'update-open', + btnUpdateBrew: 'update-brew', + convImportBtn: 'conv-import', + convExportBtn: 'conv-export', + convReplayBtn: 'conv-replay', + convChatgptBtn: 'conv-chatgpt', + convCopyPathBtn: 'conv-copy-path', + btnCopyExport: 'copy-export', + btnGenToken: 'token-generate', + btnPickHistDir: 'histdir-pick', + popOpen: 'popover-open-main', + popQuit: 'app-quit' + }; + + /* ---------- listeners (capture phase, so no UI code can swallow them) ---------- */ + document.addEventListener('click', function (e) { + var t = e.target && e.target.nodeType === 1 ? e.target : null; + if (!t) return; + track('click:' + descriptor(t)); + + var host = t.closest ? t.closest('[id]') : null; + if (host && FUNNEL[host.id]) track('goal:' + FUNNEL[host.id]); + // Theme flips after the app handler runs — re-read it on the next tick. + if (host && host.id === 'btnTheme') { + setTimeout(function () { try { tag('theme', localStorage.getItem('ccbud-theme') || ''); } catch (_) {} }, 0); + } + + // Virtual page views: sidebar views, settings panes, popover tabs. + var nav = t.closest ? t.closest('[data-view],[data-settings],[data-tab]') : null; + if (nav) { + var d = nav.dataset; + var view = d.view || (d.settings ? 'settings/' + d.settings : 'popover/' + d.tab); + track('view:' + view); + tag('view', view); + } + var fmt = t.closest ? t.closest('[data-export]') : null; + if (fmt) track('goal:conv-export:' + fmt.dataset.export); + }, true); + + document.addEventListener('contextmenu', function (e) { + if (e.target && e.target.nodeType === 1) track('rclick:' + descriptor(e.target)); + }, true); + document.addEventListener('dblclick', function (e) { + if (e.target && e.target.nodeType === 1) track('dblclick:' + descriptor(e.target)); + }, true); + document.addEventListener('dragend', function (e) { + if (e.target && e.target.nodeType === 1) track('drag:' + descriptor(e.target)); + }, true); + + // Committed control changes: checkbox/radio state and enum select values are safe; + // for anything free-form only the field identity is reported. + document.addEventListener('change', function (e) { + var t = e.target; + if (!t || t.nodeType !== 1) return; + var name = t.id || t.name || descriptor(t); + var suffix = ''; + if (t.type === 'checkbox' || t.type === 'radio') suffix = t.checked ? ':on' : ':off'; + else if (t.tagName === 'SELECT') suffix = ':' + String(t.value).slice(0, 32); + track('change:' + name + suffix); + if (t.id === 'fLang') tag('lang', t.value); + }, true); + + // First keystroke per field per window life — signals "user typed here", no content. + var typed = {}; + document.addEventListener('input', function (e) { + var t = e.target; + if (!t || t.nodeType !== 1) return; + var k = t.id || t.name || t.tagName; + if (typed[k]) return; + typed[k] = 1; + track('input:' + k); + }, true); + + /* ---------- errors ---------- */ + // Error messages can embed user paths or URLs — redact those before tagging. + function scrubError(s) { + return String(s == null ? 'unknown' : s) + .replace(/(?:file|https?):\/\/[^\s'")]+/gi, '') + .replace(/(^|[\s'"(=:,])(?:~\/|\/)[^\s'")]+/g, '$1') + .replace(/[A-Za-z]:\\[^\s'")]+/g, '') + .slice(0, 120); + } + window.addEventListener('error', function (e) { + if (e && e.target && e.target !== window && e.target.nodeType === 1) { + track('error:resource:' + (e.target.tagName || '').toLowerCase()); + return; + } + track('error:js'); + tag('lastError', scrubError(e && e.message)); + try { window.clarity('upgrade', 'js-error'); } catch (_) {} + }, true); + window.addEventListener('unhandledrejection', function (e) { + var r = e && e.reason; + track('error:unhandled-rejection'); + tag('lastError', scrubError(r && r.message ? r.message : r)); + try { window.clarity('upgrade', 'js-error'); } catch (_) {} + }); + + /* ---------- window foreground/background ---------- */ + document.addEventListener('visibilitychange', function () { + track(document.hidden ? 'app:hidden' : 'app:visible'); + }); +})(); diff --git a/src/renderer/analytics.js b/src/renderer/analytics.js index 87caf41..5f03762 100644 --- a/src/renderer/analytics.js +++ b/src/renderer/analytics.js @@ -2,10 +2,12 @@ /* * Usage analytics — Microsoft Clarity (https://clarity.microsoft.com). * - * Loaded FIRST in both windows (index.html + popover.html) so the `clarity` command + * Loaded (deferred) in both windows (index.html + popover.html) so the `clarity` command * queue exists before any UI code runs; the real tag is then loaded async through the * vendored @microsoft/clarity npm package (vendor/clarity, synced from node_modules - * by `npm run sync:clarity`). + * by `npm run sync:clarity`). This file owns the queue, identity, baseline tags and boot; + * the DOM event layer lives in analytics-events.js, which loads right after it and reads + * the window.ccTrack / window.ccTag helpers published here. * * Coverage: window opens, virtual page views (sidebar views, settings panes, popover * tabs), every click / right-click / double-click / drag, control changes, first @@ -61,151 +63,6 @@ track('open:' + SURFACE); track('view:' + (SURFACE === 'popover' ? 'popover/overview' : 'providers')); - /* ---------- interaction descriptors ---------- */ - // Enum-ish dataset keys whose VALUES are safe to report (fixed UI vocabulary). - var ENUM_KEYS = ['view', 'settings', 'tab', 'range', 'hrange', 'preset', 'copy', 'export', 'icon']; - // Content-bearing dataset keys: report the bare key name, never the value. - var NAME_KEYS = ['proj', 'file', 'id', 'target', 'act', 'tip']; - - function classToken(n) { - return typeof n.className === 'string' ? n.className.trim().split(/\s+/)[0] : ''; - } - function descriptor(start) { - for (var n = start, depth = 0; n && n.nodeType === 1 && depth < 15; n = n.parentElement, depth++) { - if (n.id) return '#' + n.id; - var d = n.dataset; - if (d) { - for (var i = 0; i < ENUM_KEYS.length; i++) if (d[ENUM_KEYS[i]]) return ENUM_KEYS[i] + '=' + d[ENUM_KEYS[i]]; - if (d.i18n) return 'i18n:' + d.i18n; - if (d.i18nTitle) return 'i18n:' + d.i18nTitle; - // Dynamic list items (provider cards, sessions, stream rows): label them by - // their semantic class token, never by the id/path payload they carry. - for (var j = 0; j < NAME_KEYS.length; j++) if (d[NAME_KEYS[j]] != null) return classToken(n) || 'data-' + NAME_KEYS[j]; - } - } - var el = start && start.closest ? start.closest('button, a, summary, label, [role="button"], input, select') : null; - var probe = el || start; - if (probe && probe.nodeType === 1) { - var cls = classToken(probe); - return probe.tagName.toLowerCase() + (cls ? '.' + cls : ''); - } - return 'unknown'; - } - - // Semantic funnel layer: element id → business event (fires alongside the raw click). - var FUNNEL = { - btnConnect: 'connect-toggle', - popConnect: 'connect-toggle', - btnAdd: 'provider-add', - btnAddEmpty: 'provider-add', - btnSave: 'provider-save', - btnTest: 'provider-test', - btnUpdateCheck: 'update-check', - btnUpdateDownload: 'update-download', - btnUpdateApply: 'update-apply', - btnUpdateOpen: 'update-open', - btnUpdateBrew: 'update-brew', - convImportBtn: 'conv-import', - convExportBtn: 'conv-export', - convReplayBtn: 'conv-replay', - convChatgptBtn: 'conv-chatgpt', - convCopyPathBtn: 'conv-copy-path', - btnCopyExport: 'copy-export', - btnGenToken: 'token-generate', - btnPickHistDir: 'histdir-pick', - popOpen: 'popover-open-main', - popQuit: 'app-quit' - }; - - /* ---------- listeners (capture phase, so no UI code can swallow them) ---------- */ - document.addEventListener('click', function (e) { - var t = e.target && e.target.nodeType === 1 ? e.target : null; - if (!t) return; - track('click:' + descriptor(t)); - - var host = t.closest ? t.closest('[id]') : null; - if (host && FUNNEL[host.id]) track('goal:' + FUNNEL[host.id]); - // Theme flips after the app handler runs — re-read it on the next tick. - if (host && host.id === 'btnTheme') { - setTimeout(function () { try { tag('theme', localStorage.getItem('ccbud-theme') || ''); } catch (_) {} }, 0); - } - - // Virtual page views: sidebar views, settings panes, popover tabs. - var nav = t.closest ? t.closest('[data-view],[data-settings],[data-tab]') : null; - if (nav) { - var d = nav.dataset; - var view = d.view || (d.settings ? 'settings/' + d.settings : 'popover/' + d.tab); - track('view:' + view); - tag('view', view); - } - var fmt = t.closest ? t.closest('[data-export]') : null; - if (fmt) track('goal:conv-export:' + fmt.dataset.export); - }, true); - - document.addEventListener('contextmenu', function (e) { - if (e.target && e.target.nodeType === 1) track('rclick:' + descriptor(e.target)); - }, true); - document.addEventListener('dblclick', function (e) { - if (e.target && e.target.nodeType === 1) track('dblclick:' + descriptor(e.target)); - }, true); - document.addEventListener('dragend', function (e) { - if (e.target && e.target.nodeType === 1) track('drag:' + descriptor(e.target)); - }, true); - - // Committed control changes: checkbox/radio state and enum select values are safe; - // for anything free-form only the field identity is reported. - document.addEventListener('change', function (e) { - var t = e.target; - if (!t || t.nodeType !== 1) return; - var name = t.id || t.name || descriptor(t); - var suffix = ''; - if (t.type === 'checkbox' || t.type === 'radio') suffix = t.checked ? ':on' : ':off'; - else if (t.tagName === 'SELECT') suffix = ':' + String(t.value).slice(0, 32); - track('change:' + name + suffix); - if (t.id === 'fLang') tag('lang', t.value); - }, true); - - // First keystroke per field per window life — signals "user typed here", no content. - var typed = {}; - document.addEventListener('input', function (e) { - var t = e.target; - if (!t || t.nodeType !== 1) return; - var k = t.id || t.name || t.tagName; - if (typed[k]) return; - typed[k] = 1; - track('input:' + k); - }, true); - - /* ---------- errors ---------- */ - // Error messages can embed user paths or URLs — redact those before tagging. - function scrubError(s) { - return String(s == null ? 'unknown' : s) - .replace(/(?:file|https?):\/\/[^\s'")]+/gi, '') - .replace(/(^|[\s'"(=:,])(?:~\/|\/)[^\s'")]+/g, '$1') - .replace(/[A-Za-z]:\\[^\s'")]+/g, '') - .slice(0, 120); - } - window.addEventListener('error', function (e) { - if (e && e.target && e.target !== w && e.target.nodeType === 1) { - track('error:resource:' + (e.target.tagName || '').toLowerCase()); - return; - } - track('error:js'); - tag('lastError', scrubError(e && e.message)); - try { w.clarity('upgrade', 'js-error'); } catch (_) {} - }, true); - window.addEventListener('unhandledrejection', function (e) { - var r = e && e.reason; - track('error:unhandled-rejection'); - tag('lastError', scrubError(r && r.message ? r.message : r)); - try { w.clarity('upgrade', 'js-error'); } catch (_) {} - }); - - /* ---------- window foreground/background ---------- */ - document.addEventListener('visibilitychange', function () { - track(document.hidden ? 'app:hidden' : 'app:visible'); - }); - /* ---------- boot the tag ---------- */ function fallbackInject() { try { diff --git a/src/renderer/conversations.js b/src/renderer/conversations.js deleted file mode 100644 index eac10f5..0000000 --- a/src/renderer/conversations.js +++ /dev/null @@ -1,1994 +0,0 @@ -'use strict'; - -/* "对话" view — reads Claude Code's on-disk session history (~/.claude/projects) directly - and renders it claude-code-history-viewer style: projects → sessions tree, a rich message - timeline (text / thinking / per-tool cards + results / diffs / code / images), live-follow - for active sessions, per-session stats, and in-conversation search. */ -(function () { - const api = window.ccbud; - if (!api) return; - const $ = (id) => document.getElementById(id); - const L = (k, p) => (window.I18n ? window.I18n.t(k, p) : k); // translate (t/$ already taken) - // Middle-ellipsis a long path so the start (/Users…) and meaningful tail (…/work) both stay visible. - const midEllip = (s, max) => { s = String(s == null ? '' : s); if (s.length <= max) return s; const k = max - 1, h = Math.ceil(k / 2), t = Math.floor(k / 2); return s.slice(0, h) + '…' + s.slice(s.length - t); }; - const ICN = window.ccbudIcons || {}; // SVG icon set (icons.js loads before this script) - const localeTag = () => (window.I18n ? window.I18n.localeTag : 'en-US'); - // Non-Claude session sources (meta.source): list-row chip label + assistant display name. - // Claude ('disk') deliberately has no chip — it's the app's home turf. - const SOURCE_NAMES = { codex: 'Codex', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity', qoder: 'Qoder' }; - const isForeignSource = (s) => !!SOURCE_NAMES[s]; - // conv.permissionDenied walks the user through macOS System Settings — that guidance only fits - // macOS (the helper-backed Qoder read path); other platforms show the generic read-failure copy. - const IS_MAC = /mac/i.test(navigator.platform || ''); - const readErrorKey = (kind) => (kind === 'permissionDenied' && IS_MAC - ? 'conv.permissionDenied' - : kind === 'notFound' ? 'conv.notFound' : 'conv.readFailed'); - - let projects = []; // [{ cwd, name, sessions:[...], lastActivity }] - let openId = null; - let openFile = null; - let search = ''; - // Big-search content matching (backend scan of session bodies — main, subagents, codex): - let contentHits = null; // Map for the current query; null = no content results yet - let contentSearching = false; // a backend content scan is in flight (list shows a "searching" hint) - let contentSeq = 0; // staleness guard: results from a superseded query are dropped - let contentTimer = null; - let pendingLocate = null; // { query, agent } — auto-locate target consumed after opening a content hit - let activeDir = 'all'; // active history bucket; '__trash__' = recycle bin (deleted sessions) - let tagFilter = null; // when set, the list shows only conversations carrying this exact tag - let tagClickTimer = null; // debounces a tag's single-click (filter) so a double-click (edit) can cancel it - let listTimer = null; - let collapsed = new Set(); // collapsed project cwds - let lastRender = { file: null, count: -1 }; - let currentDetail = null; // last-loaded session detail (for export) - let detailRequestSeq = 0; // drops a late historyGet result after another session/request took over - let detailRequest = null; // latest in-flight { seq, file }; also prevents timer requests piling up - // Failed detail reads retry via the safety-net timer: { file, attempts, nextAt }. - // permissionDenied probes steadily (granting macOS access emits no event we could watch); - // other read/IPC failures back off exponentially so a permanently broken transcript isn't - // re-read — and on macOS re-spawned through the helper — every 4 seconds forever. - let detailRetry = null; - // Which session occupies the main panel: 'main' (the root thread) or a subagent key (its tool_use - // id in detail.subagents). Each subagent is an independent session, so it gets the WHOLE panel — - // switched via the agent list in the right nav, not nested inline. Reset to 'main' on open. - let activeAgent = 'main'; - // Message list of one thread of the open session ('main' or a subagent key). - function threadMessages(agent) { - if (!currentDetail) return []; - if (agent !== 'main' && currentDetail.subagents && currentDetail.subagents[agent]) { - return currentDetail.subagents[agent].messages || []; - } - return agent === 'main' ? (currentDetail.messages || []) : []; - } - // The message list currently shown in the main panel (main thread or the active subagent's). - function activeMessages() { return threadMessages(activeAgent); } - // Render only the most recent N messages of a thread; a "load earlier" control reveals more. - // Huge threads (1000s of turns) otherwise put 1000s of nodes in the DOM, so every window - // resize / live re-render walks the whole tree (~1s) — the measured root cause of the jank. - // Windowed (virtualized) rendering: only a window [vStart, vEnd) of the thread is ever in the DOM. - // Browsing extends it via load-earlier/later; search/TOC jump renders a fresh window around the - // target. Keeps the DOM bounded (fast resize/scroll) and never renders thousands of messages at once. - const DETAIL_WIN = 160; // window size when opening / jumping (~115 rendered after skips — a lot of thread) - const LOAD_MORE = 120; // messages revealed per load-earlier / load-later click - const MAX_WIN = 240; // hard cap on rendered messages — load-more trims the far end past this. Keeps the - // DOM small so collapse/resize/scroll stay cheap no matter how far you browse. - let vStart = 0, vEnd = 0; // rendered window into the active thread's messages - // Per-message plain text for data-driven search: Map in reading order - // ('main' first, then subagents by call site). A Map — not a plain object — so transcript- - // supplied keys (tool_use ids) can't collide with Object.prototype and iteration order is - // exactly insertion order. Spans every thread, so the in-conversation search finds cross-agent - // content. Built lazily on first search (not on every live re-render) and invalidated on change. - let searchDocs = null; - - try { collapsed = new Set(JSON.parse(localStorage.getItem('ccbud-collapsed-projects') || '[]')); } catch (_) {} - function persistCollapsed() { try { localStorage.setItem('ccbud-collapsed-projects', JSON.stringify([...collapsed])); } catch (_) {} } - - // Detail search state (data-driven). Matches are found in the parsed message text (not the DOM) - // across EVERY thread of the open session — navigation steps through matching messages and - // switches the panel between main/subagents as it crosses thread boundaries. - let searchOcc = []; // [{ agent, mi }] — messages with ≥1 match, in reading order (main first) - let searchIndex = -1; // position in searchOcc; -1 = matches known but not navigated yet - let searchQuery = ''; - let searchTotalOcc = 0; // total match occurrences across all threads (shown after the count) - - if (window.marked && window.marked.setOptions) { - window.marked.setOptions({ gfm: true, breaks: true }); - // Defense-in-depth: never pass raw HTML from model/user text through to the DOM. - try { - window.marked.use({ renderer: { html: (tok) => esc(typeof tok === 'string' ? tok : (tok && tok.text) || '') } }); - } catch (_) {} - } - - /* ---------- helpers ---------- */ - function esc(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - } - function fmtTok(n) { - n = n || 0; - if (n < 1000) return String(n); - if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; - return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; - } - // Qoder Credits are billing units, not tokens or currency. Keep their fractional precision for - // individual turns, while abbreviating only large conversation totals. - function fmtCredits(n) { - n = Number(n); - if (!Number.isFinite(n)) return '—'; - const trim = (s) => s.replace(/(\.\d*?[1-9])0+$|\.0+$/, '$1'); - if (Math.abs(n) >= 1000) return trim((n / 1000).toFixed(Math.abs(n) < 10000 ? 1 : 0)) + 'K'; - if (Math.abs(n) >= 100) return trim(n.toFixed(1)); - if (Math.abs(n) >= 1) return trim(n.toFixed(2)); - return trim(n.toFixed(3)); - } - function truncate(s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n) + L('conv.charsMore', { n: s.length - n }) : s; } - // Size shown in KB until it's large enough to read better as MB / GB. - function fmtSizeKB(kb) { - kb = kb || 0; - if (kb < 1024) return kb + ' KB'; - if (kb < 1024 * 1024) return (kb / 1024).toFixed(1).replace(/\.0$/, '') + ' MB'; - return (kb / 1024 / 1024).toFixed(2).replace(/\.0+$/, '') + ' GB'; - } - function md(text) { try { return window.marked ? window.marked.parse(String(text || '')) : esc(text); } catch (_) { return esc(text); } } - function normContent(c) { - if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : []; - return Array.isArray(c) ? c : []; - } - // Codex records its initial AGENTS instructions and environment snapshot as two text blocks in - // one user message. Turn that XML-ish transport shape into compact Markdown for the transcript. - function formatCodexBootstrap(text) { - const source = String(text || ''); - const agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); - if (!agents) return null; - - const env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); - const parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; - const instructions = agents[2].trim(); - if (instructions) { - const lines = instructions.split(/\r?\n/).filter((line) => line.trim()); - parts.push(lines.length === 1 - ? '**INSTRUCTIONS:** ' + lines[0].trim() - : '**INSTRUCTIONS:**\n\n' + instructions); - } - - if (env) { - const block = env[1]; - const tag = (name) => { - const match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); - return match ? match[1].trim() : ''; - }; - const attr = (name, attribute) => { - const match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); - return match ? match[1].trim() : ''; - }; - const code = (value) => { - const tick = String.fromCharCode(96); - return value ? tick + value + tick : ''; - }; - const roots = []; - const rootRe = /]*>([\s\S]*?)<\/root>/gi; - let root; - while ((root = rootRe.exec(block)) !== null) { - if (root[1].trim()) roots.push(code(root[1].trim())); - } - const fields = [ - ['environment_context', code(tag('cwd'))], - ['shell', tag('shell')], - ['current_date', tag('current_date')], - ['timezone', tag('timezone')], - ['workspace_roots', roots.join(', ')], - ['permission_profile', attr('permission_profile', 'type')], - ['file_system', attr('file_system', 'type')], - ].filter((field) => field[1]); - if (fields.length) { - parts.push(fields.map((field) => '**' + field[0] + ':** ' + field[1]).join(' \n')); - } - } - - let rest = source.replace(agents[0], ''); - if (env) rest = rest.replace(env[0], ''); - rest = rest.trim(); - if (rest) parts.push(rest); - return parts.join('\n\n').trim(); - } - // Strip harness-injected blocks from user turns while keeping their human-facing content. - // A task notification is an XML envelope whose is the actual Markdown response; - // its IDs, status, summary, usage, and other transport metadata are not useful in the thread. - // Codex normalization turns a standalone envelope into a neutral `skill_load` card. - // Keep this raw-envelope suppression only as a legacy fallback, so it can never leak into a - // user bubble when older/imported data bypasses that normalizer. - // Returns '' when a turn contains injected metadata only. - function stripInjected(text) { - let source = String(text || ''); - const bootstrap = formatCodexBootstrap(source); - if (bootstrap != null) source = bootstrap; - // Only suppress the standalone Codex injection. Keep ordinary prose intact when a user is - // discussing or quoting markup alongside their own text. - if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; - return source - .replace(/]*>[\s\S]*?<\/task-notification>/gi, (block) => { - const result = /]*>([\s\S]*?)<\/result>/i.exec(block); - return result ? `\n${result[1].trim()}\n` : ''; - }) - .replace(/[\s\S]*?<\/system-reminder>/g, '') - .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') - .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') - .trim(); - } - function projName(cwd) { return cwd ? cwd.split('/').filter(Boolean).pop() : null; } - function isLive(ts) { return ts && (Date.now() - ts) < 90000; } - // Is the currently-open session still active (recent on-disk activity)? Used to drive the - // safety-net auto-refresh so in-progress conversations live-update even if a watch event is missed. - function openSessionLive() { - if (!openId) return false; - for (const p of projects) for (const s of p.sessions) if (s.id === openId) return isLive(s.lastActivity); - return false; - } - // Locate a loaded session by file (unique) or id — used by the rename/tag handlers to read its - // current title/tags before writing an updated set back. - function findSession(id, file) { - for (const p of projects) for (const s of p.sessions) if ((file && s.file === file) || (id && s.id === id)) return s; - return null; - } - - function relTime(ts) { - if (!ts) return ''; - const d = Date.now() - ts; - if (d < 60000) return L('time.justNow'); - if (d < 3600000) return L('time.minutesAgo', { n: Math.floor(d / 60000) }); - if (d < 86400000) return L('time.hoursAgo', { n: Math.floor(d / 3600000) }); - if (d < 7 * 86400000) return L('time.daysAgo', { n: Math.floor(d / 86400000) }); - return new Date(ts).toLocaleDateString(localeTag()); - } - - // Lightweight hover tooltip for truncated fields (overview stats, session titles, project names). - // Shows the FULL value instantly in an app-styled bubble — replaces the slow, system-default native - // `title` tooltip on these. Any element carrying a [data-tip] attribute gets it; event-delegated on - // document so it keeps working across the list's frequent re-renders. - (function initTip() { - let tipEl = null, cur = null; - const place = (el) => { - const txt = el.getAttribute('data-tip'); - if (!txt) return; - // body-level, so outside the Clarity-masked conversations section — mask it - // explicitly: it renders session titles and project paths. - if (!tipEl) { tipEl = document.createElement('div'); tipEl.className = 'cc-tip'; tipEl.setAttribute('data-clarity-mask', 'true'); document.body.appendChild(tipEl); } - tipEl.textContent = txt; - tipEl.classList.add('show'); - const r = el.getBoundingClientRect(); - const tw = tipEl.offsetWidth, th = tipEl.offsetHeight; - const left = Math.max(6, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 6)); - let top = r.top - th - 7; // prefer above the field - if (top < 6) top = r.bottom + 7; // flip below when there's no room - tipEl.style.left = left + 'px'; - tipEl.style.top = top + 'px'; - }; - const hide = () => { cur = null; if (tipEl) tipEl.classList.remove('show'); }; - // Only show the tooltip when the field is actually clipped (has the ellipsis); a fully-visible - // value like "glm-5.2" or "HEAD" needs no bubble. - const clipped = (el) => el.scrollWidth > el.clientWidth + 1; - document.addEventListener('mouseover', (e) => { - const el = e.target.closest && e.target.closest('[data-tip]'); - if (el === cur) return; - cur = el || null; - if (el && el.getAttribute('data-tip') && clipped(el)) place(el); else hide(); - }); - document.addEventListener('mouseout', (e) => { - const el = e.target.closest && e.target.closest('[data-tip]'); - if (el && !el.contains(e.relatedTarget)) hide(); - }); - document.addEventListener('scroll', hide, true); - window.addEventListener('blur', hide); - })(); - - function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - - const hasHighlightAPI = () => !!(window.CSS && CSS.highlights && typeof Highlight !== 'undefined'); - - function clearDetailSearchHighlights() { - if (hasHighlightAPI()) { CSS.highlights.delete('cd-search'); CSS.highlights.delete('cd-current'); } - searchOcc = []; searchIndex = -1; searchQuery = ''; searchTotalOcc = 0; - const countEl = $('convDetailSearchCount'); - if (countEl) countEl.textContent = ''; - } - - // Per-message plain text, for DATA-driven search — we find matches in the parsed messages (fast, no - // DOM), so search never has to render the whole thread. Built once when a conversation opens. - function contentToText(c) { - if (typeof c === 'string') return c; - if (Array.isArray(c)) return c.map((x) => (x && (x.text != null ? x.text : (typeof x.content === 'string' ? x.content : ''))) || '').join(' '); - return ''; - } - // Mirror renderMessage's logic so a thread's texts[i] is non-empty iff message i actually renders, - // and holds the SAME searchable text (incl. tool results, which render inside the assistant's tool - // card — not the user turn that carries them). Keeps search matches aligned with rendered messages. - function messagePlainText(m, results) { - const blocks = normContent(m.content); - const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); - if (skillLoads.length) { - return skillLoads.map((b) => [b.name, b.path, b.snapshot].filter(Boolean).join('\n')).join('\n'); - } - if (m.role === 'user') { - const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); - if (!vis.length) return ''; - // Same stripping renderMessage applies: injected reminders/commands are unsearchable, but the - // human prose beside them (e.g. the first turn, which carries a reminder) IS. - return vis.map((b) => (b.type === 'text' ? stripInjected(b.text) : '')).filter(Boolean).join('\n'); - } - let s = ''; - for (const b of blocks) { - if (b.type === 'text') s += (b.text || '') + '\n'; - else if (b.type === 'thinking') s += (b.thinking || '') + '\n'; - else if (b.type === 'tool_use') { s += (b.name || '') + ' ' + (b.input ? JSON.stringify(b.input) : '') + '\n'; const r = results && results[b.id]; if (r) s += contentToText(r.content) + '\n'; } - } - return s; - } - // Thread keys in reading order — main first, then subagents by where they were spawned in the - // main thread (unresolved call sites sort last). Cross-agent search steps through this order. - function searchAgentOrder() { - const subs = (currentDetail && currentDetail.subagents) || {}; - const keys = Object.keys(subs); - if (!keys.length) return ['main']; - if (!subIndex) buildSubIndex(); - const pos = (k) => { const cs = subIndex.callSite.get(k); return cs && cs.thread === 'main' ? cs.mi : Infinity; }; - keys.sort((a, b) => pos(a) - pos(b)); - return ['main'].concat(keys); - } - function buildSearchDocs() { - searchDocs = new Map(); - for (const agent of searchAgentOrder()) { - const msgs = threadMessages(agent); - const results = buildResults(msgs); - searchDocs.set(agent, msgs.map((m) => messagePlainText(m, results))); - } - } - - // Highlight every match inside the CURRENT window via the CSS Custom Highlight API (Range-based, zero - // DOM mutation). The window is bounded, so this is tiny + fast. Re-run after each window paint. - function refreshWindowHighlights() { - if (!hasHighlightAPI()) return; - const host = $('convDetail'); if (!host || !searchQuery) { if (hasHighlightAPI()) CSS.highlights.delete('cd-search'); return; } - let re; try { re = new RegExp(escapeRegExp(searchQuery), 'gi'); } catch (_) { return; } - const h = new Highlight(); - const w = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, null); let node; - while ((node = w.nextNode())) { - const text = node.nodeValue; if (!text || !text.trim()) continue; re.lastIndex = 0; let m; - while ((m = re.exec(text)) !== null) { - try { const r = document.createRange(); r.setStart(node, m.index); r.setEnd(node, m.index + m[0].length); h.add(r); } catch (_) {} - if (m[0].length === 0) re.lastIndex++; - } - } - CSS.highlights.set('cd-search', h); - } - - // Run the message search across EVERY thread (main + subagents). Landing rules: - // - opts.agent (big-search auto-locate): jump to that thread's first match, switching the panel; - // - opts.first (Enter confirm): jump to the first match overall, switching if needed; - // - opts.silent (live refresh): recompute counts/highlights only, never move the view; - // - default (typing): jump only within the CURRENT thread — matches elsewhere just show in the - // count until the user navigates (Enter / ↑↓), so the panel never switches under the cursor. - function performDetailSearch(query, opts) { - opts = opts || {}; - searchQuery = query || ''; - if (hasHighlightAPI()) { CSS.highlights.delete('cd-search'); CSS.highlights.delete('cd-current'); } - searchOcc = []; searchIndex = -1; searchTotalOcc = 0; - const c = $('convDetailSearchCount'); - if (!query) { if (c) c.textContent = ''; return; } - if (!searchDocs) buildSearchDocs(); - let re; try { re = new RegExp(escapeRegExp(query), 'gi'); } catch (_) { return; } - // Scan the parsed message texts (NOT the DOM) — each matching message lists once in searchOcc, - // and every match inside it is highlighted on arrival. Map iteration = insertion order = - // reading order (main first, then subagents by call site), so navigation is deterministic. - for (const [agent, texts] of searchDocs) { - for (let i = 0; i < texts.length; i++) { - const t = texts[i]; if (!t) continue; re.lastIndex = 0; let m, has = false; - while ((m = re.exec(t)) !== null) { searchTotalOcc++; has = true; if (m[0].length === 0) re.lastIndex++; } - if (has) searchOcc.push({ agent, mi: i }); - } - } - if (!searchOcc.length) { if (c) c.textContent = '0/0'; return; } - if (opts.silent) { updateSearchCount(); refreshWindowHighlights(); return; } - let target = -1; - if (opts.agent) { target = searchOcc.findIndex((o) => o.agent === opts.agent); if (target < 0) target = 0; } - else if (opts.first) target = 0; - else target = searchOcc.findIndex((o) => o.agent === activeAgent); - if (target >= 0) gotoDetailSearchMatch(target); - else { updateSearchCount(); refreshWindowHighlights(); } // matches exist, none here — count only - } - - function updateSearchCount() { - const c = $('convDetailSearchCount'); if (!c) return; - if (!searchOcc.length) { c.textContent = searchQuery ? '0/0' : ''; return; } - const pos = searchIndex >= 0 ? String(searchIndex + 1) : '–'; - c.textContent = `${pos}/${searchOcc.length}` + (searchTotalOcc > searchOcc.length ? ` · ${searchTotalOcc}` : ''); - } - - // Navigate to match #newIndex (wraps): switch the panel to the match's thread when it lives in a - // different agent, bring the message into the window, highlight every match in the window, and - // mark + centre the first match in the target message. Bounded — never renders a whole thread. - function gotoDetailSearchMatch(newIndex) { - const len = searchOcc.length; if (!len) return; - searchIndex = ((newIndex % len) + len) % len; - const occ = searchOcc[searchIndex]; - if (occ.agent !== activeAgent) setPanelAgent(occ.agent); // cross-agent step: move the panel first - const mi = occ.mi; - jumpToMessage(mi, 'center'); - refreshWindowHighlights(); - const host = $('convDetail'); - const el = host && host.querySelector(`[data-mi="${mi}"]`); - const skillSnapshot = el && el.querySelector('.skill-snapshot'); - const skillBody = skillSnapshot && skillSnapshot.querySelector('.skill-snapshot-body'); - if (skillSnapshot && skillBody && searchQuery - && String(skillBody.textContent || '').toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase())) { - skillSnapshot.open = true; - refreshWindowHighlights(); - } - if (el && hasHighlightAPI() && searchQuery) { - let re; try { re = new RegExp(escapeRegExp(searchQuery), 'gi'); } catch (_) { re = null; } - let curRange = null; - if (re) { - const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); let node; - while ((node = w.nextNode()) && !curRange) { - const text = node.nodeValue; if (!text) continue; re.lastIndex = 0; const m = re.exec(text); - if (m) { curRange = document.createRange(); curRange.setStart(node, m.index); curRange.setEnd(node, m.index + m[0].length); } - } - } - if (curRange) { - const cur = new Highlight(); cur.add(curRange); CSS.highlights.set('cd-current', cur); - const rect = curRange.getBoundingClientRect(); const hr = host.getBoundingClientRect(); - if (rect && hr && rect.height) host.scrollTop += (rect.top - hr.top) - host.clientHeight / 2; - } - } - updateSearchCount(); - } - - /* ---------- list (projects → sessions) ---------- */ - async function refresh() { - try { projects = (await api.historyProjects()) || []; } catch (_) { projects = []; } - // Fetch dir stats up front so activeDir (which renderList reads for trash mode) is set before - // the list renders — and pass the data into renderDirSwitch to avoid a second round-trip. - let dirData = null; - if (api.historyDirs) { try { dirData = await api.historyDirs(); } catch (_) { dirData = null; } } - activeDir = (dirData && dirData.active) || 'all'; - renderDirSwitch(dirData); - renderList(); - } - - async function renderDirSwitch(pre) { - const host = $('convDirSwitch'); - if (!host || !api.historyDirs) return; - let data = pre; - if (!data) { try { data = await api.historyDirs(); } catch (_) { data = { dirs: [], active: 'all' }; } } - const active = data.active || 'all'; - const allDirs = data.dirs || []; - // Recycle bin: a synthetic bucket of soft-deleted sessions. Surface its chip whenever something - // is in it (or it's the active view), so single-dir users still get an entry point. - const trashEntry = allDirs.find((d) => d.id === '__trash__' || d.trash); - const trashN = trashEntry ? (trashEntry.sessions || 0) : 0; - const showTrash = trashN > 0 || active === '__trash__'; - // Hide the synthetic 导入 chip until something is actually imported (keeps the bar clean / - // unchanged for single-dir users). The + button is the import entry point regardless. - const dirs = allDirs.filter((d) => d.id !== '__trash__' && !d.trash && !(d.imported && !d.sessions)); - const showDirs = dirs.length > 1; - if (!showDirs && !showTrash) { host.classList.add('hidden'); host.innerHTML = ''; return; } - const opts = [{ id: 'all', label: L('conv.all') }].concat(showDirs ? dirs.map((d) => ({ id: d.id, label: d.id === '__imported__' ? L('conv.importedDir') : d.label, imported: d.imported, sessions: d.sessions })) : []); - host.classList.remove('hidden'); - let html = opts.map((o) => ``).join(''); - if (showTrash) { - html += ``; - } - host.innerHTML = html; - } - - function filteredProjects() { - if (!search && !tagFilter) return projects; - const q = search.toLowerCase(); - return projects - .map((p) => { - const sessions = p.sessions.filter((s) => { - if (tagFilter && (s.tags || []).indexOf(tagFilter) < 0) return false; - if (!q) return true; - if ((s.title || '').toLowerCase().includes(q) || - (s.model || '').toLowerCase().includes(q) || - (p.name || '').toLowerCase().includes(q) || - (s.tags || []).some((t) => t.toLowerCase().includes(q))) return true; - // Content match (async backend scan of message bodies, incl. subagents) — see - // scheduleContentSearch; these rows carry a snippet in sessionItem. - return !!(contentHits && contentHits.has(s.file)); - }); - return sessions.length ? Object.assign({}, p, { sessions }) : null; - }) - .filter(Boolean); - } - - // Big-search content matching: ask the backend to scan session BODIES (message text, thinking, - // tool calls/results — main thread, every subagent transcript, codex rollouts) for the query. - // Debounced per keystroke; responses for a superseded query are dropped. Field filtering above - // stays instant — content hits merge into the same list as they arrive. - function scheduleContentSearch() { - clearTimeout(contentTimer); - contentSeq++; - contentHits = null; - contentSearching = false; - const q = search; - if (!q || !api.historySearch) return; - contentSearching = true; - const seq = contentSeq; - contentTimer = setTimeout(async () => { - let res = null; - try { res = await api.historySearch(q); } catch (_) { res = null; } - if (seq !== contentSeq) return; // a newer query took over while this one was scanning - contentSearching = false; - const map = new Map(); - for (const h of (Array.isArray(res) ? res : [])) if (h && h.file) map.set(h.file, h); - contentHits = map; - renderList(); - }, 220); - } - - // Escape a content snippet and wrap query matches in for the session row. - function markSnippet(text, q) { - const s = String(text || ''); - if (!q) return esc(s); - let re; try { re = new RegExp(escapeRegExp(q), 'gi'); } catch (_) { return esc(s); } - let out = '', last = 0, m; - while ((m = re.exec(s)) !== null) { - out += esc(s.slice(last, m.index)) + '' + esc(m[0]) + ''; - last = m.index + m[0].length; - if (m[0].length === 0) re.lastIndex++; - } - return out + esc(s.slice(last)); - } - - function renderList() { - const el = $('convList'); - if (!el) return; - const list = filteredProjects(); - const total = list.reduce((n, p) => n + p.sessions.length, 0); - const fbar = tagFilter - ? `
🏷 ${esc(tagFilter)}
` - : ''; - if (!total) { - const emptyMsg = (search || tagFilter) - ? esc(search && contentSearching ? L('conv.searching') : L('conv.noMatch')) - : (activeDir === '__trash__' - ? esc(L('conv.trashEmpty')) - : esc(L('conv.noLocal')) + '
~/.claude/projects'); - el.innerHTML = fbar + `
${emptyMsg}
`; - return; - } - el.innerHTML = fbar + list.map((p) => { - const isCol = collapsed.has(p.cwd || p.name) && !search; - const items = isCol ? '' : `
${orderSessionRows(p.sessions).map(sessionItem).join('')}
`; - return `
-
- ${isCol ? '▸' : '▾'} - ${esc(p.name || L('conv.unknownProject'))} - ${p.sessions.length} -
${items} -
`; - }).join(''); - } - - // Codex assigns one session_id to the whole root/subagent tree. Keep that tree together in the - // list, but key each node by its canonical first SessionMeta.id. The first bucket encounter is - // already the newest activity in the backend order; within it, root precedes recursively nested - // children so parallel agents no longer look like duplicate top-level conversations. - function orderSessionRows(sessions) { - const buckets = new Map(); - (sessions || []).forEach((session, index) => { - const grouped = session.source === 'codex' && session.canonicalThreadIdValid && session.rootSessionId; - // Keep live/configured/imported stores independent. A copied snapshot may share both root - // and thread ids with a live rollout, but must never replace it merely because its copy mtime - // is newer. - const key = grouped - ? `codex:${session.dirId || ''}:${session.rootSessionId}` - : `row:${session.id || ''}:${session.file || index}`; - if (!buckets.has(key)) buckets.set(key, { index, activity: 0, sessions: [] }); - const bucket = buckets.get(key); - bucket.activity = Math.max(bucket.activity, session.lastActivity || 0); - bucket.sessions.push(session); - }); - const ordered = []; - [...buckets.values()].sort((a, b) => b.activity - a.activity || a.index - b.index).forEach((bucket) => { - if (bucket.sessions.length === 1 || bucket.sessions[0].source !== 'codex') { - ordered.push(...bucket.sessions); - return; - } - const byParent = new Map(); - bucket.sessions.forEach((session) => { - const parent = session.parentThreadId || ''; - if (!byParent.has(parent)) byParent.set(parent, []); - byParent.get(parent).push(session); - }); - const newest = (a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) - || (b.createdAt || 0) - (a.createdAt || 0) - || String(a.threadId || a.id || '').localeCompare(String(b.threadId || b.id || '')) - || String(a.file || '').localeCompare(String(b.file || '')); - byParent.forEach((children) => children.sort(newest)); - const seen = new Set(); - const append = (session) => { - const id = session.canonicalThreadIdValid - ? (session.threadId || session.sessionId || session.id) - : `${session.id || ''}:${session.file || ''}`; - if (seen.has(id)) return; - seen.add(id); ordered.push(session); - (byParent.get(id) || []).forEach(append); - }; - bucket.sessions - .filter((session) => !session.isSubagent || session.threadId === session.rootSessionId) - .sort(newest) - .forEach(append); - bucket.sessions.sort((a, b) => (a.agentDepth || 0) - (b.agentDepth || 0) || newest(a, b)).forEach(append); - }); - return ordered; - } - - // Two timestamps: the session's start (createdAt, the sort key) and — only when it meaningfully - // differs — the last-updated time, so an edited/active session shows both without redundancy. - function metaTimes(c) { - const created = c.createdAt || c.lastActivity; - const start = `${esc(relTime(created))}`; - const updated = (c.lastActivity && created && c.lastActivity - created > 60000) - ? `${esc(L('conv.updatedPrefix'))} ${esc(relTime(c.lastActivity))}` - : ''; - return start + updated; - } - function sessionItem(c) { - const live = isLive(c.lastActivity) ? '' : ''; - const subLabel = [L('conv.subagent'), c.agentNickname].filter(Boolean).join(' · '); - const sub = c.isSubagent ? `${esc(subLabel)}` : ''; - const imp = c.imported ? `${ICN.download || ''}${esc(L('conv.imported'))}` : ''; - // Non-Claude sources carry a small origin chip so a mixed project group stays readable. - const srcName = SOURCE_NAMES[c.source]; - const srcBadge = srcName ? `${esc(srcName)}` : ''; - // A row whose transcript couldn't be read explains itself on hover instead of sitting as a - // silent untitled entry (the reason only became visible after clicking before). - const rerr = c.readError; - const errBadge = rerr ? `` : ''; - // Recycle bin rows swap the import-remove affordance for restore + delete-forever; everywhere else - // imported copies (which live only in the app store) keep their remove affordance. - const inTrash = activeDir === '__trash__'; - // A LIVE session of another CLI (codex/grok/copilot/antigravity/qoder, not an imported - // copy) is that tool's file — it can be restored but NEVER permanently deleted, since the - // app must not rm another tool's data. - const foreign = isForeignSource(c.source) && !c.imported; - const restoreBtn = ``; - const deleteForeverBtn = ``; - const rm = inTrash - ? restoreBtn + (foreign ? '' : deleteForeverBtn) - : (c.imported ? `` : ''); - const model = c.model ? `${esc(c.model)}` : ''; - // User tags (deletable: x; double-click to edit; click to filter). The import badge stays - // separate and non-deletable. Empty when the conversation has no custom tags. - const tags = (c.tags || []).map((t) => - `${esc(t)}`).join(''); - const tagsRow = tags ? `
${tags}
` : ''; - // Content-search hit: show WHERE the query matched — a highlighted snippet, badged with the - // subagent's type when the match lives inside one (clicking auto-opens there). - const hit = (search && contentHits) ? contentHits.get(c.file) : null; - const snipRow = hit && hit.snippet - ? `
${hit.agent && hit.agent !== 'main' ? `🤖 ${esc(hit.agentType || L('conv.subagent'))} ` : ''}${markSnippet(hit.snippet, search)}${hit.count > 1 ? ` ×${hit.count}` : ''}
` - : ''; - // Full title on hover; when a custom title overrides the auto one, also surface the original first line. - const fullTitle = c.title || L('conv.untitled'); - const tip = (c.autoTitle && c.title && c.autoTitle !== c.title) ? (fullTitle + ' · ' + c.autoTitle) : fullTitle; - const treeDepth = c.source === 'codex' && c.isSubagent ? Math.max(1, Math.min(Number(c.agentDepth) || 1, 5)) : 0; - const treeIndent = 22 + treeDepth * 13; - const treeMark = treeDepth ? '' : ''; - return `
-
${treeMark}${live}${esc(fullTitle)}${rm}
-
${model}${srcBadge}${errBadge}${sub}${imp}
- ${snipRow} - ${tagsRow} -
${metaTimes(c)}${c.sizeKB ? '' + fmtSizeKB(c.sizeKB) + '' : ''}
-
`; - } - - /* ---------- detail ---------- */ - // The right rail (overview + navigation) only means something for an open conversation — hide - // it (and its resizer) entirely when nothing is selected. - function syncConvNav() { - const nav = document.querySelector('.conv-nav'); - const rs = document.querySelector('.conv-resizer-right'); - if (nav) nav.classList.toggle('hidden', !openFile); - if (rs) rs.classList.toggle('hidden', !openFile); - if (!openFile) detailRetry = null; - } - - // Drop all transcript-derived UI while a different session loads or the current read fails. - // openFile/openId deliberately stay untouched, so the selected row and navigation rail remain - // present and a later retry can recover in place without showing data from the previous session. - function clearLoadedDetail() { - currentDetail = null; - searchDocs = null; - subIndex = null; - renderAgentTabs(null); - const stats = $('convStats'); if (stats) stats.innerHTML = ''; - const toc = $('convToc'); if (toc) toc.innerHTML = ''; - } - - async function openConversation(id, file) { - const ds = $('convDetailSearch'); - if (ds) ds.value = ''; - clearDetailSearchHighlights(); - openId = id; openFile = file || null; - syncConvNav(); - activeAgent = 'main'; // new conversation always opens on its main thread - detailRetry = null; - clearLoadedDetail(); - vStart = 0; vEnd = 0; // reset the render window for the new conversation - lastRender = { file: null, count: -1 }; - const eb = $('convExportBtn'); if (eb) eb.disabled = !openFile; - const cp = $('convCopyPathBtn'); if (cp) cp.disabled = !openFile; - const rp = $('convReplayBtn'); if (rp) rp.disabled = !openFile; - const cg = $('convChatgptBtn'); if (cg) cg.disabled = !openFile; - const mb = $('convMoreBtn'); if (mb) mb.disabled = !openFile; - renderList(); - // Big sessions take a beat to read+parse off disk — show a loading hint during the async fetch - // (this wait is genuinely async/IPC, so the hint paints; the later render is what's kept bounded). - const host = $('convDetail'); - if (host && openFile) host.innerHTML = `
${esc(L('conv.loading'))}
`; - await rerenderDetail(true); - // Opened from a big-search content hit: restore the query in the message search box, move the - // panel to the matched thread (subagent hits switch automatically), and land on the match. - // The openFile identity check drops the jump when another session was opened mid-load. - if (pendingLocate && openFile && openFile === (file || null) && currentDetail) { - const pl = pendingLocate; pendingLocate = null; - if (ds) ds.value = pl.query; - performDetailSearch(pl.query, { agent: pl.agent || 'main' }); - } - } - - async function rerenderDetail(force) { - if (!openFile) return; - const requestedFile = openFile; - // The live/error retry timer can fire while a slower helper-backed Qoder read is still running. - // One request for the currently-selected file is enough; a genuinely different selection may - // start immediately and its newer sequence invalidates this result. - if (detailRequest && detailRequest.file === requestedFile) { - if (force) detailRequest.force = true; // preserve a language-change/re-open forced paint - return; - } - const request = { seq: ++detailRequestSeq, file: requestedFile, force: !!force }; - detailRequest = request; - let detail = null; - let ipcReadFailed = false; - try { detail = await api.historyGet(requestedFile); } catch (_) { ipcReadFailed = true; } - if (detailRequest === request) detailRequest = null; - // A→B (or A→B→A) can leave older IPC calls in flight. Never let their success/error overwrite - // the latest selection, even when the path happens to match again after an intervening click. - if (openFile !== requestedFile || request.seq !== detailRequestSeq) return; - force = request.force; - const host = $('convDetail'); - if (!host) return; - // A failed read is distinct from a missing/moved transcript. Keep openFile intact so the - // selected row + navigation rail remain open and a later retry (for example after granting - // macOS access to Qoder's data) can recover in place. - const loadError = ipcReadFailed ? { kind: 'readFailed' } : (detail && detail.error); - if (loadError || !detail) { - const kind = loadError && loadError.kind; - const key = !loadError ? 'conv.notFound' : readErrorKey(kind); - host.innerHTML = `
${esc(L(key))}
`; - clearLoadedDetail(); - // A missing/moved path is not expected to recover in place. Permission failures re-probe at - // the timer's steady 4s; other read/IPC failures back off (4s → 60s cap) per attempt. - if (key === 'conv.notFound') { - detailRetry = null; - } else { - const attempts = (detailRetry && detailRetry.file === requestedFile ? detailRetry.attempts : 0) + 1; - const delay = kind === 'permissionDenied' ? 0 : Math.min(4000 * 2 ** (attempts - 1), 60000); - detailRetry = { file: requestedFile, attempts, nextAt: Date.now() + delay }; - } - lastRender = { file: null, count: -1 }; - return; - } - detailRetry = null; - currentDetail = detail; - subIndex = null; // call-site map is rebuilt lazily against the freshly-loaded subagents - - const messages = detail.messages || []; - const msgLen = (m) => { - if (!m || !m.content) return 0; - if (typeof m.content === 'string') return m.content.length; - if (Array.isArray(m.content)) return m.content.reduce((sum, b) => sum + (b.text ? b.text.length : 0) + (b.thinking ? b.thinking.length : 0), 0); - return 0; - }; - let contentLen = messages.reduce((acc, m) => acc + msgLen(m), 0); - // Fold subagent growth into the change key too: while a subagent streams, the main thread can - // sit idle, and the skip-guard below would otherwise freeze the nested subagent view mid-run. - const subs = detail.subagents || {}; - let subCount = 0; - for (const k of Object.keys(subs)) { - const sm = (subs[k] && subs[k].messages) || []; - subCount += sm.length; - contentLen += sm.reduce((acc, m) => acc + msgLen(m), 0); - } - - // Skip needless re-renders: on-disk turns are written whole, so a stable message count - // and content length means nothing changed — preserves scroll + expanded thinking/result panels. - if (!force && lastRender.file === openFile && lastRender.count === messages.length && lastRender.contentLen === contentLen && lastRender.subCount === subCount && host.querySelector('.msg')) return; - - const total = activeMessages().length; // window/paint follow the ACTIVE session (main or subagent) - const wasBottom = isNearBottom(host); - searchDocs = null; // content changed (or fresh open) — search docs rebuild lazily on next use - if (force) { - clearDetailSearchHighlights(); - // A still-running session opens at the newest turns (trailing window, pinned to the bottom) so - // it live-follows. A finished history conversation opens at the START — leading window scrolled - // to the top — so the first human message is what you see, not the tail. (Subagent threads, which - // have no live-follow semantics, also read top-down.) - const followTail = activeAgent === 'main' && openSessionLive(); - if (followTail) { - vEnd = total; vStart = Math.max(0, total - DETAIL_WIN); - paintWindow(); - host.scrollTop = host.scrollHeight; - } else { - vStart = 0; vEnd = Math.min(total, DETAIL_WIN); - paintWindow(); - host.scrollTop = 0; - } - } else if (wasBottom) { - // live-follow at the bottom: extend the window to the newest and stay pinned to the bottom - vEnd = total; vStart = Math.max(0, total - DETAIL_WIN); - paintWindow(); - host.scrollTop = host.scrollHeight; - } else { - // scrolled up reading history: don't repaint (preserves scroll + expanded panels); new turns are - // appended past the window and surface via the "load later" affordance / next jump. - vEnd = Math.min(vEnd, total); - } - // A live-updating session with an active search: refresh counts/highlights against the new - // content without moving the view, keeping the current position when it still exists. - // (force paths cleared the search above.) - if (searchQuery) { - const cur = searchIndex >= 0 ? searchOcc[searchIndex] : null; - performDetailSearch(searchQuery, { silent: true }); - if (cur) { - const i = searchOcc.findIndex((o) => o.agent === cur.agent && o.mi === cur.mi); - if (i >= 0) { searchIndex = i; updateSearchCount(); } - } - } - renderSidePanels(detail); - renderAgentTabs(detail); - lastRender = { file: openFile, count: messages.length, contentLen, subCount }; - } - function isNearBottom(el) { return el.scrollHeight - el.scrollTop - el.clientHeight < 120; } - // Add a GitHub-style line-number gutter to a code block (after highlighting, so token spans are - // intact). Skipped for plain blocks (terminal/JSON output) and once already applied. - function addGutter(pre) { - if (!pre || pre.dataset.gutter || pre.classList.contains('cb-plain')) return; - const code = pre.querySelector('code'); - if (!code) return; - let n = (code.textContent || '').replace(/\n+$/, '').split('\n').length; - if (n < 1) n = 1; - let s = ''; - for (let i = 1; i <= n; i++) s += i + (i < n ? '\n' : ''); - const g = document.createElement('span'); - g.className = 'cb-gutter'; - g.setAttribute('aria-hidden', 'true'); - g.textContent = s; - pre.insertBefore(g, code); - pre.classList.add('cb-has-gutter'); - pre.dataset.gutter = '1'; - } - function highlight(root) { - if (!window.hljs) return; - root.querySelectorAll('pre code').forEach((code) => { - if (code.classList.contains('nohljs')) return; // plain output (terminal / JSON) — no highlight, no gutter - if (!code.dataset.highlighted) { try { window.hljs.highlightElement(code); } catch (_) {} } - addGutter(code.parentElement); // GitHub-style line-number gutter - }); - } - - function buildResults(messages) { - const results = {}; - messages.forEach((m) => normContent(m.content).forEach((b) => { if (b.type === 'tool_result') results[b.tool_use_id] = b; })); - return results; - } - // Returns the HTML for one message, or '' for a pure tool_result / hidden meta user turn. - // Structured metadata such as a loaded Skill is rendered before the role branches so it reads - // as an event in the timeline, rather than being mislabeled as either the user or the assistant. - // inSub: rendered inside a nested subagent block — suppress the per-turn "subagent" badge - // (the surrounding block already labels it) so the nested thread stays clean. - function renderMessage(m, results, idx, inSub) { - const mid = idx == null ? '' : ` id="m${idx}" data-mi="${idx}"`; - const blocks = normContent(m.content); - const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); - if (skillLoads.length) { - return `
${skillLoads.map(renderSkillLoad).join('')}
`; - } - if (m.role === 'user') { - const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); - if (!vis.length) return ''; - // Strip harness-injected noise but keep the human prose — the first user turn carries an - // appended , and the old "contains a tag → drop the whole turn" rule made - // that turn (the one that also seeds the title) disappear from the panel. - const clean = vis - .map((b) => (b.type === 'text' ? { type: 'text', text: stripInjected(b.text) } : b)) - .filter((b) => b.type === 'image' || b.text); - if (!clean.length) return ''; - return `
👤 ${esc(L('conv.you'))}
${clean.map(renderUserBlock).join('')}
`; - } - let body = ''; - blocks.forEach((b) => { - if (b.type === 'text') body += `
${md(b.text)}
`; - else if (b.type === 'thinking') body += renderThinking(b); - else if (b.type === 'tool_use') body += renderToolCard(b, results[b.id]); - else if (b.type === 'image') body += renderUserBlock(b); - else body += `
${esc(JSON.stringify(b))}
`; - }); - if (!body) return ''; - return `
✦ ${esc(assistantName())}${m.isSidechain && !inSub ? ` ${esc(L('conv.subagent'))}` : ''}
${body}${turnMeta(m)}
`; - } - // Assistant display name for the open session — "Codex" for codex rollouts, else Claude. - function assistantName() { - return (currentDetail && currentDetail.meta && currentDetail.meta.assistant) || 'Claude'; - } - function winBtn(dir, n) { - const lbl = esc(L('conv.loadEarlier', { n })); - return ``; - } - // HTML for the current window [vStart,vEnd) into currentDetail.messages, plus load-earlier/later buttons. - function renderWindow() { - const messages = activeMessages(); - const total = messages.length; - if (!total) return `
${esc(L('conv.emptyConv'))}
`; - const results = buildResults(messages); // scan ALL so tool_use cards resolve their result even if out of window - const inSub = activeAgent !== 'main'; // in a subagent view the whole panel is that agent — drop per-turn badge - let html = vStart > 0 ? winBtn('earlier', vStart) : ''; - for (let i = vStart; i < vEnd; i++) html += renderMessage(messages[i], results, i, inSub); - if (vEnd < total) html += winBtn('later', total - vEnd); - return html || `
${esc(L('conv.emptyConv'))}
`; - } - function paintWindow() { - const host = $('convDetail'); if (!host) return; - host.innerHTML = renderWindow(); - highlight(host); - refreshWindowHighlights(); // re-paint search highlights for the new window (no-op if not searching) - } - // The first message whose bottom is below the viewport top, with its offset within the viewport — - // used to keep the view fixed across a repaint even when content is both added AND trimmed. - function visibleAnchor() { - const host = $('convDetail'); if (!host) return null; - const hr = host.getBoundingClientRect(); - const els = host.querySelectorAll('[data-mi]'); - for (const el of els) { const r = el.getBoundingClientRect(); if (r.bottom > hr.top + 2) return { mi: +el.dataset.mi, off: r.top - hr.top }; } - return null; - } - function anchoredPaint(a) { - paintWindow(); - const host = $('convDetail'); - const el = a && host && host.querySelector(`[data-mi="${a.mi}"]`); - if (el) host.scrollTop += (el.getBoundingClientRect().top - host.getBoundingClientRect().top) - a.off; - } - // Extend the window upward / downward; trim the far end past MAX_WIN so the DOM stays bounded. - // Anchored on a currently-visible message so the viewport doesn't jump despite add+trim. - function loadEarlier() { - const host = $('convDetail'); if (!host || vStart <= 0) return; - const a = visibleAnchor(); - vStart = Math.max(0, vStart - LOAD_MORE); - if (vEnd - vStart > MAX_WIN) vEnd = vStart + MAX_WIN; // trim the (off-screen) bottom - anchoredPaint(a); - } - function loadLater() { - const host = $('convDetail'); if (!host) return; - const total = activeMessages().length; - if (vEnd >= total) return; - const a = visibleAnchor(); - vEnd = Math.min(total, vEnd + LOAD_MORE); - if (vEnd - vStart > MAX_WIN) vStart = vEnd - MAX_WIN; // trim the (off-screen) top - anchoredPaint(a); - } - // Render a fresh window centred on message `mi` and bring it into view. - function jumpToMessage(mi, block) { - const total = activeMessages().length; - if (!total) return null; - mi = Math.max(0, Math.min(total - 1, mi)); - if (mi < vStart || mi >= vEnd || vEnd - vStart > DETAIL_WIN * 2) { - vStart = Math.max(0, mi - Math.floor(DETAIL_WIN / 2)); - vEnd = Math.min(total, vStart + DETAIL_WIN); - paintWindow(); - } - const host = $('convDetail'); - const el = host && host.querySelector(`[data-mi="${mi}"]`); - if (el) el.scrollIntoView({ block: block || 'center' }); - return el; - } - - function renderUserBlock(b) { - if (b.type === 'image') { - const s = b.source || {}; - if (s.data) return ``; - return `
🖼 ${esc(L('conv.image'))}
`; - } - return `
${md(b.text)}
`; - } - function renderThinking(b) { - const t = b.thinking || ''; - // Some turns carry a thinking block with only a signature and no visible text (the model/upstream - // returned encrypted/empty reasoning). Skip it rather than draw an empty collapsible. - if (!t.trim()) return ''; - const first = t.split('\n').find((x) => x.trim()) || L('conv.thinking'); - return `
💭 ${esc(L('conv.thinking'))} · ${esc(first.slice(0, 60))}
${md(t)}
`; - } - // A Skill envelope is an automatic Codex context-load event. Its recorded body is the exact - // snapshot used for that turn, so keep it collapsed by default but make the full source available - // for later workflow/debug reviews. It deliberately carries no user/assistant role label. - function renderSkillLoad(b) { - const name = String(b.name || '').trim() || 'Skill'; - const path = String(b.path || '').trim(); - const snapshot = String(b.snapshot || ''); - const target = path ? shortPath(path) : ''; - const size = resultSummary(snapshot); - const source = path - ? `
${esc(L('conv.skillSource'))}${esc(path)}
` - : ''; - const disclosure = snapshot - ? `
${esc(L('conv.skillSnapshot'))}${size ? `${esc(size)}` : ''}
${source}${codeBlock(snapshot, 'markdown')}
` - : `
${esc(L('conv.skillNoSnapshot'))}
`; - return `
🧩${esc(L('conv.skillLoaded'))}${esc(name)}${target ? `${esc(target)}` : ''}
${disclosure}
`; - } - function turnMeta(m) { - const bits = []; - if (m.modelActual) bits.push(esc(m.modelActual)); - if (m.usage) { - const tokenTotal = (m.usage.inputTokens || 0) + (m.usage.outputTokens || 0) - + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); - // A credit-bearing, all-zero Qoder usage object means token accounting was not recorded. - // Do not turn that absence into a misleading "0↑ 0↓" badge. - if (m.usage.credits == null || tokenTotal > 0) { - bits.push(`${fmtTok(m.usage.inputTokens)}↑ ${fmtTok(m.usage.outputTokens)}↓`); - } - if (m.usage.credits != null) bits.push(`${fmtCredits(m.usage.credits)} ${esc(L('conv.credits'))}`); - } - if (m.usage && m.usage.cacheRead) bits.push(`${fmtTok(m.usage.cacheRead)} ${esc(L('conv.cache'))}`); - if (m.stopReason && m.stopReason !== 'end_turn' && m.stopReason !== 'tool_use') bits.push(esc(m.stopReason)); - return bits.length ? `
${bits.map((b) => `${b}`).join('')}
` : ''; - } - - function toolResultText(b) { - const c = b && b.content; - if (typeof c === 'string') return c; - // image blocks render separately (renderToolCard) — stringifying them would dump base64 - if (Array.isArray(c)) return c.filter((x) => !(x && x.type === 'image')).map((x) => (x && x.type === 'text' ? x.text : (x && x.text) || JSON.stringify(x))).join('\n'); - return c == null ? '' : JSON.stringify(c); - } - function diff(oldS, newS) { - const o = String(oldS || '').split('\n'); - const n = String(newS || '').split('\n'); - return '
' + o.map((l) => `
- ${esc(l)}
`).join('') + n.map((l) => `
+ ${esc(l)}
`).join('') + '
'; - } - function todos(list) { - return '
' + (list || []).map((t) => { - const m = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; - return `
${m}${esc(t.content || t.activeForm || '')}
`; - }).join('') + '
'; - } - // File extension → highlight.js language id (so code blocks get language-specific highlighting). - const EXT_LANG = { - js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', - py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', scala: 'scala', swift: 'swift', - c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', cs: 'csharp', m: 'objectivec', mm: 'objectivec', - php: 'php', pl: 'perl', lua: 'lua', r: 'r', dart: 'dart', ex: 'elixir', exs: 'elixir', erl: 'erlang', clj: 'clojure', - sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell', - json: 'json', jsonc: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini', ini: 'ini', conf: 'ini', env: 'ini', - html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', xhtml: 'xml', - css: 'css', scss: 'scss', sass: 'scss', less: 'less', styl: 'stylus', - md: 'markdown', markdown: 'markdown', sql: 'sql', graphql: 'graphql', gql: 'graphql', proto: 'protobuf', - tf: 'terraform', tsv: 'plaintext', csv: 'plaintext', - }; - function langFromPath(p) { - if (!p) return ''; - const base = String(p).split(/[\\/]/).pop().toLowerCase(); - if (base === 'dockerfile') return 'dockerfile'; - if (base === 'makefile' || base === 'gnumakefile') return 'makefile'; - const dot = base.lastIndexOf('.'); - return (dot >= 0 ? EXT_LANG[base.slice(dot + 1)] : '') || ''; - } - // Strip `cat -n` prefixes ("␠␠␠12\t…", as Claude Code's Read returns) so we render our own gutter. - function stripCatN(text) { - return /^\s*\d+\t/.test(text) ? text.replace(/^\s*\d+\t/gm, '') : text; - } - // A styled code block. lang='' → plain (no syntax highlight, no gutter). highlight()+gutter are - // applied after insertion (see highlight()). Shared by tool cards and (indirectly) the renderer. - function codeBlock(text, lang) { - const cls = lang ? 'language-' + esc(lang) : 'nohljs'; - return `
${esc(text)}
`; - } - function codePre(text, lang) { return codeBlock(truncate(text, 12000), lang || ''); } - // Markdown file: rendered preview (default) ↔ highlighted source, toggled by tabs. marked renders the - // preview; highlight() lights up code blocks inside both panes (source is highlighted even while hidden). - function mdDoc(text) { - return '
' - + `
` - + `
${md(text)}
` - + `` - + '
'; - } - const isMdPath = (p) => langFromPath(p) === 'markdown'; - - function shortPath(p) { if (!p) return ''; const s = String(p).split('/'); return s.length > 3 ? '…/' + s.slice(-2).join('/') : p; } - function resultSummary(txt) { const b = txt ? txt.length : 0; if (!b) return ''; return b < 1024 ? b + ' B' : (b / 1024).toFixed(1) + ' KB'; } - const PRE = 'pre bg-[#0c0e12] border border-white/7 rounded-[7px] p-2.5 overflow-x-auto font-mono text-[11px] leading-[1.48] text-[#e8edf4] whitespace-pre-wrap break-all'; - const TOOL_CLS = { Bash: 'exec', Script: 'exec', Read: 'read', Edit: 'write', MultiEdit: 'write', Write: 'write', ApplyPatch: 'write', Grep: 'search', Glob: 'search', TodoWrite: 'todo', Task: 'task', WebSearch: 'net', WebFetch: 'net' }; - // Codex apply_patch envelope: "*** Update File: x" headers → the card's target (file, or "N files"). - function patchTarget(patch) { - const files = []; - String(patch || '').split('\n').forEach((l) => { - const m = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)$/.exec(l.trim()); - if (m) files.push(m[1].trim()); - }); - if (!files.length) return ''; - return files.length === 1 ? shortPath(files[0]) : L('conv.patchFiles', { n: files.length }); - } - function renderToolCard(tu, resBlock) { - const name = tu.name || 'tool'; - const input = (tu.input && typeof tu.input === 'object') ? tu.input : {}; - const cls = /^mcp__/.test(name) ? 'mcp' : (TOOL_CLS[name] || 'default'); - let icon = '🔧', label = name, target = '', bodyInput = ''; - if (name === 'Bash') { icon = '⌘'; label = 'Bash'; target = input.description || ''; bodyInput = codeBlock(input.command || '', 'bash'); } - // Codex code-mode orchestration scripts (multi-call / write_stdin / custom JS) — the plain - // shell-run shape is already mapped to Bash by the backend (codex.rs map_exec_script). - else if (name === 'Script') { icon = '📜'; label = 'Script'; bodyInput = codeBlock(truncate(input.code || '', 12000), 'javascript'); } - else if (name === 'Read') { icon = '📖'; label = 'Read'; target = shortPath(input.file_path); } - else if (name === 'Edit') { icon = '✏️'; label = 'Edit'; target = shortPath(input.file_path); bodyInput = diff(input.old_string, input.new_string); } - else if (name === 'MultiEdit') { icon = '✏️'; label = 'MultiEdit'; target = shortPath(input.file_path); bodyInput = Array.isArray(input.edits) && input.edits.length ? input.edits.map((e) => diff(e.old_string, e.new_string)).join('') : `
${esc(L('conv.noEdits'))}
`; } - else if (name === 'Write') { icon = '📝'; label = 'Write'; target = shortPath(input.file_path); const c = truncate(input.content || '', 12000); bodyInput = isMdPath(input.file_path) ? mdDoc(c) : codeBlock(c, langFromPath(input.file_path)); } - else if (name === 'ApplyPatch') { icon = '✏️'; label = 'ApplyPatch'; target = patchTarget(input.patch); bodyInput = codeBlock(truncate(input.patch || '', 12000), 'diff'); } - else if (name === 'Grep') { icon = '🔎'; label = 'Grep'; target = input.pattern || ''; if (input.path) bodyInput = `
in ${esc(input.path)}
`; } - else if (name === 'Glob') { icon = '🔎'; label = 'Glob'; target = input.pattern || ''; } - else if (name === 'TodoWrite') { icon = '✅'; label = 'Todos'; bodyInput = todos(input.todos); } - else if (name === 'Task') { icon = '🤖'; label = 'Task'; target = '→ ' + (input.subagent_type || 'agent'); bodyInput = (input.description ? `
${esc(input.description)}
` : '') + (input.prompt ? `
${esc(truncate(input.prompt, 4000))}
` : ''); } - else if (name === 'WebSearch') { icon = '🌐'; label = 'WebSearch'; target = input.query || ''; } - else if (name === 'WebFetch') { icon = '🌐'; label = 'WebFetch'; target = input.url || ''; } - else if (/^mcp__/.test(name)) { icon = '🧩'; label = 'MCP · ' + name.replace(/^mcp__/, ''); bodyInput = Object.keys(input).length ? codeBlock(JSON.stringify(input, null, 2), 'json') : ''; } - else { bodyInput = Object.keys(input).length ? codeBlock(JSON.stringify(input, null, 2), 'json') : ''; } - - let resHtml; - if (resBlock) { - const isErr = !!resBlock.is_error; - const txt = toolResultText(resBlock); - const size = resultSummary(txt); - // Read shows the file's content → highlight by extension (+ our own gutter, stripping cat -n); - // other results stay plain text. An empty text renders nothing (no bare empty code box). - const resBody = !txt ? '' : name === 'Read' - ? (isMdPath(input.file_path) - ? mdDoc(stripCatN(truncate(txt, 8000))) - : codeBlock(stripCatN(truncate(txt, 8000)), langFromPath(input.file_path))) - : name === 'Bash' - ? codeBlock(truncate(txt, 8000), 'bash') - : codeBlock(truncate(txt, 8000), ''); - // Screenshot-carrying results (codex code-mode / grok): image blocks render as images. - const resImgs = Array.isArray(resBlock.content) - ? resBlock.content - .filter((x) => x && x.type === 'image' && x.source && x.source.data) - .map((x) => ``) - .join('') - : ''; - resHtml = `
${isErr ? '✗ ' + esc(L('conv.errResult')) : '✓ ' + esc(L('conv.result'))}${size ? `${esc(size)}` : ''}
${resBody}${resImgs}
`; - } else { - resHtml = `
— ${esc(L('conv.noResult'))}
`; - } - // If this call spawned a subagent (Task / Agent / Workflow / …, matched by tool_use id), nest its - // transcript right under the call so it's read in the context that produced it. See inlineSubagentBlock. - const subHtml = inlineSubagentBlock(tu.id); - return `
${icon}${esc(label)}${target ? `${esc(target)}` : ''}
${bodyInput ? `
${bodyInput}
` : ''}${resHtml}${subHtml}
`; - } - - // ---------- inline subagents (expand-at-call-site) ---------- - // Display name of a subagent: its agent type, suffixed with the skill that invoked it - // (`type:skill`) when the backend attributed one (Skill tool_use / transcript sentinel). - function subName(s) { return (s.type || 'agent') + (s.skill ? ':' + s.skill : ''); } - function subUsageSummary(s) { - const totals = (s && s.totals) || {}; - const bits = []; - if (totals.tokenUsageAvailable !== false) bits.push(`${fmtTok(totals.out || 0)}↓`); - if (totals.credits != null) bits.push(`${fmtCredits(totals.credits)} ${L('conv.credits')}`); - return bits.join(' · ') || '—'; - } - // A subagent dialogue is keyed by the tool_use id that spawned it (history.readSubagents). We render it - // as a lazily-filled disclosure directly under that call — at any nesting depth, since a subagent's own - // tool cards run through this same path. Body stays empty until opened (see fillSubBody) to bound the DOM. - function inlineSubagentBlock(id) { - const subs = (currentDetail && currentDetail.subagents) || {}; - const s = id && subs[id]; - if (!s) return ''; - const cnt = s.count != null ? s.count : ((s.messages || []).length); - const meta = `${esc(L('conv.subagentMsgs', { n: cnt }))} · ${esc(subUsageSummary(s))}`; - const desc = s.description ? ` · ${esc(s.description)}` : ''; - return `
🤖 ${esc(L('conv.subagent'))} · ${esc(subName(s))}${desc}${meta}
`; - } - // Render one subagent's whole thread (recursively wiring its own inline subagents via renderMessage → - // renderToolCard). idx=null so nested turns carry no data-mi (they're outside main-window navigation). - function renderSubThread(key) { - const s = currentDetail && currentDetail.subagents && currentDetail.subagents[key]; - if (!s) return ''; - const msgs = s.messages || []; - if (!msgs.length) return `
${esc(L('conv.emptyConv'))}
`; - const results = buildResults(msgs); - return msgs.map((m) => renderMessage(m, results, null, true)).join('') || `
${esc(L('conv.emptyConv'))}
`; - } - // Fill a subagent disclosure's body on first open (no-op afterwards). Returns the body element. - function fillSubBody(det) { - const body = det && det.querySelector(':scope > [data-sub-body]'); - if (!body) return null; - if (!body.dataset.filled) { body.innerHTML = renderSubThread(body.getAttribute('data-sub-body')); body.dataset.filled = '1'; highlight(body); } - return body; - } - - /* ---------- session tabs (top of the main panel) ---------- */ - // When a conversation spawned subagents, the panel header shows peer tabs: [主会话] [子代理 (N) ▾]. - // 主会话 and each subagent are equals — picking one moves the WHOLE panel to that session. - let agentMenuOpen = false; - function renderAgentTabs(detail) { - const host = $('convAgentTabs'); - if (!host) return; - const subs = (detail && detail.subagents) || {}; - const keys = Object.keys(subs); - if (!keys.length) { host.innerHTML = ''; host.classList.add('hidden'); host.classList.remove('flex'); agentMenuOpen = false; return; } - host.classList.remove('hidden'); host.classList.add('flex'); - const mainActive = activeAgent === 'main'; - const activeSub = !mainActive && subs[activeAgent] ? subs[activeAgent] : null; - const seg = (active) => `inline-flex items-center gap-1.5 h-[28px] px-3 rounded-[8px] text-[12px] font-semibold cursor-pointer border transition-colors whitespace-nowrap ${active ? 'bg-brand-soft text-brand border-brand/25' : 'bg-bg-elev text-muted border-border-custom hover:text-fg hover:bg-chip-bg'}`; - const mainTab = ``; - const ddLabel = activeSub ? `🤖 ${esc(subName(activeSub))}` : `🤖 ${esc(L('conv.stat.subagents'))} (${keys.length})`; - const items = keys.map((k) => { - const s = subs[k] || {}; - const cnt = s.count != null ? s.count : ((s.messages || []).length); - const active = activeAgent === k; - const desc = s.description ? `
${esc(s.description)}
` : ''; - return ``; - }).join(''); - const menu = `
${items}
`; - const dd = `
${menu}
`; - host.innerHTML = mainTab + dd; - } - // Move the panel to another thread (main ↔ subagent) KEEPING search state — used by cross-agent - // search navigation and the big-search auto-locate, where the jump that follows paints the window. - // Resets the window to "unpainted" so the follow-up jumpToMessage always renders fresh. - function setPanelAgent(key) { - if (!currentDetail || key === activeAgent) return; - agentMenuOpen = false; - activeAgent = key; - vStart = 0; vEnd = 0; - renderAgentTabs(currentDetail); - renderSidePanels(currentDetail); - } - // User-driven move of the main panel to a different session (main thread or a subagent). Resets - // the render window + search and repaints from the bottom, exactly like opening a fresh conversation. - function switchAgent(key) { - agentMenuOpen = false; - if (key === activeAgent) { renderAgentTabs(currentDetail); return; } - clearDetailSearchHighlights(); - const ds = $('convDetailSearch'); if (ds) ds.value = ''; - setPanelAgent(key); - const total = activeMessages().length; - vEnd = total; vStart = Math.max(0, total - DETAIL_WIN); - paintWindow(); - const host = $('convDetail'); if (host) host.scrollTop = host.scrollHeight; - } - - // Map every subagent to where it was spawned: callSite.get(subKey) = { thread, mi } where thread is - // 'main' or another subagent's key (nested spawns), and mi is the message index in that thread. Built - // lazily per open session and reset when the session changes. - let subIndex = null; - function buildSubIndex() { - const subs = (currentDetail && currentDetail.subagents) || {}; - const keys = new Set(Object.keys(subs)); - const callSite = new Map(); - const scan = (msgs, threadKey) => (msgs || []).forEach((m, i) => normContent(m.content).forEach((b) => { - if (b.type === 'tool_use' && keys.has(b.id) && !callSite.has(b.id)) callSite.set(b.id, { thread: threadKey, mi: i }); - })); - scan((currentDetail && currentDetail.messages) || [], 'main'); - for (const k of keys) scan(subs[k].messages, k); - subIndex = { callSite }; - } - // Ancestor chain from the outermost (spawned in main) down to `key`, e.g. [topSub, …, key]. Empty if - // the call site can't be resolved (e.g. a subagent whose meta recorded no toolUseId). - function subChain(key) { - if (!subIndex) buildSubIndex(); - const chain = []; const seen = new Set(); let cur = key; - while (cur && cur !== 'main' && !seen.has(cur)) { - seen.add(cur); chain.unshift(cur); - const cs = subIndex.callSite.get(cur); - if (!cs) return []; // broken link — can't place it in context - cur = cs.thread; - } - return chain; - } - // Bring a subagent into view AT ITS CALL SITE: jump the main thread to the outermost spawning turn, - // then expand each disclosure down the chain (filling lazily) and scroll/flash the target. Falls back - // to the standalone full-panel view when the call site is unknown, so orphan subagents stay reachable. - function focusSubagent(key) { - agentMenuOpen = false; - const menu = document.querySelector('#convAgentTabs .conv-agent-menu'); - if (menu) menu.classList.add('hidden'); // close the picker immediately as click feedback - if (!currentDetail || !(currentDetail.subagents || {})[key]) return; - const chain = subChain(key); - if (!chain.length) { switchAgent(key); return; } // call site unknown → standalone full-panel view - if (activeAgent !== 'main') setPanelAgent('main'); // search docs span all threads — no rebuild - const top = subIndex.callSite.get(chain[0]); // { thread:'main', mi } - jumpToMessage(top.mi, 'center'); - const host = $('convDetail'); - let det = null; - if (host) for (const k of chain) { - det = host.querySelector(`.subagent-inline[data-sub="${cssAttr(k)}"]`); - if (!det) break; - fillSubBody(det); det.open = true; // child level now exists in the DOM for the next iteration - } - renderAgentTabs(currentDetail); - if (!det) { switchAgent(key); return; } // couldn't place it inline → don't leave the click doing nothing - // Land on the spawning CALL (the tool card), not the middle of the now-tall subagent body, so the - // "why did this subagent appear" context reads top-down. Flash the whole block so it's unmistakable. - const anchor = det.closest('.tool-card') || det; - anchor.scrollIntoView({ block: 'start' }); - if (host) host.scrollTop = Math.max(0, host.scrollTop - 48); - det.classList.remove('sub-flash'); void det.offsetWidth; // restart the animation if re-focused - det.classList.add('sub-flash'); - setTimeout(() => det.classList.remove('sub-flash'), 2200); - } - // Escape a tool_use id for use inside a [data-sub="…"] attribute selector (ids may contain ':'). - function cssAttr(s) { return String(s).replace(/(["\\])/g, '\\$1'); } - - function renderSidePanels(detail) { - const m = detail.meta || {}; - // Invoking skill of the session in the panel: the active subagent's when one is selected, - // else the session's own (a standalone subagent transcript). Absent → row filtered out. - const panelSub = activeAgent !== 'main' && detail.subagents ? detail.subagents[activeAgent] : null; - const t = (panelSub && panelSub.totals) || m.totals || {}; - const messageCount = panelSub - ? (panelSub.count != null ? panelSub.count : (panelSub.messages || []).length) - : m.messages; - const skill = panelSub ? panelSub.skill : m.skill; - const rows = [ - [L('conv.stat.title'), m.title], - [L('conv.stat.model'), m.model], - [L('conv.stat.skill'), skill || null], - ...(m.isSubagent ? [[L('conv.stat.type'), L('conv.subagentSession')]] : []), - ...(m.imported ? [[L('conv.imported'), m.importedFrom || '✓']] : []), - [L('conv.stat.project'), m.cwd ? projName(m.cwd) : m.project], - [L('conv.stat.branch'), m.gitBranch], - [L('conv.stat.session'), m.sessionId ? String(m.sessionId).slice(0, 8) : null], - [L('conv.stat.rootSession'), m.rootSessionId && m.rootSessionId !== m.sessionId ? String(m.rootSessionId).slice(0, 8) : null], - [L('conv.stat.parentThread'), m.parentThreadId ? String(m.parentThreadId).slice(0, 8) : null], - [L('conv.stat.agent'), m.agentNickname], - [L('conv.stat.agentPath'), m.agentPath], - [L('conv.stat.messages'), messageCount], - [L('conv.stat.turns'), t.turns], - [L('conv.stat.input'), t.tokenUsageAvailable === false ? '—' : (t.in != null ? fmtTok(t.in) : null)], - [L('conv.stat.output'), t.tokenUsageAvailable === false ? '—' : (t.out != null ? fmtTok(t.out) : null)], - [L('conv.stat.credits'), t.credits != null ? fmtCredits(t.credits) : null], - [L('conv.stat.cacheRead'), t.cacheRead ? fmtTok(t.cacheRead) : null], - [L('conv.stat.tool'), m.assistant || 'Claude Code'], - [L('conv.stat.version'), m.version], - ].filter((r) => r[1] != null && r[1] !== ''); - $('convStats').innerHTML = rows.map((r) => `
${esc(r[0])}${esc(r[1])}
`).join(''); - - // TOC is built from the message DATA (global indices) so it spans the WHOLE thread even though only - // a window is rendered; clicking jumps the window to that message. Keyed on user turns — the natural - // navigation points — which also keeps the sidebar light on huge threads. - const messages = activeMessages(); // TOC follows the session shown in the main panel - const toc = []; - messages.forEach((m, i) => { - if (m.role !== 'user' || m._meta || m.meta) return; - const vis = normContent(m.content).filter((b) => b.type === 'text'); - const tv = vis.map((b) => stripInjected(b.text)).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); - if (!tv) return; - toc.push(`
👤 ${esc(tv.slice(0, 32) || '…')}
`); - }); - $('convToc').innerHTML = toc.join(''); - } - - /* ---------- export ---------- */ - function toast(msg, ok) { - let t = document.querySelector('.conv-toast'); - if (!t) { t = document.createElement('div'); t.className = 'conv-toast'; t.setAttribute('data-clarity-mask', 'true'); document.body.appendChild(t); } - t.textContent = msg; - t.classList.toggle('err', ok === false); - t.classList.add('show'); - clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 2200); - } - function hideExportMenu() { const m = $('convExportMenu'); if (m) m.classList.add('hidden'); } - - // Absolute .jsonl path for the session currently in the panel — the active subagent's file when - // one is selected, else the main session file. Used by the "copy path" button so a transcript can - // be handed to another Claude Code session for replay / agent debugging. - function currentJsonlPath() { - if (activeAgent !== 'main' && currentDetail && currentDetail.subagents) { - const s = currentDetail.subagents[activeAgent]; - if (s && s.file) return s.file; - } - return openFile; - } - - function doCopyPath() { - const p = currentJsonlPath(); - if (!p) return; - try { api.copy(p); } catch (_) {} - toast(L('conv.pathCopied')); - } - async function doReplay(btn) { - const p = currentJsonlPath(); - if (!p || !api.desktopReplay) return; - if (btn) btn.disabled = true; - toast(L('conv.replayOpening')); - let res; - const prompt = L('desktop.replayPrompt').slice(0, 13000); // q is truncated ~14k by Claude - try { res = await api.desktopReplay(p, prompt); } catch (e) { res = { ok: false, reason: 'failed' }; } - if (btn) btn.disabled = false; - if (res && res.ok) return; // Claude Desktop now opening with the file + prompt - const reason = res && res.reason; - toast( - reason === 'notInstalled' ? L('conv.replayNoApp') - : reason === 'unsupported' ? L('conv.replayUnsupported') - : reason === 'permission' ? L('conv.replayPermission') - : reason === 'cancelled' ? L('conv.replayOpening') - : L('conv.replayFail'), - false - ); - } - // Same shape as doReplay, but for the ChatGPT desktop app: the backend opens a - // codex://new deep link with the review prompt and the transcripts' directory as - // the workspace, so the task can read the JSONL files listed in the prompt. - async function doChatgpt(btn) { - const p = currentJsonlPath(); - if (!p || !api.chatgptReplay) return; - if (btn) btn.disabled = true; - toast(L('conv.chatgptOpening')); - let res; - const prompt = L('desktop.chatgptPrompt').slice(0, 13000); - try { res = await api.chatgptReplay(p, prompt); } catch (e) { res = { ok: false, reason: 'failed' }; } - if (btn) btn.disabled = false; - if (res && res.ok) return; // ChatGPT now opening with the prompt + workspace - const reason = res && res.reason; - toast( - reason === 'notInstalled' ? L('conv.chatgptNoApp') - : reason === 'unsupported' ? L('conv.replayUnsupported') - : L('conv.chatgptFail'), - false - ); - } - // Collapse the action buttons into a "⋯" menu when the toolbar is too narrow to fit them - // alongside a 200px-min search box. - function updateToolbarLayout() { - const tb = document.querySelector('.conv-detail-toolbar'); - const actions = $('convActions'); - const moreWrap = $('convMoreWrap'); - if (!tb || !actions || !moreWrap) return; - actions.classList.remove('hidden'); - moreWrap.classList.add('hidden'); - const exp = $('convExportMenu'); if (exp) exp.classList.add('hidden'); - if (tb.scrollWidth > tb.clientWidth + 1) { - actions.classList.add('hidden'); - moreWrap.classList.remove('hidden'); - } - } - - // HTML export is built MAIN-process side (src/main/exportHtml.js): it needs fs access to - // the on-disk subagent dialogues and emits a self-contained, Claude-styled viewer app. - - async function doExport(kind) { - hideExportMenu(); - if (!openFile) return; - try { - if (kind === 'jsonl') { - const r = await api.historyExportRaw(openFile); - if (r && r.canceled) return; - // A session with subagents comes back as a .zip bundle (r.bundled) — say so, so the .zip - // (rather than the expected .jsonl) isn't a surprise. - if (r && r.path) toast(L(r.bundled ? 'conv.exportOkZip' : 'conv.exportOk')); - else toast(L('conv.exportFail'), false); - } else if (kind === 'html') { - const r = await api.historyExportHtml(openFile); - if (r && r.canceled) return; - toast(r && r.path ? L('conv.exportOk') : L('conv.exportFail'), !!(r && r.path)); - } - } catch (_) { toast(L('conv.exportFail'), false); } - } - - /* ---------- import (file-picker button + drag-drop share this) ---------- */ - // r = { imported, skipped, failed } | { canceled } from history:import / history:importPaths. - // Toast a summary, jump to the imports dir on success, refresh the list. - async function applyImportResult(r) { - if (!r || r.canceled) return; - if (!r.imported) { - toast(r.skipped ? L('conv.importSkip', { n: r.skipped }) : L('conv.importNone'), r.failed ? false : undefined); - } else { - const parts = [L('conv.importDone', { n: r.imported })]; - if (r.skipped) parts.push(L('conv.importSkip', { n: r.skipped })); - if (r.failed) parts.push(L('conv.importFail', { n: r.failed })); - toast(parts.join(' · ')); - try { if (api.historySetActive) await api.historySetActive('__imported__'); } catch (_) {} - } - await refresh(); - } - - /* ---------- rename + tags (right-click customization) ---------- */ - // Persist a title/tags patch for one conversation, then refresh. Main also broadcasts - // history:changed (which refreshes too) — the explicit refresh just makes it feel instant. - async function applyMeta(file, patch) { - if (!file || !api.historySetMeta) return; - try { await api.historySetMeta(file, patch); } catch (_) {} - await refresh(); - } - function itemEl(id, file) { - return document.querySelector(`.conv-item[data-id="${cssAttr(id)}"]`) - || (file ? document.querySelector(`.conv-item[data-file="${cssAttr(file)}"]`) : null); - } - // Swap a node for a single-line text input; commit on Enter/blur, cancel on Escape. The `done` - // guard makes Enter-then-blur (or Esc-then-blur) run the callback exactly once. onCommit(value|null): - // null = cancelled, '' = emptied (callers treat empty as clear/no-op), else the trimmed value. - function inlineEdit(node, opts) { - const inp = document.createElement('input'); - inp.className = opts.cls; - inp.value = opts.value || ''; - if (opts.placeholder) inp.setAttribute('placeholder', opts.placeholder); - node.replaceWith(inp); - inp.focus(); inp.select(); - let done = false; - const finish = (save) => { if (done) return; done = true; opts.onCommit(save ? inp.value.trim() : null); }; - inp.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); finish(true); } - else if (e.key === 'Escape') { e.preventDefault(); finish(false); } - }); - inp.addEventListener('blur', () => finish(true)); - } - function startRename(file, id) { - const item = itemEl(id, file); if (!item) return; - const titleEl = item.querySelector('.conv-title'); if (!titleEl) return; - const s = findSession(id, file); - inlineEdit(titleEl, { - cls: 'conv-title-edit', value: (s && s.title) || '', placeholder: L('conv.renamePlaceholder'), - onCommit: (v) => { if (v == null) { renderList(); return; } applyMeta(file, { title: v }); }, // '' clears → auto title - }); - } - function startAddTag(file, id) { - const item = itemEl(id, file); if (!item) return; - let row = item.querySelector('.conv-item-tags'); - if (!row) { - row = document.createElement('div'); - row.className = 'conv-item-tags'; - row.dataset.file = file; - const sub = item.querySelector('.conv-item-sub'); - if (sub) sub.after(row); else item.appendChild(row); - } - const holder = document.createElement('span'); - row.appendChild(holder); - inlineEdit(holder, { - cls: 'conv-tag-edit', value: '', placeholder: L('conv.tagPlaceholder'), - onCommit: (v) => { - if (!v) { renderList(); return; } - const s = findSession(id, file); - applyMeta(file, { tags: ((s && s.tags) || []).concat([v]) }); - }, - }); - } - function startEditTag(file, oldTag, chip) { - if (!chip) return; - inlineEdit(chip, { - cls: 'conv-tag-edit', value: oldTag, placeholder: L('conv.tagPlaceholder'), - onCommit: (v) => { - if (v == null) { renderList(); return; } - const s = findSession(null, file); - const cur = (s && s.tags) || []; - const nextTags = v ? cur.map((t) => (t === oldTag ? v : t)) : cur.filter((t) => t !== oldTag); // empty = delete - if (tagFilter === oldTag) tagFilter = v || null; - applyMeta(file, { tags: nextTags }); - }, - }); - } - async function deleteTag(file, tag) { - const s = findSession(null, file); - await applyMeta(file, { tags: ((s && s.tags) || []).filter((t) => t !== tag) }); - } - - // Soft delete: confirm, flag __ccbud__.delete=true, then refresh (the session drops out of every - // normal view and reappears only in the recycle bin). Mirrors removeImport's open-session reset. - async function askConfirm(opts) { - if (window.confirmDialog) return window.confirmDialog(opts); - return Promise.resolve(window.confirm(opts.message || '')); - } - async function softDelete(file) { - if (!file) return; - const ok = await askConfirm({ title: L('conv.deleteTitle'), message: L('conv.deleteConfirm'), confirmText: L('conv.ctxDelete'), cancelText: L('modal.cancel'), danger: true }); - if (!ok) return; - if (file === openFile) { openId = null; openFile = null; syncConvNav(); } - await applyMeta(file, { delete: true }); - } - async function restoreSession(file) { - if (!file) return; - await applyMeta(file, { delete: false }); // drop the flag → back to its working dir - } - async function deleteForever(file) { - if (!file || !api.historyDeleteForever) return; - const ok = await askConfirm({ title: L('conv.deleteForeverTitle'), message: L('conv.deleteForeverConfirm'), confirmText: L('conv.deleteForever'), cancelText: L('modal.cancel'), danger: true }); - if (!ok) return; - let res; try { res = await api.historyDeleteForever(file); } catch (_) { res = null; } - if (!res || !res.ok) return; - if (file === openFile) { openId = null; openFile = null; syncConvNav(); } - await refresh(); - } - - // Right-click context menu on a conversation row: rename / add tag. A single body-level element, - // re-targeted per open (the list re-renders, so a list-child menu would be wiped out). - let ctxMenuEl = null; - function hideCtxMenu() { if (ctxMenuEl) ctxMenuEl.classList.add('hidden'); } - function showCtxMenu(x, y, file, id) { - if (!ctxMenuEl) { - ctxMenuEl = document.createElement('div'); - ctxMenuEl.className = 'conv-ctx-menu hidden'; - document.body.appendChild(ctxMenuEl); - ctxMenuEl.addEventListener('click', (e) => { - const it = e.target.closest('[data-ctx]'); if (!it) return; - const act = it.dataset.ctx, f = ctxMenuEl._file, i = ctxMenuEl._id; - hideCtxMenu(); - if (act === 'rename') startRename(f, i); - else if (act === 'addtag') startAddTag(f, i); - else if (act === 'delete') softDelete(f); - else if (act === 'restore') restoreSession(f); - else if (act === 'deleteforever') deleteForever(f); - }); - } - ctxMenuEl._file = file; ctxMenuEl._id = id; - // A live session of another CLI can be restored but never permanently deleted (the file - // belongs to that tool); imported copies live in our store and keep delete-forever. - const s = findSession(id, file); - const foreign = s && isForeignSource(s.source) && !s.imported; - // Recycle-bin rows offer restore / delete-forever; everywhere else it's rename / add-tag / delete. - ctxMenuEl.innerHTML = (activeDir === '__trash__') - ? `` + - (foreign ? '' : ``) - : `` + - `` + - ``; - ctxMenuEl.classList.remove('hidden'); - ctxMenuEl.style.left = Math.min(x, window.innerWidth - 180) + 'px'; - ctxMenuEl.style.top = Math.min(y, window.innerHeight - 80) + 'px'; - } - - /* ---------- events ---------- */ - function bind() { - const list = $('convList'); - if (list) list.addEventListener('click', async (e) => { - if (e.target.closest('[data-clear-tagfilter]')) { e.stopPropagation(); tagFilter = null; renderList(); return; } - const delTag = e.target.closest('[data-del-tag]'); - if (delTag) { e.stopPropagation(); await deleteTag(delTag.dataset.file, delTag.dataset.delTag); return; } - const tagChip = e.target.closest('.conv-tag'); - if (tagChip && tagChip.dataset.tag) { - e.stopPropagation(); - // Defer the filter toggle so a double-click (edit) can cancel it — otherwise the first click - // of the dblclick would re-render the list and destroy the chip before dblclick fires. - const tag = tagChip.dataset.tag; - clearTimeout(tagClickTimer); - tagClickTimer = setTimeout(() => { tagFilter = (tagFilter === tag) ? null : tag; renderList(); }, 220); - return; - } - const restoreBtn = e.target.closest('[data-restore]'); - if (restoreBtn) { e.stopPropagation(); await restoreSession(restoreBtn.dataset.restore); return; } - const delFvr = e.target.closest('[data-delete-forever]'); - if (delFvr) { e.stopPropagation(); await deleteForever(delFvr.dataset.deleteForever); return; } - const rm = e.target.closest('[data-remove-import]'); - if (rm) { - e.stopPropagation(); - const file = rm.dataset.removeImport; - if (!file || !api.historyRemoveImport) return; - let res; try { res = await api.historyRemoveImport(file); } catch (_) { res = null; } // confirms in main - if (!res || !res.ok) return; // cancelled or failed → leave the list as-is - if (file === openFile) { openId = null; openFile = null; syncConvNav(); } - await refresh(); - return; - } - const head = e.target.closest('.conv-proj-head'); - if (head) { - const key = head.dataset.proj; - if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key); - persistCollapsed(); - renderList(); - return; - } - const item = e.target.closest('.conv-item'); - if (item) { - // Opening from a content hit carries the query along, so the conversation lands right on - // the match — switching to the matching subagent first when that's where it lives. - const hit = (search && contentHits) ? contentHits.get(item.dataset.file) : null; - pendingLocate = hit ? { query: search, agent: hit.agent || 'main' } : null; - openConversation(item.dataset.id, item.dataset.file); - } - }); - // Right-click a conversation → rename / add-tag menu. - if (list) list.addEventListener('contextmenu', (e) => { - const item = e.target.closest('.conv-item'); - if (!item) return; - e.preventDefault(); - showCtxMenu(e.clientX, e.clientY, item.dataset.file, item.dataset.id); - }); - // Double-click a tag chip → edit it in place. - if (list) list.addEventListener('dblclick', (e) => { - const label = e.target.closest('.conv-tag-label'); - if (!label) return; - e.preventDefault(); e.stopPropagation(); - clearTimeout(tagClickTimer); // cancel the pending single-click filter toggle - const chip = label.closest('.conv-tag'); - if (chip && chip.dataset.tag) startEditTag(chip.dataset.file, chip.dataset.tag, chip); - }); - if (list) list.addEventListener('scroll', hideCtxMenu, true); - document.addEventListener('click', (e) => { if (!e.target.closest('.conv-ctx-menu')) hideCtxMenu(); }); - document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCtxMenu(); }); - const sb = $('convSearch'); - if (sb) sb.addEventListener('input', (e) => { search = e.target.value.trim(); scheduleContentSearch(); renderList(); }); - const clr = $('convClear'); - if (clr) clr.addEventListener('click', () => { const i = $('convSearch'); if (i) { i.value = ''; search = ''; scheduleContentSearch(); renderList(); i.focus(); } }); - const imp = $('convImportBtn'); - if (imp && api.historyImport) imp.addEventListener('click', async () => { - imp.disabled = true; - let r; try { r = await api.historyImport(); } catch (_) { r = null; } - imp.disabled = false; - await applyImportResult(r); - }); - const dirSwitch = $('convDirSwitch'); - if (dirSwitch) dirSwitch.addEventListener('click', async (e) => { - const btn = e.target.closest('[data-dir]'); - if (!btn) return; - try { if (api.historySetActive) await api.historySetActive(btn.dataset.dir); } catch (_) {} - await refresh(); - }); - const toc = $('convToc'); - if (toc) toc.addEventListener('click', (e) => { const it = e.target.closest('.toc-item'); if (it) jumpToMessage(+it.dataset.go, 'start'); }); - - // Session tabs: [主会话] [子代理 (N) ▾]. The dropdown lists subagents; picking one jumps the main - // thread to where it was spawned and expands it inline there (focusSubagent), so it reads in context. - // The 主会话 tab switches the whole panel back to the root thread. - const tabs = $('convAgentTabs'); - if (tabs) tabs.addEventListener('click', (e) => { - if (e.target.closest('[data-agent-dd]')) { - agentMenuOpen = !agentMenuOpen; - const menu = tabs.querySelector('.conv-agent-menu'); - if (menu) menu.classList.toggle('hidden', !agentMenuOpen); - return; - } - const it = e.target.closest('[data-agent]'); - if (it) { if (it.dataset.agent === 'main') switchAgent('main'); else focusSubagent(it.dataset.agent); } - }); - // Close the subagent menu when clicking outside the tab bar. - document.addEventListener('click', (e) => { - if (!agentMenuOpen) return; - if (e.target.closest('#convAgentTabs')) return; - agentMenuOpen = false; - const menu = document.querySelector('#convAgentTabs .conv-agent-menu'); - if (menu) menu.classList.add('hidden'); - }); - - // Drag-to-resize the left/right panels (middle absorbs the rest). Widths persist; collapse wins via CSS. - (function initConvResizers() { - const layout = document.querySelector('.conv-layout'); - const sidebar = document.querySelector('.conv-sidebar'); - const nav = document.querySelector('.conv-nav'); - if (!layout || !sidebar || !nav) return; - const MIN_LEFT = 200, MIN_RIGHT = 180, MIN_MAIN = 320; // MIN_MAIN keeps the middle usable, not a fixed width - const num = (v, d) => { const n = parseInt(v, 10); return isFinite(n) ? n : d; }; - let leftW = num(localStorage.getItem('ccbud-conv-leftw'), 248); - let rightW = num(localStorage.getItem('ccbud-conv-rightw'), 220); - const apply = () => { sidebar.style.setProperty('--conv-left-w', leftW + 'px'); nav.style.setProperty('--conv-right-w', rightW + 'px'); }; - apply(); - const startDrag = (side, handle, e) => { - e.preventDefault(); - const total = layout.getBoundingClientRect().width; - const startX = e.clientX, sL = leftW, sR = rightW; - layout.classList.add('resizing'); handle.classList.add('dragging'); - const onMove = (ev) => { - const dx = ev.clientX - startX; - if (side === 'left') leftW = Math.max(MIN_LEFT, Math.min(total - rightW - MIN_MAIN, sL + dx)); - else rightW = Math.max(MIN_RIGHT, Math.min(total - leftW - MIN_MAIN, sR - dx)); - apply(); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - layout.classList.remove('resizing'); handle.classList.remove('dragging'); - try { localStorage.setItem('ccbud-conv-leftw', String(leftW)); localStorage.setItem('ccbud-conv-rightw', String(rightW)); } catch (_) {} - }; - document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); - }; - layout.querySelectorAll('.conv-resizer').forEach((r) => r.addEventListener('mousedown', (e) => startDrag(r.dataset.resize, r, e))); - })(); - - // Collapse the conversation list sidebar / nav panel - const convSidebar = document.querySelector('.conv-sidebar'); - const I = window.ccbudIcons || {}; - // Left sidebar: ‹ when expanded (collapse leftward), › when collapsed (expand rightward). - const setChevron = (btn, isCol) => { - const icon = btn && btn.querySelector('[data-icon]'); - if (icon) icon.innerHTML = isCol ? (I.chevronRight || '›') : (I.chevronLeft || '‹'); - }; - // Right nav is the mirror image: › when expanded (collapse rightward), ‹ when collapsed. - const setChevronNav = (btn, isCol) => { - const icon = btn && btn.querySelector('[data-icon]'); - if (icon) icon.innerHTML = isCol ? (I.chevronLeft || '‹') : (I.chevronRight || '›'); - }; - - const collapseListBtn = $('btnCollapseConvList'); - if (collapseListBtn && convSidebar) { - try { if (localStorage.getItem('ccbud-convlist-collapsed') === '1') { convSidebar.classList.add('collapsed'); setChevron(collapseListBtn, true); } } catch (_) {} - collapseListBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const isCol = convSidebar.classList.toggle('collapsed'); - setChevron(collapseListBtn, isCol); - try { localStorage.setItem('ccbud-convlist-collapsed', isCol ? '1' : '0'); } catch (_) {} - }); - } - - syncConvNav(); // nothing selected at startup → no right rail - const convNav = document.querySelector('.conv-nav'); - const collapseNavBtn = $('btnCollapseConvNav'); - if (collapseNavBtn && convNav) { - setChevronNav(collapseNavBtn, false); // default expanded → › (collapse rightward) - try { if (localStorage.getItem('ccbud-convnav-collapsed') === '1') { convNav.classList.add('collapsed'); setChevronNav(collapseNavBtn, true); } } catch (_) {} - collapseNavBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const isCol = convNav.classList.toggle('collapsed'); - setChevronNav(collapseNavBtn, isCol); - try { localStorage.setItem('ccbud-convnav-collapsed', isCol ? '1' : '0'); } catch (_) {} - }); - } - - // Detail message search. Typing searches (and highlights) without pulling the view to another - // agent; Enter CONFIRMS — it jumps straight to the first match, switching to its thread if - // needed — and further Enter presses step next/previous (Shift). ↑/↓ step across agents too. - const dsearch = $('convDetailSearch'); - if (dsearch) { - let t; - dsearch.addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => performDetailSearch(dsearch.value.trim()), 200); }); - dsearch.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const q = dsearch.value.trim(); - clearTimeout(t); - if (q !== searchQuery) performDetailSearch(q, { first: true }); - else if (searchOcc.length) gotoDetailSearchMatch(searchIndex < 0 ? 0 : searchIndex + (e.shiftKey ? -1 : 1)); - } - if (e.key === 'Escape') { dsearch.value = ''; clearDetailSearchHighlights(); } - }); - } - const dprev = $('convDetailSearchPrev'); - if (dprev) dprev.addEventListener('click', () => { if (searchOcc.length) gotoDetailSearchMatch(searchIndex < 0 ? -1 : searchIndex - 1); }); - const dnext = $('convDetailSearchNext'); - if (dnext) dnext.addEventListener('click', () => { if (searchOcc.length) gotoDetailSearchMatch(searchIndex < 0 ? 0 : searchIndex + 1); }); - const dclear = $('convDetailSearchClear'); - if (dclear) dclear.addEventListener('click', () => { const inp = $('convDetailSearch'); if (inp) inp.value = ''; clearDetailSearchHighlights(); }); - - // Load-earlier / load-later (delegated; #convDetail is stable, its innerHTML isn't). - const detailHost = $('convDetail'); - if (detailHost) detailHost.addEventListener('click', (e) => { - // Markdown preview ↔ source toggle (Read/Write of .md files). - const mdTab = e.target.closest('.md-tab'); - if (mdTab) { - const doc = mdTab.closest('.md-doc'); - if (doc) { - const which = mdTab.dataset.mdTab; - doc.querySelectorAll('.md-tab').forEach((t) => t.classList.toggle('active', t === mdTab)); - const prev = doc.querySelector('.md-preview'); if (prev) prev.classList.toggle('hidden', which !== 'preview'); - const src = doc.querySelector('.md-source'); if (src) src.classList.toggle('hidden', which !== 'source'); - } - return; - } - if (e.target.closest('[data-load-earlier]')) { loadEarlier(); return; } - if (e.target.closest('[data-load-later]')) { loadLater(); return; } - // Lazily render an inline subagent transcript the first time its disclosure is opened (its - // children render the same way, so the tree fills one level per click — never all at once). - const sum = e.target.closest('.subagent-inline > summary'); - if (sum) fillSubBody(sum.parentElement); - }); - - // Action buttons (inline) + their collapsed "⋯" menu equivalents. - const copyPathBtn = $('convCopyPathBtn'); - if (copyPathBtn) copyPathBtn.addEventListener('click', doCopyPath); - const replayBtn = $('convReplayBtn'); - if (replayBtn) replayBtn.addEventListener('click', () => doReplay(replayBtn)); - const chatgptBtn = $('convChatgptBtn'); - if (chatgptBtn) chatgptBtn.addEventListener('click', () => doChatgpt(chatgptBtn)); - - const moreBtn = $('convMoreBtn'); - const moreMenu = $('convMoreMenu'); - if (moreBtn) moreBtn.addEventListener('click', (e) => { e.stopPropagation(); if (moreBtn.disabled) return; if (moreMenu) moreMenu.classList.toggle('hidden'); }); - if (moreMenu) moreMenu.addEventListener('click', (e) => { - const it = e.target.closest('[data-more]'); if (!it) return; - moreMenu.classList.add('hidden'); - const a = it.dataset.more; - if (a === 'replay') doReplay(); - else if (a === 'chatgpt') doChatgpt(); - else if (a === 'copyPath') doCopyPath(); - else if (a === 'jsonl') doExport('jsonl'); - else if (a === 'html') doExport('html'); - }); - document.addEventListener('click', (e) => { if (moreMenu && !e.target.closest('.conv-more-wrap')) moreMenu.classList.add('hidden'); }); - - // Responsive toolbar: collapse the action buttons into the "⋯" menu when space is tight. - const toolbar = document.querySelector('.conv-detail-toolbar'); - if (toolbar && window.ResizeObserver) { - const ro = new ResizeObserver(() => updateToolbarLayout()); - ro.observe(toolbar); - } - updateToolbarLayout(); - - // Export menu (JSONL / HTML) - const exportBtn = $('convExportBtn'); - if (exportBtn) exportBtn.addEventListener('click', (e) => { e.stopPropagation(); if (exportBtn.disabled) return; const m = $('convExportMenu'); if (m) m.classList.toggle('hidden'); }); - const exportMenu = $('convExportMenu'); - if (exportMenu) exportMenu.addEventListener('click', (e) => { const it = e.target.closest('[data-export]'); if (it) doExport(it.dataset.export); }); - document.addEventListener('click', (e) => { if (!e.target.closest('.conv-export-wrap')) hideExportMenu(); }); - - // Live follow: ~/.claude/projects changed → refresh list, re-render open session if touched. - // rerenderDetail rebuilds the WHOLE thread, so during an active Claude Code session (the file - // is rewritten on every streamed turn) we debounce it — bursts of writes coalesce into one - // rebuild instead of one-per-write, which was the main "under load" jank (traced). - let detailTimer; - // True when a changed file belongs to the OPEN session — its own .jsonl, or one of its - // subagent files (/subagents/agent-*.jsonl) — so nested subagents live-follow too. - const touchesOpenSession = (files) => { - if (!openFile || !files) return false; - const base = openFile.replace(/\.jsonl$/i, ''); - return files.some((f) => f === openFile || f.indexOf(base + '/subagents/') === 0 || f.indexOf(base + '\\subagents\\') === 0); - }; - if (api.onHistoryChanged) api.onHistoryChanged((p) => { - clearTimeout(listTimer); - listTimer = setTimeout(refresh, 200); - if (p && p.files && touchesOpenSession(p.files)) { - clearTimeout(detailTimer); - detailTimer = setTimeout(() => rerenderDetail(false), 300); - } - }); - - // Drag a .jsonl transcript or a .zip conversation bundle (main session + subagents) anywhere onto - // the window → import it directly, same pipeline as the import button. preventDefault on - // dragover/drop is REQUIRED — otherwise Electron navigates to the dropped file:// URL. Other files - // are ignored (import validates each is a real transcript/bundle before copying it in). - const dragHasFiles = (e) => { try { return Array.from((e.dataTransfer && e.dataTransfer.types) || []).indexOf('Files') >= 0; } catch (_) { return false; } }; - let dropOverlay = null, dropDepth = 0; - const showDropOverlay = () => { - if (!dropOverlay) { - dropOverlay = document.createElement('div'); - dropOverlay.className = 'conv-drop-overlay'; - dropOverlay.innerHTML = '
' + (ICN.download || '') + '
'; - document.body.appendChild(dropOverlay); - } - dropOverlay.querySelector('span').textContent = L('conv.dropHint'); - dropOverlay.classList.add('show'); - }; - const hideDropOverlay = () => { dropDepth = 0; if (dropOverlay) dropOverlay.classList.remove('show'); }; - document.addEventListener('dragenter', (e) => { if (!dragHasFiles(e)) return; e.preventDefault(); dropDepth++; showDropOverlay(); }); - document.addEventListener('dragover', (e) => { if (!dragHasFiles(e)) return; e.preventDefault(); try { e.dataTransfer.dropEffect = 'copy'; } catch (_) {} }); - document.addEventListener('dragleave', (e) => { if (!dragHasFiles(e)) return; dropDepth = Math.max(0, dropDepth - 1); if (!dropDepth) hideDropOverlay(); }); - document.addEventListener('drop', async (e) => { - if (!dragHasFiles(e)) return; - e.preventDefault(); - hideDropOverlay(); - const files = Array.prototype.slice.call(e.dataTransfer.files || []); - const paths = files.map((f) => { try { return api.pathForFile ? api.pathForFile(f) : (f.path || ''); } catch (_) { return ''; } }).filter(Boolean); - const importable = paths.filter((p) => /\.(jsonl|zip)$/i.test(p)); - if (!importable.length) { toast(L('conv.dropNotJsonl'), false); return; } - if (!api.historyImportPaths) return; - let r; try { r = await api.historyImportPaths(importable); } catch (_) { r = null; } - await applyImportResult(r); - }); - - // Unified safety-net: live sessions still refresh when a file-watch event is missed, while a - // failed read retries on its own schedule (steady probe for permission errors, backoff for - // the rest). rerenderDetail coalesces ticks while a helper-backed read is already in flight. - setInterval(() => { - if (!openFile) return; - const retryDue = detailRetry && detailRetry.file === openFile && Date.now() >= detailRetry.nextAt; - if (retryDue || openSessionLive()) rerenderDetail(false); - }, 4000); - } - - window.ccbudConversations = { - onShow() { refresh(); if (openFile) rerenderDetail(false); }, - // Re-render everything this view owns when the UI language changes. - setLang() { renderDirSwitch(); renderList(); if (openFile) rerenderDetail(true); }, - }; - - bind(); -})(); diff --git a/src/renderer/css/01.css b/src/renderer/css/01.css new file mode 100644 index 0000000..fc5f6e4 --- /dev/null +++ b/src/renderer/css/01.css @@ -0,0 +1,137 @@ +@import "tailwindcss"; + +/* Make the `dark:` utility variant follow the app's data-theme toggle (not the OS setting), + so the popover body bg and the request-inspector code blocks track the in-app theme. */ +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + +@theme { + --color-brand: var(--brand); + --color-brand-soft: var(--brand-soft); + --color-brand-2: var(--brand-2); + --color-brand-3: var(--brand-3); + --color-bg-app: var(--bg-app); + --color-bg-sidebar: var(--bg-sidebar); + --color-bg-elev: var(--bg-elev); + --color-bg-input: var(--bg-input); + --color-fg: var(--fg); + --color-muted: var(--muted); + --color-caption: var(--caption); + --color-border-custom: var(--border); + --color-border-strong: var(--border-strong); + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-soft: var(--primary-soft); + --color-green: var(--green); + --color-green-soft: var(--green-soft); + --color-red: var(--red); + --color-red-soft: var(--red-soft); + --color-orange: var(--orange); + --color-amber: var(--amber); + --color-amber-soft: var(--amber-soft); + --color-chip-bg: var(--chip-bg); + --shadow-card: var(--card-shadow); + --shadow-card-hover: var(--card-shadow-hover); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-sm: var(--radius-sm); +} + +/* ===== Restored original hand-written design system (pre-migration, the good one) ===== */ +/* ccbud — macOS 27 design system */ + +:root { + /* Claude design language — warm paper + terracotta (mirrors export-assets/skin.css, + the skin used by exported transcripts). Accents are terracotta, not Apple blue/purple. */ + --brand: #cc785c; + --brand-2: #d97757; + --brand-3: #bd5d3a; + --brand-soft: rgba(204, 120, 92, 0.12); + --brand-glow: rgba(204, 120, 92, 0.30); + --brand-text: #a44a2c; + + /* Warm "Ivory" paper canvas; white cards lift off it like the exported transcript. */ + --bg-app: rgba(250, 249, 245, 0.94); + --bg-sidebar: rgba(240, 238, 230, 0.80); + --bg-elev: #ffffff; + --bg-input: #ffffff; + --card-bg: #ffffff; + --card-shadow: 0 1px 2px rgba(40, 37, 30, .06), 0 4px 16px rgba(40, 37, 30, .08); + --card-shadow-hover: 0 3px 8px rgba(40, 37, 30, .10), 0 16px 36px rgba(40, 37, 30, .14); + + --fg: #29261f; /* warm charcoal (Claude --text) */ + --muted: #6e6a5f; /* warm dim text (Claude --text-dim); passes WCAG AA on #faf9f5 */ + --caption: #857f72; /* warm faint text; deepened from Claude --text-faint to keep AA */ + --border: rgba(41, 38, 31, 0.14); /* warm border — visible on white cards over paper */ + --border-strong: rgba(41, 38, 31, 0.2); + --window-border: rgba(0, 0, 0, 0.12); + + --primary: var(--brand-3); + --primary-hover: #a44a2c; + --primary-soft: rgba(189, 93, 58, 0.10); + + /* Vivid system colors stay for tiny FILLS/dots; text & borders use the calmer + `-text`/`-border` tokens below so light surfaces don't glare (Tailwind skin pattern: + vivid fill = ~500, text = ~700, border = ~200). */ + --green: #5b7f3f; /* Claude olive (skin.css) — replaces Apple #34C759 */ + --green-text: #3f5a2c; /* deep olive — WCAG AA on green-soft + paper */ + --green-soft: rgba(91, 127, 63, 0.12); + --green-border: rgba(91, 127, 63, 0.30); + --red: #b24632; /* Claude brick red (skin.css) — replaces Apple #FF3B30 */ + --red-text: #a23b29; + --red-soft: rgba(178, 70, 50, 0.10); + --red-border: rgba(178, 70, 50, 0.22); + --orange: #FF9500; + --amber: #FF9F0A; + --amber-text: #b3760a; + --amber-soft: rgba(255, 159, 10, 0.12); + + --chip-bg: rgba(41, 38, 31, 0.05); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + + --sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "PingFang SC", sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + + --ease: cubic-bezier(0.23, 1, 0.32, 1); +} + +[data-theme="dark"] { + /* OPT: deeper graphite canvas + cards lifted a step with a top "lit" highlight */ + --bg-app: rgba(18, 20, 27, 0.95); + --bg-sidebar: rgba(12, 13, 18, 0.72); + --bg-elev: #22242e; + --bg-input: #16181f; + --card-bg: #22242e; + --card-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 1px 2px rgba(0,0,0,.32), 0 6px 18px rgba(0,0,0,.34), 0 16px 38px rgba(0,0,0,.30); + --card-shadow-hover: inset 0 1px 0 rgba(255,255,255,.08), 0 2px 8px rgba(0,0,0,.4), 0 22px 50px rgba(0,0,0,.55); + + --fg: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.60); /* a11y: more contrast on dark cards/sidebar */ + --caption: rgba(255, 255, 255, 0.55); /* a11y: was 0.38, too dim */ + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.14); + --window-border: rgba(255, 255, 255, 0.09); + + --brand-soft: rgba(99, 102, 241, 0.20); + --brand-text: #5856D6; + --primary: #4F9FFF; + --primary-hover: #6BB0FF; + --primary-soft: rgba(79, 159, 255, 0.14); + + /* On dark, the vivid hues already read well — keep `-text`/`-border` = the live color. */ + --green: #32D74B; + --green-text: #32D74B; + --green-soft: rgba(50, 215, 75, 0.14); + --green-border: rgba(48, 209, 88, 0.38); + --red: #FF6961; + --red-text: #FF6961; + --red-soft: rgba(255, 105, 97, 0.14); + --green-text: #54c47e; /* a11y: bright enough for dark soft-green backgrounds */ + --red-text: #ff8a80; + --red-border: rgba(255, 69, 58, 0.20); + --amber-text: #FF9F0A; + --amber-soft: rgba(255, 179, 64, 0.14); + + --chip-bg: rgba(255, 255, 255, 0.07); +} diff --git a/src/renderer/css/02.css b/src/renderer/css/02.css new file mode 100644 index 0000000..a64155f --- /dev/null +++ b/src/renderer/css/02.css @@ -0,0 +1,111 @@ +/* NOTE: do NOT add `margin:0`/`padding:0` here — this block is UNLAYERED and would beat + Tailwind's layered spacing utilities, flattening every utility-only element (this was the + real cause of the "everything cramped" look). Preflight (@layer base) already zeroes + margin/padding and is correctly overridden by utilities. */ +*, *::before, *::after { box-sizing: border-box; } + +body { + height: 100vh; + font-family: var(--sans); + font-size: 13px; + color: var(--fg); + background: transparent; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +.drag-region { -webkit-app-region: drag; } +.no-drag, .btn, .tool-btn, .nav-item, .fab, .switch, select, input, textarea, summary { -webkit-app-region: no-drag; } + +/* The app-wide `user-select: none` (chrome shouldn't be selectable) also blocked copying the + conversation transcript. Re-enable text selection for the message panels (main + request inspector). */ +.conv-detail, .conv-detail *, #reqDrawerBody, #reqDrawerBody * { -webkit-user-select: text; user-select: text; } +/* Gateway URL / export snippet are copyable content read-outs — keep them selectable so the user + can grab a fragment (the app-wide `user-select: none` would otherwise block it). */ +#endpoint, #exportBlock, .endpoint, .code-block { + -webkit-user-select: text; + user-select: text; +} + +/* ── Shell ── */ +.app-window { + display: flex; + height: 100vh; + background: var(--bg-app); + /* No CSS backdrop-filter here: --bg-app is ~94% opaque, so a 52px blur was nearly invisible yet + repainted the ENTIRE window every frame (the main resize/scroll jank). Native macOS vibrancy + ('under-window', set in main.js createWindow) supplies the subtle frosting that does show. */ + border: 1px solid var(--window-border); + border-radius: 14px; + overflow: hidden; + box-shadow: inset 0 0.5px 0 rgba(255,255,255,.12); +} + +/* ── Sidebar ── */ +.sidebar { + width: 196px; + display: flex; + flex-direction: column; + padding: 48px 10px 14px; + background: var(--bg-sidebar); + flex-shrink: 0; + position: relative; + overflow: hidden; /* clip labels mid-animation so they don't wrap while the sidebar is briefly narrow */ + transition: width 0.22s var(--ease); +} +/* The divider is a pseudo-element that starts BELOW the titlebar / traffic-light zone (rather than a + full-height border-right). When collapsed to 52px the border used to run right up against the macOS + traffic-light buttons, which looked jarring; starting it lower keeps the top clean. */ +.sidebar::after { + content: ''; + position: absolute; + top: 44px; + right: 0; + bottom: 0; + width: 1px; + background: var(--border); + pointer-events: none; +} + +.sidebar.collapsed { + width: 52px; + padding-left: 5px; + padding-right: 5px; +} + +/* Settings view must FILL the scroll viewport (not grow with content) so only the content column + scrolls while the sub-nav stays put. Overrides `.panel { flex: 0 0 auto }` (id beats class). */ +#view-settings { flex: 1 1 0%; min-height: 0; } + +/* Settings sub-nav (二级菜单) — a bounded left column, collapsible with auto-shrinking width */ +/* Settings view: left-align it (override .panel's `margin: 0 auto`) so the sub-nav sits flush after the + main sidebar instead of floating in the centre with a big empty gap on its left. */ +#view-settings { margin-left: 0; margin-right: auto; } + +.settings-subnav { + width: 148px; + align-self: stretch; /* divider runs the full height of the section */ + border-right: 1px solid var(--border); + padding-right: 16px; + margin-right: 2px; + min-height: 232px; + overflow: hidden; /* clip labels mid-animation instead of letting them wrap */ + transition: width 0.2s var(--ease); +} +/* Keep each item on one line — during the expand animation the sub-nav is briefly narrow, and without + this the labels wrap to two lines until it finishes. */ +.settings-subnav-item { white-space: nowrap; } +.settings-subnav.collapsed { + width: 44px; + padding-right: 8px; +} +.settings-subnav.collapsed .settings-subnav-label { display: none; } +.settings-subnav.collapsed .settings-subnav-item { + justify-content: center; + padding-left: 0; + padding-right: 0; + gap: 0; +} diff --git a/src/renderer/css/03.css b/src/renderer/css/03.css new file mode 100644 index 0000000..3b7ac22 --- /dev/null +++ b/src/renderer/css/03.css @@ -0,0 +1,117 @@ +.sidebar-brand { + display: flex; + align-items: center; + gap: 9px; + padding: 0 6px 20px; +} + +.logo { + width: 30px; + height: 30px; + flex-shrink: 0; + filter: drop-shadow(0 2px 8px var(--brand-glow)); +} + +.brand-title { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.15; + white-space: nowrap; + transition: color 0.2s; +} + +.brand-title.running { color: var(--green-text); } + +.brand-sub { + font-size: 11.5px; + color: #59616f; /* a11y: --caption was too light over the sidebar (failed WCAG AA contrast) */ + letter-spacing: -0.01em; + margin-top: 1px; + white-space: nowrap; +} +/* a11y: sidebar text needs more contrast than the global muted/caption tokens. */ +[data-theme="dark"] .brand-sub { color: rgba(255, 255, 255, 0.56); } +[data-theme="dark"] .nav-item { color: rgba(255, 255, 255, 0.66); } + +.sidebar.collapsed .brand-text { display: none; } + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: #4e5564; /* a11y: was --muted, too light over the sidebar (WCAG AA) */ + font: 500 13.5px/1.2 var(--sans); + white-space: nowrap; /* label stays one line during the collapse/expand width animation */ + cursor: pointer; + text-align: left; + transition: background 0.18s var(--ease), color 0.18s var(--ease), transform 0.12s; +} + +.nav-item:hover { + background: var(--chip-bg); + color: var(--fg); +} + +.nav-item:active { transform: scale(0.98); } + +.nav-item.active { + background: var(--brand-soft); + color: #a44a2c; /* a11y: deep terracotta on the light brand-soft tint (Claude accent) */ + font-weight: 600; +} +[data-theme="dark"] .nav-item.active { color: #c9c7ff; } + +.nav-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; +} + +.nav-item.active .nav-icon { opacity: 1; } + +.sidebar.collapsed .nav-item { + justify-content: center; + padding-left: 0; + padding-right: 0; +} + +.sidebar.collapsed .nav-label { display: none; } + +.sidebar-foot { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3.5px 10px; + border-radius: 99px; + font-size: 11.5px; + font-weight: 600; + color: var(--muted); + background: var(--chip-bg); + letter-spacing: -0.01em; + white-space: nowrap; +} diff --git a/src/renderer/css/04.css b/src/renderer/css/04.css new file mode 100644 index 0000000..a67092d --- /dev/null +++ b/src/renderer/css/04.css @@ -0,0 +1,113 @@ +.status-chip.on { + color: var(--green-text); + background: var(--green-soft); +} + +.status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.foot-tools { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.sidebar.collapsed .status-chip { display: none; } + +.sidebar.collapsed .sidebar-foot { + justify-content: center; +} +.sidebar.collapsed .foot-tools { + flex-direction: column; + align-items: center; + gap: 3px; +} + +/* ── Main ── */ +.main-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.titlebar-drag { + height: 36px; + flex-shrink: 0; +} + +.main-scroll { + flex: 1 1 0; + min-height: 0; + height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + -webkit-overflow-scrolling: touch; +} + +.main-scroll:has(#view-conversations:not(.hidden)) { + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel { + flex: 0 0 auto; + max-width: 1120px; + margin: 0 auto; + padding: 0 32px 28px; + width: 100%; + display: flex; + flex-direction: column; + gap: 14px; + animation: panelIn 0.28s var(--ease); +} + +.panel-full { + flex: 1; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + animation: panelIn 0.28s var(--ease); +} + +.hidden { display: none !important; } + +@keyframes panelIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +/* ── Hero ── */ +.panel-hero { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 16px; + padding: 16px 18px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--card-shadow); + transition: border-color 0.22s var(--ease); +} + +.panel-hero.connected { + border-color: var(--green-border); +} + +.hero-body { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} diff --git a/src/renderer/css/05.css b/src/renderer/css/05.css new file mode 100644 index 0000000..af3308d --- /dev/null +++ b/src/renderer/css/05.css @@ -0,0 +1,109 @@ +.hero-icon { + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--chip-bg); + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.22s var(--ease); +} + +.panel-hero.connected .hero-icon { + background: var(--green-soft); + color: var(--green-text); +} + +.hero-title { + font-size: 16.5px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; +} + +.hero-desc { + font-size: 12.5px; + color: var(--muted); + margin-top: 2.5px; + line-height: 1.35; +} + +.hero-desc b { color: var(--fg); font-weight: 500; } + +.hero-action { + border: none; + border-radius: 9px; + padding: 8px 18px; + font: 600 12px/1 var(--sans); + letter-spacing: -0.01em; + cursor: pointer; + background: linear-gradient(180deg, var(--brand-2) 0%, var(--brand) 100%); + color: #fff; + box-shadow: 0 1px 2px rgba(204,120,92,.25), 0 4px 12px rgba(204,120,92,.18); + transition: transform 0.15s, box-shadow 0.15s, background 0.15s; + white-space: nowrap; +} + +.hero-action:hover { + box-shadow: 0 2px 4px rgba(204,120,92,.3), 0 8px 20px rgba(204,120,92,.22); +} + +.hero-action:active { transform: scale(0.98); } + +.panel-hero.connected .hero-action { + background: var(--red-soft); + color: var(--red-text); + box-shadow: none; + border: 1px solid var(--red-border); +} + +.panel-hero.connected .hero-action:hover { + background: var(--red); + color: #fff; + border-color: transparent; +} + +.hero-hint { + width: 100%; + padding: 7px 10px; + border-radius: 7px; + font-size: 12px; + line-height: 1.4; + color: var(--fg); + background: var(--primary-soft); + border-left: 2px solid var(--primary); +} + +.hero-hint.warn { + background: var(--amber-soft); + border-left-color: var(--amber); +} + +/* ── Toolbar ── */ +.panel-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 2px; +} + +.panel-label { + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--caption); +} + +.toolbar-end { + display: flex; + align-items: center; + gap: 10px; +} + +.caption { + font-size: 12px; + color: var(--caption); +} diff --git a/src/renderer/css/06.css b/src/renderer/css/06.css new file mode 100644 index 0000000..61e717c --- /dev/null +++ b/src/renderer/css/06.css @@ -0,0 +1,112 @@ +.caption.warn { color: var(--amber-text); } + +.muted { color: var(--muted); } +.small { font-size: 11px; } +.mono { font-family: var(--mono); } + +/* ── Buttons ── */ +.btn { + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 7px; + padding: 5px 12px; + font: 500 12px/1 var(--sans); + cursor: pointer; + transition: background 0.14s, border-color 0.14s, transform 0.1s; +} + +.btn:hover { + background: var(--chip-bg); + border-color: var(--border-strong); +} + +.btn:active { transform: scale(0.985); } + +.btn-primary { + background: var(--primary); + border-color: transparent; + color: #fff; + font-weight: 600; +} + +.btn-primary:hover { background: var(--primary-hover); } + +.btn-sm { + padding: 4px 9px; + font-size: 11px; + border-radius: 6px; +} + +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--muted); +} + +.btn.ghost:hover { + background: var(--chip-bg); + color: var(--fg); +} + +.tool-btn { + width: 26px; + height: 26px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-elev); + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.14s; +} + +.tool-btn:hover { + color: var(--fg); + background: var(--chip-bg); + border-color: var(--border-strong); +} + +.tool-btn span { display: flex; align-items: center; justify-content: center; } + +.fab { + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + cursor: pointer; + background: linear-gradient(180deg, #FFAB40 0%, var(--orange) 100%); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(255, 149, 0, .28); + transition: transform 0.14s var(--ease); +} + +.fab:hover { transform: scale(1.06); } + +/* ── Providers ── */ +.provider-list { + display: flex; + flex-direction: column; + gap: 5px; +} + +.provider { + display: grid; + grid-template-columns: 14px 36px 1fr minmax(72px, auto) auto; + align-items: center; + gap: 10px; + padding: 8px 11px 8px 7px; + min-height: 52px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 11px; + box-shadow: var(--card-shadow); + cursor: pointer; + position: relative; + transition: all 0.18s var(--ease); +} diff --git a/src/renderer/css/07.css b/src/renderer/css/07.css new file mode 100644 index 0000000..069ecb0 --- /dev/null +++ b/src/renderer/css/07.css @@ -0,0 +1,115 @@ +.provider:hover { + border-color: var(--border-strong); + box-shadow: var(--card-shadow-hover); +} + +.provider.active { + border-color: var(--green-border); + background: color-mix(in srgb, var(--card-bg) 92%, var(--green) 8%); +} + +.provider.dragging { opacity: 0.4; transform: scale(0.99); } +.provider.drag-over { border-color: var(--brand); background: var(--brand-soft); } + +.grip { + color: var(--caption); + cursor: grab; + font-size: 12px; + opacity: 0.3; + line-height: 1; + user-select: none; +} + +.provider:hover .grip { opacity: 0.65; } + +.prov-icon { + width: 36px; + height: 36px; + border-radius: 9px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 13px; + letter-spacing: -0.01em; + box-shadow: 0 1px 3px rgba(0,0,0,.1); +} + +.prov-icon.lg { width: 52px; height: 52px; font-size: 18px; border-radius: 12px; } +.prov-emoji { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 1.65em; line-height: 1; } + +/* Provider icon picker popover (emoji grid + image upload) */ +.icon-picker { position: fixed; z-index: 300; width: 272px; padding: 10px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 14px 40px rgba(0, 0, 0, 0.3); animation: panelIn 0.16s var(--ease); } +.icon-picker .ip-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; } +.icon-picker .ip-emoji { border: none; background: transparent; cursor: pointer; font-size: 19px; line-height: 1; padding: 4px 0; border-radius: 6px; transition: background 0.12s ease; } +.icon-picker .ip-emoji:hover { background: var(--chip-bg); } +.icon-picker .ip-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 9px; border-top: 1px solid var(--border); } +.icon-picker .ip-act { flex: 1; border: 1px solid var(--border); background: var(--bg-input); color: var(--fg); border-radius: 7px; padding: 5px 6px; font-size: 11px; font-weight: 500; cursor: pointer; transition: all 0.12s ease; } +.icon-picker .ip-act:hover { background: var(--chip-bg); border-color: var(--border-strong); } + +.pinfo { min-width: 0; } + +/* Provider rows respond to their OWN width (container query) so relaxing the window min-width + never lets the model-alias chips collide with the name/URL. Below ~760px the chips drop to a + full-width row beneath the name instead of overflowing into it. */ +.provider-list { container-type: inline-size; } +@container (max-width: 760px) { + .provider { + grid-template-columns: 14px 36px 1fr auto; + grid-template-areas: "grip icon info actions" "models models models models"; + row-gap: 7px; + } + .provider > .grip { grid-area: grip; } + .provider > .prov-icon { grid-area: icon; } + .provider > .pinfo { grid-area: info; } + .provider > .pactions { grid-area: actions; } + .provider > .pmodels { grid-area: models; justify-content: flex-start; max-width: none; } +} + +.pname { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 14.5px; + letter-spacing: -0.01em; +} + +.badge-active { + font-size: 10.5px; + font-weight: 600; + color: var(--green-text); + background: var(--green-soft); + border-radius: 99px; + padding: 1.5px 7px; +} + +.pmeta { + margin-top: 2.5px; + font-size: 12px; + font-family: var(--mono); + color: var(--caption); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pmodels { + display: flex; + gap: 4px; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 200px; +} + +.tag { + font-size: 11px; + font-family: var(--mono); + background: var(--chip-bg); + border-radius: 4px; + padding: 1.5px 5.5px; + color: var(--fg); + white-space: nowrap; +} diff --git a/src/renderer/css/08.css b/src/renderer/css/08.css new file mode 100644 index 0000000..b7fc3a9 --- /dev/null +++ b/src/renderer/css/08.css @@ -0,0 +1,111 @@ +.tag.map { + color: var(--brand-text); + background: var(--brand-soft); + font-weight: 500; +} + +.pactions { + display: flex; + gap: 1px; + opacity: 1; +} + +.pactions button { + width: 26px; + height: 26px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; +} + +.pactions button:hover { + background: var(--chip-bg); + color: var(--fg); +} + +.pactions .danger:hover { + background: var(--red-soft); + color: var(--red-text); +} + +/* ── Empty states ── */ +.state-empty { + text-align: center; + padding: 32px 20px; + border: 1px dashed var(--border); + border-radius: 12px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.state-empty p { margin-bottom: 12px; } + +.state-icon { + display: flex; + justify-content: center; + margin-bottom: 10px; + color: var(--caption); +} + +.state-inline { + padding: 20px 16px; + text-align: center; + font-size: 11.5px; + color: var(--caption); +} + +/* ── Disclosure ── */ +.disclosure { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card-bg); + overflow: hidden; +} + +.disclosure > summary { + padding: 10px 14px; + cursor: pointer; + font-size: 12.5px; + font-weight: 500; + color: var(--muted); + list-style: none; + outline: none; + user-select: none; +} + +.disclosure > summary::-webkit-details-marker { display: none; } + +.disclosure[open] > summary { + border-bottom: 1px solid var(--border); +} + +.disclosure-body { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.settings-head { + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 4px; + margin-bottom: 4px; +} +/* keep the subtitle aligned under "设置" (past the collapse button + gap) */ +.settings-head-sub { padding-left: 36px; } + +.settings-page { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 640px; +} diff --git a/src/renderer/css/09.css b/src/renderer/css/09.css new file mode 100644 index 0000000..6e6d176 --- /dev/null +++ b/src/renderer/css/09.css @@ -0,0 +1,112 @@ +.settings-card { + background: var(--card-bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + box-shadow: var(--card-shadow); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +/* Items inside a settings card are separated only by whitespace, so their edges blur + together. Add a hairline between each direct child to make every item's boundary clear — + but keep a title and its description (and consecutive descriptions) as one group. */ +.settings-card > * + * { + padding-top: 12px; + border-top: 1px solid var(--border); +} +.settings-card-title + .caption, +.settings-card-header + .caption, +.caption + .caption { + padding-top: 0; + border-top: none; +} + +.settings-card-title { + font-size: 13px; + font-weight: 600; + color: var(--fg); +} + +.endpoint-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.endpoint { + flex: 1; + min-width: 180px; + font-family: var(--mono); + font-size: 12px; + color: var(--brand); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 7px; + padding: 7px 10px; +} + +.port-label { + font-size: 12px; + color: var(--muted); + display: flex; + align-items: center; + gap: 5px; +} + +.port-input { + width: 72px; + padding: 5px 7px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + outline: none; +} + +.port-input:focus, .field input:focus, +.conv-detail-toolbar input:focus, .token-row input:focus { + border-color: var(--primary); +} + +.code-block, .export-block { + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + background: #0c0e12 !important; + color: #e8edf4 !important; + border: 1px solid rgba(255,255,255,.08); + border-radius: 8px; + padding: 11px 13px; + overflow-x: auto; +} + +.connect-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.settings-row { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.settings-row.no-border { + padding-top: 0; + border-top: none; +} + +.token-row { + display: inline-flex; + gap: 6px; + align-items: center; + max-width: 300px; +} diff --git a/src/renderer/css/10.css b/src/renderer/css/10.css new file mode 100644 index 0000000..84f66a4 --- /dev/null +++ b/src/renderer/css/10.css @@ -0,0 +1,115 @@ +.token-row input, .mini-select { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 8px; + color: var(--fg); + font-size: 12px; + outline: none; +} + +.token-row input { font-family: var(--mono); flex: 1; } + +.mini-select { + font-family: inherit; + font-size: 12px; + appearance: none; + -webkit-appearance: none; + padding-right: 28px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none'%3E%3Cpath d='M2 3.5L5 6.5L8 3.5' stroke='%238b93a5' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 10px; +} + +/* Request/response body search highlight */ +.dr-mark { background: rgba(255, 213, 0, 0.34); color: inherit; border-radius: 2px; } +.dr-mark.cur { background: #ff9f0a; color: #1a1a1a; } + +.switch { + display: inline-flex; + align-items: center; + gap: 7px; + cursor: pointer; + font-size: 12.5px; +} + +.switch input { display: none; } + +.switch .track { + width: 32px; + height: 17px; + border-radius: 99px; + background: var(--border-strong); + position: relative; + transition: background 0.18s var(--ease); + flex-shrink: 0; +} + +.switch .track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0,0,0,.18); + transition: transform 0.18s var(--ease); +} + +.switch input:checked + .track { background: var(--primary); } +.switch input:checked + .track::after { transform: translateX(15px); } +.switch-label { color: var(--fg); opacity: 0.88; } + +/* ── Alerts ── */ +.alert { + font-size: 12px; + padding: 9px 12px; + border-radius: 8px; + border: 1px solid transparent; +} + +.alert-err, .alert.err { + color: var(--red-text); + background: var(--red-soft); + border-color: var(--red-border); +} + +.alert.ok { + color: var(--green-text); + background: var(--green-soft); + border-color: var(--green-border); +} + +.alert.pending { + color: var(--amber-text); + background: var(--amber-soft); +} + +/* ── Metrics ── */ +.metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; +} + +.metric { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px 14px; + box-shadow: var(--card-shadow); + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--caption); +} diff --git a/src/renderer/css/11.css b/src/renderer/css/11.css new file mode 100644 index 0000000..5d9359b --- /dev/null +++ b/src/renderer/css/11.css @@ -0,0 +1,113 @@ +.metric-value { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: 6px; +} + +.metric-value.sm { font-size: 13.5px; word-break: break-all; } +.metric-value.mono { font-family: var(--mono); } +.metric-value .unit { font-size: 10px; color: var(--muted); font-weight: 400; } + +.metric-sub { + font-size: 10.5px; + color: var(--caption); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pulse-dot, .live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--muted); + flex-shrink: 0; +} + +.pulse-dot.on, .live-dot.on { + background: var(--green); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(91, 127, 63, 0.5); } + 70% { box-shadow: 0 0 0 5px rgba(91, 127, 63, 0); } + 100% { box-shadow: 0 0 0 0 rgba(91, 127, 63, 0); } +} + +/* ── Stream ── */ +.stream-list { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--card-shadow); +} + +.stream-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + border-bottom: 1px solid var(--border); + font-size: 11.5px; + transition: background 0.12s; +} + +.stream-row:hover { background: var(--chip-bg); } +.stream-row:last-child { border-bottom: none; } + +.stream-row .sdot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.sdot.ok { background: var(--green); } +.sdot.err { background: var(--red); } + +.stream-row .method { + font-family: var(--mono); + font-size: 10px; + color: var(--caption); + width: 40px; +} + +.stream-row .models { + flex: 1; + min-width: 0; + font-family: var(--mono); + font-size: 11px; + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; +} + +.stream-row .models .req { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stream-row .models .arrow { color: var(--brand); opacity: 0.55; } +.stream-row .models .out { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stream-row .rewrite { color: var(--brand); font-size: 10px; } +.stream-row .agent-tag { + font-size: 9px; + font-weight: 700; + padding: 1px 4px; + border-radius: 4px; + line-height: 1.2; + flex-shrink: 0; +} +.stream-row .agent-tag.sub { + color: var(--muted); + background: var(--chip-bg); + border: 1px solid var(--border-light); +} +.stream-row .prov { color: var(--caption); font-size: 11px; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stream-row .code { font-family: var(--mono); font-weight: 600; width: 34px; text-align: right; } +.code.ok { color: var(--green-text); } +.code.err { color: var(--red-text); } +.stream-row .ms { font-family: var(--mono); color: var(--caption); width: 48px; text-align: right; } +.stream-row .ts { font-family: var(--mono); color: var(--caption); font-size: 10px; width: 56px; text-align: right; } diff --git a/src/renderer/css/12.css b/src/renderer/css/12.css new file mode 100644 index 0000000..848f621 --- /dev/null +++ b/src/renderer/css/12.css @@ -0,0 +1,119 @@ +.raw-log-wrap summary { padding: 9px 14px; cursor: pointer; font-size: 12px; font-weight: 600; outline: none; } + +.raw-log { + font-family: var(--mono); + font-size: 10.5px; + color: var(--caption); + padding: 8px 14px 12px; + max-height: 150px; + overflow-y: auto; + line-height: 1.55; +} + +/* Status badge shown next to the "网关日志" summary, so the panel reads as live even when empty. */ +.raw-log-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--mono); + font-size: 10.5px; + font-weight: 500; + color: var(--muted); +} +.raw-log-badge .rl-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--caption); + flex-shrink: 0; +} +.raw-log-badge.on { color: var(--green-text); } +.raw-log-badge.on .rl-dot { background: var(--green); } + +.raw-log-empty { color: var(--caption); padding: 3px 0; font-style: italic; } + +.raw-log-line { + display: flex; + gap: 8px; + padding: 2px 0; + white-space: pre-wrap; + word-break: break-word; +} +.raw-log-line .rl-lv { + flex-shrink: 0; + width: 42px; + text-transform: uppercase; + font-weight: 600; + color: var(--muted); +} +.raw-log-line .rl-msg { color: var(--fg); flex: 1; min-width: 0; } +.raw-log-line .rl-t { color: var(--caption); flex-shrink: 0; padding-left: 12px; text-align: right; } +.raw-log-line.lv-error .rl-lv { color: var(--red-text); } +.raw-log-line.lv-warn .rl-lv { color: var(--amber-text); } +.raw-log-line.lv-info .rl-lv { color: var(--green-text); } + +/* ── Conversations ── */ +.conv-layout { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* Export toast (transient confirmation after a JSONL/HTML export) */ +.conv-toast { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%) translateY(10px); + background: var(--fg); + color: var(--bg-app); + font-size: 12.5px; + font-weight: 500; + padding: 9px 16px; + border-radius: 9px; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s var(--ease), transform 0.18s var(--ease); + z-index: 400; +} +.conv-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.conv-toast.err { background: var(--red); color: #fff; } + +/* Tool-card category accent (left rail) — lets you scan what each step is doing */ +.tool-card { border-left-width: 3px; } +.tool-exec { border-left-color: #f59e0b; } /* run / Bash */ +.tool-read { border-left-color: #3b82f6; } /* read */ +.tool-write { border-left-color: #5a9a55; } /* edit / write — soft olive (skin.css) */ +.tool-search { border-left-color: #a855f7; } /* grep / glob */ +.tool-task { border-left-color: #ec4899; } /* subagent */ +.tool-net { border-left-color: #06b6d4; } /* web */ +.tool-todo { border-left-color: #6366f1; } /* todos */ +.tool-mcp { border-left-color: #14b8a6; } /* mcp */ +.tool-default { border-left-color: var(--border-strong); } +/* result-size chip pushed to the right edge of the result summary */ +.tool-res-size { margin-left: auto; font-family: ui-monospace, monospace; font-size: 9.5px; font-weight: 600; color: var(--caption); background: var(--chip-bg); padding: 1px 6px; border-radius: 5px; } + +/* Codex Skill context-load event: tool-card visual language, neutral timeline semantics. */ +.skill-name { flex-shrink: 0; font-family: var(--mono); font-size: calc(10.5px * var(--conv-fs, 1)); color: var(--fg); } +.skill-snapshot > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + cursor: pointer; + list-style: none; + color: var(--muted); + font-size: calc(10.5px * var(--conv-fs, 1)); + font-weight: 600; +} +.skill-snapshot > summary::-webkit-details-marker { display: none; } +.skill-snapshot[open] > summary { border-bottom: 1px solid var(--border); } +.skill-caret { display: inline-block; transition: transform 0.14s var(--ease); } +.skill-snapshot[open] > summary .skill-caret { transform: rotate(90deg); } +.skill-snapshot-body { padding: 8px 10px 10px; } +.skill-snapshot-body pre { margin: 0; } +.skill-source { display: flex; align-items: baseline; gap: 7px; min-width: 0; margin-bottom: 7px; color: var(--caption); font-size: calc(10px * var(--conv-fs, 1)); } +.skill-source code { overflow: hidden; color: var(--muted); font-family: var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.skill-no-snapshot { padding: 6px 10px; color: var(--caption); font-size: calc(10.5px * var(--conv-fs, 1)); } diff --git a/src/renderer/css/13.css b/src/renderer/css/13.css new file mode 100644 index 0000000..a0e2690 --- /dev/null +++ b/src/renderer/css/13.css @@ -0,0 +1,110 @@ +/* Collapsed session sidebar: only the expand button may occupy the 34px strip. Without this the import + (+) button stays in the flex row, overflows the narrow strip, and justify-center shoves the expand + button left under the primary nav — leaving an empty, unclickable strip. !important beats `flex`. */ +.conv-sidebar.collapsed .conv-search > :not(#btnCollapseConvList) { display: none !important; } + +.conv-sidebar { + width: var(--conv-left-w, 248px); /* drag-resizable; JS sets the var, .collapsed overrides below */ + min-width: 200px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + /* No width transition: animating the width reflows the (large) message area every frame — noticeable + jank once a thread has many messages loaded. Collapse instantly (a single reflow) instead. */ + overflow: hidden; +} + +.conv-sidebar.collapsed { + width: 34px; + min-width: 34px; + overflow: visible; +} + +.conv-sidebar.collapsed .conv-search { + padding: 0; + height: 100%; + border-bottom: none; + display: flex; + align-items: center; + justify-content: center; +} + +.conv-sidebar.collapsed .conv-search .search-field { display: none; } + +.conv-sidebar.collapsed .conv-search .tool-btn { + width: 100%; + height: 100%; + min-height: 72px; + border-radius: 0; + border: none; + background: transparent; +} + +.conv-sidebar.collapsed .conv-list { display: none; } + +.conv-search { + padding: 7px 8px; + border-bottom: 1px solid var(--border); + display: flex; + gap: 5px; + align-items: center; + min-width: 0; +} + +.conv-search .tool-btn { + flex-shrink: 0; +} + +/* Collapse/expand morphs these buttons' size/border/radius. `.tool-btn` sets `transition: all`, so those + layout changes animate while the sidebar width snaps instantly → visible jitter in the search row. + Restrict the two collapse toggles to color transitions (id beats `.tool-btn`) so layout snaps cleanly. */ +#btnCollapseConvList, #btnCollapseConvNav { transition-property: color, background-color, border-color; } + +.search-field { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 7px; + padding: 0 4px 0 8px; + transition: border-color 0.12s; +} + +.search-field:focus-within { + border-color: var(--primary); +} + +.search-field input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + padding: 5px 0; + color: var(--fg); + font-size: 11.5px; + outline: none; +} + +.search-clear { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--caption); + font: 500 10px/1 var(--sans); + padding: 3px 6px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s, background 0.12s; +} + +.search-clear:hover { + color: var(--fg); + background: var(--chip-bg); +} + +.conv-list { flex: 1; overflow-y: auto; } diff --git a/src/renderer/css/14.css b/src/renderer/css/14.css new file mode 100644 index 0000000..d6e80e2 --- /dev/null +++ b/src/renderer/css/14.css @@ -0,0 +1,128 @@ +.conv-item { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.12s; + display: flex; + flex-direction: column; + gap: 3px; +} + +.conv-item:hover { background: var(--chip-bg); } + +.conv-item.active { + background: var(--brand-soft); + border-left: 2.5px solid var(--brand); + padding-left: 9.5px; +} + +.conv-item-top { display: flex; align-items: center; gap: 5px; } + +.conv-title { + font-size: 13.5px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conv-item-sub { + font-size: 11.5px; + color: var(--caption); + font-family: var(--mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* project → sessions tree */ +.conv-proj { border-bottom: 1px solid var(--border); } +.conv-proj-head { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-sidebar); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + user-select: none; +} +.conv-proj-head:hover { background: var(--chip-bg); } +.conv-proj-caret { font-size: 10px; color: var(--caption); width: 10px; flex-shrink: 0; } +.conv-proj-name { + font-size: 12.5px; + font-weight: 700; + color: var(--fg); + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.conv-proj-count { + font-size: 10.5px; + font-weight: 600; + color: var(--muted); + background: var(--chip-bg); + padding: 1px 7px; + border-radius: 99px; + flex-shrink: 0; +} +.conv-proj-sessions .conv-item { padding-left: 22px; border-bottom: 1px solid var(--border); } +.conv-proj-sessions .conv-item:last-child { border-bottom: none; } +.conv-proj-sessions .conv-item.active { padding-left: 19.5px; } +.conv-model { color: var(--brand); } + +.conv-item-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--caption); +} + +.conv-live { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--green); + animation: pulse 1.6s infinite; + flex-shrink: 0; +} + +.conv-badge { + font-size: 10.5px; + padding: 1.5px 6px; + border-radius: 99px; + background: var(--chip-bg); +} + +.conv-badge.disk { + color: var(--amber-text); + background: var(--amber-soft); +} + +/* ---- big-search content hit: highlighted snippet on the session row ---- */ +.conv-item-snippet { + font-size: 11px; + color: var(--muted); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; +} +.conv-item-snippet mark { + background: rgba(255, 200, 50, 0.40); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} +/* the match lives inside a subagent — clicking the row opens straight into it */ +.conv-snip-agent { color: var(--brand); font-weight: 600; white-space: nowrap; } +.conv-snip-n { color: var(--caption); font-size: 10px; } diff --git a/src/renderer/css/15.css b/src/renderer/css/15.css new file mode 100644 index 0000000..0a6e85a --- /dev/null +++ b/src/renderer/css/15.css @@ -0,0 +1,170 @@ +/* ---- user tags + rename/add-tag customization ---- */ +.conv-item-tags { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.conv-tag { + display: inline-flex; + align-items: center; + gap: 1px; + max-width: 150px; + padding: 1px 3px 1px 7px; + border-radius: 99px; + background: var(--chip-bg); + color: var(--fg); + font-size: 10.5px; + line-height: 1.45; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.conv-tag:hover { background: var(--brand-soft); color: var(--brand-text); } +.conv-tag.active { background: var(--brand-soft); color: var(--brand-text); font-weight: 600; } +.conv-tag-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conv-tag-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + border: none; + border-radius: 99px; + background: transparent; + color: var(--caption); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: color 0.12s, background 0.12s; +} +.conv-tag-x:hover { color: var(--red); background: var(--red-soft); } +.conv-tag-edit, +.conv-title-edit { + font: inherit; + color: var(--fg); + background: var(--bg-input); + border: 1px solid var(--brand); + outline: none; + padding: 1px 5px; + border-radius: 5px; +} +.conv-tag-edit { width: 100px; font-size: 10.5px; border-radius: 99px; } +.conv-title-edit { width: 100%; font-size: 13.5px; font-weight: 600; } +.conv-tagfilter { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 11px; + color: var(--caption); + border-bottom: 1px solid var(--border); +} +.conv-ctx-menu { + position: fixed; + z-index: 100; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 160px; + padding: 4px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 9px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); +} +.conv-ctx-menu.hidden { display: none; } +.conv-ctx-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--fg); + font-size: 12px; + text-align: left; + cursor: pointer; +} +.conv-ctx-item:hover { background: var(--chip-bg); } +.conv-ctx-item.conv-ctx-danger { color: var(--red-text); } +.conv-ctx-item.conv-ctx-danger:hover { background: var(--red-soft); } + +/* drag-a-.jsonl-to-import overlay */ +.conv-drop-overlay { + position: fixed; + inset: 0; + z-index: 200; + display: none; + align-items: center; + justify-content: center; + background: var(--brand-soft); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + pointer-events: none; /* let drag events fall through to the real target (stable depth count) */ +} +.conv-drop-overlay.show { display: flex; } +.conv-drop-card { + display: flex; + align-items: center; + gap: 10px; + padding: 18px 28px; + border-radius: 14px; + background: var(--bg-elev); + border: 2px dashed var(--brand); + color: var(--fg); + font-size: 15px; + font-weight: 600; + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.3); +} +/* icon sizing — the `download` glyph stands in for the old 📥 on the import badge / dir chip / overlay */ +.conv-drop-card svg { width: 20px; height: 20px; color: var(--brand); } +.conv-badge-import svg { width: 11px; height: 11px; } +.dir-chip-ico { display: inline-flex; align-items: center; } +.dir-chip-ico svg { width: 12px; height: 12px; } +/* protocol badge — shows a provider's wire protocol / whether requests get translated. Direct + (Anthropic passthrough) is quiet; translated (OpenAI Chat/Responses) uses the brand accent. */ +.proto-badge { + display: inline-flex; align-items: center; + font-size: 10px; font-weight: 600; line-height: 1; + padding: 2px 6px; border-radius: 999px; white-space: nowrap; + letter-spacing: 0; text-transform: none; + border: 1px solid transparent; +} +.proto-badge-direct { background: var(--chip-bg); color: var(--muted); } +.proto-badge-xlate { + background: var(--brand-soft); color: var(--brand); + border-color: color-mix(in srgb, var(--brand) 30%, transparent); +} +/* segmented single-select for the upstream protocol — three equal buttons, active = brand pill */ +.proto-seg { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; + padding: 3px; border-radius: 9px; + background: var(--bg-input); border: 1px solid var(--border-custom); +} +.proto-seg-btn { + appearance: none; border: 1px solid transparent; background: transparent; + color: var(--muted); font-size: 12.5px; font-weight: 500; + padding: 7px 6px; border-radius: 7px; cursor: pointer; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + transition: background 140ms, color 140ms, border-color 140ms; +} +.proto-seg-btn:hover { color: var(--fg); background: var(--chip-bg); } +.proto-seg-btn.selected { + background: var(--brand-soft); color: var(--brand); font-weight: 600; + border-color: color-mix(in srgb, var(--brand) 32%, transparent); +} +/* directory-filter chips: the ACTIVE bucket reads as a brand pill (same selection language as + the nav / session tabs); the count bubble tints along. Trash goes red instead. */ +.dir-chip.active { + background: var(--brand-soft); + color: var(--brand); + border-color: color-mix(in srgb, var(--brand) 40%, transparent); + font-weight: 600; +} +.dir-chip.active:hover { background: var(--brand-soft); color: var(--brand); } +.dir-chip.active .dir-chip-n { background: color-mix(in srgb, var(--brand) 20%, transparent); } +.dir-chip-trash.active { + background: var(--red-soft); + color: var(--red); + border-color: color-mix(in srgb, var(--red) 40%, transparent); +} +.dir-chip-trash.active:hover { background: var(--red-soft); color: var(--red); } +.dir-chip-trash.active .dir-chip-n { background: color-mix(in srgb, var(--red) 20%, transparent); } diff --git a/src/renderer/css/16.css b/src/renderer/css/16.css new file mode 100644 index 0000000..7043b42 --- /dev/null +++ b/src/renderer/css/16.css @@ -0,0 +1,114 @@ +/* hover tooltip for truncated fields (overview stats / session titles / project names) */ +.cc-tip { + position: fixed; + z-index: 300; + width: max-content; /* size to the content (one line when it fits) … */ + max-width: min(360px, 88vw); /* … capped narrow so long titles wrap, not bannerize */ + padding: 6px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-elev) 92%, transparent); + backdrop-filter: blur(14px) saturate(1.4); + -webkit-backdrop-filter: blur(14px) saturate(1.4); + border: 1px solid var(--border); + color: var(--fg); + font-size: 11px; + line-height: 1.5; + white-space: normal; + overflow-wrap: anywhere; /* break long unspaced paths only when past the cap */ + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0, 0, 0, 0.1); + pointer-events: none; + opacity: 0; + transform: translateY(3px); + transition: opacity 0.12s ease, transform 0.12s ease; +} +.cc-tip.show { opacity: 1; transform: translateY(0); } + +.conv-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 380px; +} + +.conv-detail-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 11px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); + flex-shrink: 0; + min-width: 0; +} + +.search-icon { + display: flex; + color: var(--caption); + flex-shrink: 0; +} + +.conv-detail-toolbar input { + flex: 1; + min-width: 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + color: var(--fg); + font-size: 11.5px; + outline: none; +} + +.conv-detail-search-controls { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.search-count { + font-size: 10px; + color: var(--caption); + font-family: var(--mono); + min-width: 32px; + text-align: center; +} + +.conv-detail { + flex: 1; + overflow-y: auto; + padding: 28px 40px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.conv-detail > .msg { + max-width: none; /* fill the middle column (resizable); no fixed reading cap */ + width: 100%; +} + +/* GFM tables in rendered markdown (marked emits ; without this they look like raw text). */ +.conv-detail table { + border-collapse: collapse; + margin: 10px 0; + font-size: calc(12.5px * var(--conv-fs, 1)); + display: block; /* scroll wide tables instead of overflowing the message column */ + width: fit-content; + max-width: 100%; + overflow-x: auto; +} +.conv-detail th, .conv-detail td { + border: 1px solid var(--border-strong); + padding: 5px 10px; + text-align: left; + vertical-align: top; +} +.conv-detail thead th { background: var(--chip-bg); font-weight: 600; color: var(--fg); white-space: nowrap; } +.conv-detail tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--fg) 3%, transparent); } + +.conv-detail > .conv-empty, +.conv-detail > .state-empty { + max-width: 360px; + margin: auto; +} diff --git a/src/renderer/css/17.css b/src/renderer/css/17.css new file mode 100644 index 0000000..46d0af8 --- /dev/null +++ b/src/renderer/css/17.css @@ -0,0 +1,118 @@ +.conv-empty { color: var(--muted); font-size: 12px; text-align: center; line-height: 1.5; } + +.search-highlight { + background: rgba(255, 200, 50, 0.35); + border-radius: 2px; + padding: 0 1px; +} + +.search-highlight.current { + background: #f1c40f; + color: #111; + box-shadow: 0 0 0 1.5px rgba(241, 196, 15, 0.5); +} + +/* CSS Custom Highlight API — in-conversation search paints matches with NO DOM mutation (Range-based), + so typing doesn't reflow a huge thread per keystroke. (::highlight only supports a few paint props.) */ +::highlight(cd-search) { background-color: rgba(255, 200, 50, 0.40); } +::highlight(cd-current) { background-color: #f1c40f; color: #111; } + +/* Messages */ +.msg { + display: flex; + flex-direction: column; + gap: 5px; + animation: panelIn 0.18s var(--ease); +} + +.msg-role { + font-size: calc(10px * var(--conv-fs, 1)); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--caption); + display: flex; + align-items: center; + gap: 5px; +} + +.msg.user .msg-body { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 11px; + padding: 12px; + box-shadow: var(--card-shadow); +} +.msg.user .msg-body > .blk-text:first-child > :first-child { margin-top: 0; } + +.msg.assistant .msg-body { + padding: 2px 0 2px 12px; + border-left: 2px solid var(--border-strong); +} + +.msg.assistant.streaming .msg-body { border-left-color: var(--green); } + +.live-pill { + font-size: 9px; + font-weight: 600; + color: var(--green-text); + background: var(--green-soft); + border-radius: 99px; + padding: 1px 5px; + text-transform: none; +} + +/* 会话正文字号 (设置 › 常规 › 会话): --conv-fs is a scale factor set on :root by the renderer + (13px body == 1, absent == default). Every reading-surface size in the message timeline + multiplies by it, so 大/特大/自定义 scale the whole log proportionally — layout stays real px + (no zoom), keeping the scroll/anchor math exact. */ +.msg-body { font-size: calc(13px * var(--conv-fs, 1)); line-height: 1.58; } + +.blk-text p { margin-bottom: 8px; } +.blk-text p:last-child { margin-bottom: 0; } +.blk-text h1, .blk-text h2, .blk-text h3 { font-size: calc(14px * var(--conv-fs, 1)); font-weight: 700; margin: 14px 0 6px; } +.blk-text ul, .blk-text ol { margin: 6px 0; padding-left: 18px; } +.blk-text code { font-family: var(--mono); font-size: calc(11px * var(--conv-fs, 1)); background: var(--chip-bg); padding: 1px 4px; border-radius: 3px; } +.blk-text pre { margin: 8px 0; } + +/* The settings-page live preview reads the same var, so it always matches the real timeline. */ +.conv-font-preview-text { font-size: calc(13px * var(--conv-fs, 1)); line-height: 1.58; color: var(--fg); } + +.msg-img { max-width: 300px; border-radius: 8px; border: 1px solid var(--border); margin: 4px 0; } + +.img-redacted { + font-size: calc(11px * var(--conv-fs, 1)); + color: var(--muted); + padding: 7px 9px; + background: var(--chip-bg); + border-radius: 6px; + display: inline-block; +} + +.turn-meta { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 6px; +} + +.turn-meta span { + font-size: calc(9.5px * var(--conv-fs, 1)); + font-family: var(--mono); + color: var(--caption); + background: var(--chip-bg); + border-radius: 4px; + padding: 1px 5px; +} + +pre { + background: #0c0e12 !important; + border: 1px solid rgba(255,255,255,.07); + border-radius: 7px; + padding: 10px; + overflow-x: auto; + font-family: var(--mono); + font-size: 11px; + line-height: 1.48; + color: #e8edf4; +} diff --git a/src/renderer/css/18.css b/src/renderer/css/18.css new file mode 100644 index 0000000..fcf39b3 --- /dev/null +++ b/src/renderer/css/18.css @@ -0,0 +1,128 @@ +pre code { background: none !important; padding: 0 !important; font-size: inherit; color: inherit; } +pre.wrap { white-space: pre-wrap; word-break: break-all; } +pre.cmd { color: var(--green); } + +/* Unified code blocks (tool results: Read syntax-highlighted by language, Write) with a GitHub-style + line-number gutter. Markdown code blocks reuse the gutter via .cb-has-gutter (added post-highlight + in highlight()). The hljs GitHub-Dark theme paints token colors; we own the frame + gutter. */ +pre.cb { padding: 0 !important; border-radius: 8px; overflow: hidden; line-height: 1.55; font-size: 11.5px; } +pre.cb > code { display: block; overflow-x: auto; padding: 9px 12px !important; white-space: pre; } +pre.cb.cb-plain > code { white-space: pre-wrap; word-break: break-word; } +/* gutter layout — applies to .cb tool blocks AND marked-rendered markdown code blocks */ +pre.cb-has-gutter, .blk-text pre.cb-has-gutter { display: flex; padding: 0 !important; } +pre.cb-has-gutter > code { flex: 1 1 auto; min-width: 0; overflow-x: auto; padding: 9px 12px !important; white-space: pre; } +.cb-gutter { + flex: 0 0 auto; + padding: 9px 10px 9px 14px; + text-align: right; + color: var(--caption); + border-right: 1px solid var(--border); + user-select: none; + -webkit-user-select: none; + white-space: pre; + font-variant-numeric: tabular-nums; + line-height: inherit; + opacity: 0.65; +} + +/* Markdown file viewer (Read/Write of .md): a rendered preview ↔ highlighted source, via tabs. */ +.md-doc { border-radius: 8px; overflow: hidden; } +.md-tabs { display: flex; gap: 3px; margin-bottom: 6px; } +.md-tab { + font-size: 10.5px; + font-weight: 600; + padding: 3px 11px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--caption); + cursor: pointer; + transition: color 0.12s, background 0.12s; +} +.md-tab:hover { color: var(--fg); } +.md-tab.active { color: var(--brand-text); background: var(--brand-soft); } +.md-pane.md-preview { + padding: 12px 16px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 8px; + font-size: calc(12.5px * var(--conv-fs, 1)); + line-height: 1.6; + overflow-x: auto; +} +.md-pane.md-preview > :first-child { margin-top: 0; } +.md-pane.md-preview > :last-child { margin-bottom: 0; } + +/* Code blocks force a dark bg globally (intended, looks right in dark theme). In LIGHT theme that dark + bg pairs with the ACTIVE light hljs token palette (dark token colors) → low contrast / hard to read. + Give light-theme conversation code blocks a light surface so those tokens read (mirrors .dr-pre). + Dark theme is deliberately left untouched. Specificity (0,2,1) + !important beats the global `pre` + !important and the inline Tailwind bg/text utilities; hljs token spans keep their own colors. */ +[data-theme="light"] .conv-detail pre { + background: #f6f8fa !important; + color: #24292e; + border-color: var(--border); +} + +/* Code blocks in the timeline follow the 会话正文字号 factor too — `pre` is global (drawer, + settings export block keep their own sizes), so the scale is scoped to .conv-detail. */ +.conv-detail pre { font-size: calc(11px * var(--conv-fs, 1)); } +.conv-detail pre.cb { font-size: calc(11.5px * var(--conv-fs, 1)); } + +.thinking { + background: rgba(255, 159, 10, 0.04); + border: 1px solid rgba(255, 159, 10, 0.12); + border-radius: 7px; + margin: 6px 0; +} + +.thinking summary { + padding: 7px 10px; + cursor: pointer; + font-size: calc(11px * var(--conv-fs, 1)); + font-weight: 500; + color: var(--amber-text); + outline: none; +} + +.thinking-body { + padding: 0 10px 8px; + font-size: calc(11.5px * var(--conv-fs, 1)); + color: var(--muted); + line-height: 1.48; + border-top: 1px solid rgba(255, 159, 10, 0.08); + margin-top: 3px; + padding-top: 7px; +} + +/* Resizable 3-column conversation layout. Left/right panels have min widths + drag handles; their + width is driven by a CSS var (set by JS, persisted) so the .collapsed override can still win. The + middle column is flex-grow with no fixed width, so it absorbs all remaining space. */ +.conv-resizer { flex: 0 0 5px; align-self: stretch; cursor: col-resize; background: transparent; position: relative; z-index: 15; } +.conv-resizer::after { content: ''; position: absolute; inset: 0 2px; border-radius: 2px; transition: background 0.12s; } +.conv-resizer:hover::after, .conv-resizer.dragging::after { background: var(--brand); } +.conv-layout.resizing { cursor: col-resize; user-select: none; } +.conv-layout.resizing .conv-sidebar, .conv-layout.resizing .conv-nav { transition: none; } /* no lag while dragging */ +.conv-layout:has(.conv-sidebar.collapsed) .conv-resizer-left, +.conv-layout:has(.conv-nav.collapsed) .conv-resizer-right { display: none; } + +/* Inline subagent transcript nested under the call that spawned it (expand-at-call-site). */ +.subagent-inline { + border-top: 1px solid var(--border-strong); + border-left: 2.5px solid var(--brand); + scroll-margin-top: 12px; +} +.subagent-inline-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 14px 16px 16px; +} +.subagent-inline[open] > summary .sub-caret { transform: rotate(90deg); } +/* Landing flash when jumped to from the subagent picker — a bright ring that fades, hard to miss. */ +.subagent-inline.sub-flash { border-radius: 4px; animation: subFlash 2.2s var(--ease); } +@keyframes subFlash { + 0% { box-shadow: 0 0 0 3px var(--brand), 0 0 16px 3px var(--brand-soft); } + 55% { box-shadow: 0 0 0 3px var(--brand), 0 0 16px 3px var(--brand-soft); } + 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); } +} diff --git a/src/renderer/css/19.css b/src/renderer/css/19.css new file mode 100644 index 0000000..f8b579c --- /dev/null +++ b/src/renderer/css/19.css @@ -0,0 +1,111 @@ +.tool-card { + border: 1px solid var(--border-strong); + border-radius: 8px; + margin: 8px 0; + overflow: hidden; + background: var(--card-bg); + box-shadow: var(--card-shadow); +} + +.tool-head { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 10px; + background: var(--chip-bg); + border-bottom: 1px solid var(--border); + font-size: calc(11px * var(--conv-fs, 1)); + font-weight: 600; +} + +.tool-icon { font-size: calc(11px * var(--conv-fs, 1)); } +.tool-name { font-family: var(--mono); font-weight: 600; } +.tool-input { padding: 8px 10px; } +.tool-input pre { margin: 0; } + +.tool-result { border-top: 1px solid var(--border); } +.tool-result summary { padding: 5px 10px; cursor: pointer; font-size: calc(10.5px * var(--conv-fs, 1)); font-weight: 600; color: var(--green-text); outline: none; } +.tool-result.err summary { color: var(--red-text); } +.tool-result pre { margin: 0 10px 8px; } +.tool-pending { padding: 5px 10px; font-size: calc(10.5px * var(--conv-fs, 1)); color: var(--muted); border-top: 1px solid var(--border); } + +.diff { + font-family: var(--mono); + font-size: calc(10.5px * var(--conv-fs, 1)); + border-radius: 5px; + overflow: hidden; + border: 1px solid var(--border); + margin-top: 3px; +} + +.d-del { background: var(--red-soft); color: var(--red-text); padding: 2px 7px; white-space: pre-wrap; } +.d-add { background: var(--green-soft); color: var(--green-text); padding: 2px 7px; white-space: pre-wrap; } + +.todos { display: flex; flex-direction: column; gap: 2px; margin-top: 3px; } +.todo { font-size: calc(11.5px * var(--conv-fs, 1)); display: flex; gap: 7px; } +.todo.completed { color: var(--muted); text-decoration: line-through; } +.todo.in_progress { color: var(--primary); font-weight: 600; } +.todo-box { width: 13px; } + +/* Conv nav */ +.conv-nav { + width: var(--conv-right-w, 220px); /* drag-resizable; JS sets the var, .collapsed overrides below */ + min-width: 180px; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + /* No width transition — see .conv-sidebar: avoids per-frame reflow of the message area on collapse. */ +} + +.conv-nav.collapsed { + width: 34px; + min-width: 34px; + overflow: visible; +} + +.conv-nav-top { + display: flex; + justify-content: flex-end; + padding: 5px 7px 2px; +} + +.conv-nav.collapsed .conv-nav-top { + padding: 0; + height: 100%; + align-items: center; + justify-content: center; +} + +.conv-nav.collapsed .conv-nav-top .tool-btn { + width: 100%; + height: 100%; + min-height: 56px; + border-radius: 0; + border: none; + background: transparent; +} + +.conv-nav.collapsed .conv-nav-head, +.conv-nav.collapsed .conv-stats, +.conv-nav.collapsed .conv-toc { display: none; } + +.conv-nav-head { + padding: 12px 12px 5px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--caption); +} + +.conv-stats { padding: 0 12px 6px; } + +.stat-row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + padding: 4.5px 0; + border-bottom: 1px solid var(--border); +} diff --git a/src/renderer/css/20.css b/src/renderer/css/20.css new file mode 100644 index 0000000..1fc60ab --- /dev/null +++ b/src/renderer/css/20.css @@ -0,0 +1,110 @@ +.stat-row:last-child { border-bottom: none; } +.stat-row .k { color: var(--caption); } +.stat-row .v { font-family: var(--mono); font-size: 11.5px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; } + +.conv-toc { overflow-y: auto; flex: 1; padding: 0 7px 10px; } + +.toc-item { + font-size: 12px; + color: var(--caption); + padding: 4px 7px; + border-radius: 5px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: all 0.1s; +} + +.toc-item:hover { background: var(--chip-bg); color: var(--fg); } + +/* ── Sheet (modal) ── */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.28); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.sheet { + width: 580px; + max-width: 92vw; + max-height: 86vh; + overflow: hidden; /* the body scrolls, not the whole sheet */ + background: var(--bg-elev); + backdrop-filter: blur(40px); + border: 1px solid var(--window-border); + border-radius: 14px; + box-shadow: 0 24px 64px rgba(0,0,0,.18); + display: flex; + flex-direction: column; +} + +.sheet-head { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: inherit; + z-index: 2; +} + +.sheet-head h3 { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.sheet-body { + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 16px; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +/* Keep each section at its natural height so the body overflows and SCROLLS, + instead of flex-compressing a child (e.g. the overflow:hidden
, + whose auto min-size becomes 0) and clipping its content unreachably. */ +.sheet-body > * { flex-shrink: 0; } + +.sheet-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid var(--border); + flex-shrink: 0; + background: inherit; + z-index: 2; +} + +.sheet-foot .spacer { flex: 1; } + +.preset-block { display: flex; flex-direction: column; gap: 7px; } + +.preset-grid { display: flex; flex-wrap: wrap; gap: 5px; } + +.preset-chip { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 99px; + padding: 4.5px 12px; + font-size: 12px; + font-weight: 500; + color: var(--fg); + cursor: pointer; + transition: all 0.12s; + font-family: inherit; +} + +.preset-chip:hover { border-color: var(--brand); color: var(--brand); } +.preset-chip.selected { background: var(--brand); border-color: transparent; color: #fff; } diff --git a/src/renderer/css/21.css b/src/renderer/css/21.css new file mode 100644 index 0000000..dc6f2ae --- /dev/null +++ b/src/renderer/css/21.css @@ -0,0 +1,128 @@ +.icon-center { display: flex; justify-content: center; padding: 2px 0; } + +.field { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; +} + +.field-label { + font-size: 11px; + font-weight: 600; + color: var(--caption); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.field input, .input-with-btn input { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 7px; + padding: 7.5px 11px; + color: var(--fg); + font-size: 13px; + font-family: var(--mono); + width: 100%; + outline: none; + transition: border-color 0.12s; +} + +.field-row { display: flex; gap: 10px; } +.input-with-btn { display: flex; gap: 7px; align-items: center; } +.input-with-btn input { flex: 1; } + +.mappings { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 7px; +} + +.mappings-details > summary { cursor: pointer; font-size: 12.5px; color: var(--muted); outline: none; padding: 11px 14px; } + +.map-rows { display: flex; flex-direction: column; gap: 5px; } + +.map-row { + display: flex; + align-items: center; + gap: 7px; +} + +.map-row input { + flex: 1; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5.5px 8px; + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + outline: none; +} + +.map-row .map-arrow { color: var(--caption); } + +.map-row .m-del { + width: 24px; + height: 24px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* ── Popover (tray) ── */ +.pop-body-root { + background: rgba(252, 252, 254, 0.98); + height: 100vh; + overflow: hidden; + border-radius: 13px; +} + +[data-theme="dark"] .pop-body-root { + background: rgba(22, 23, 28, 0.98); +} + +/* WKWebView draws a blue focus ring on focused buttons (Chromium didn't, so it only showed + up after the Tauri move). The popover is mouse-driven — suppress the ring. */ +.pop-body-root :focus, +.pop-body-root :focus-visible { + outline: none; +} + +/* Light theme: the pale chip-bg seg group + white active pill blur into the popover bg. + Give the group a border and the active pill a clear ring so the top row reads. */ +#popTabs, #popRanges { + border: 1px solid var(--border); +} +#popTabs .seg-btn.active, #popRanges .seg-btn.active { + box-shadow: 0 1px 2px rgba(40, 37, 30, 0.1), 0 0 0 1px var(--border-strong); +} + +/* Instant CSS tooltip for truncated metric cards (model/provider): renders just below the card + on hover — immediate, unlike the slow ~1s native title. */ +.pop-body-root [data-tip] { position: relative; } +.pop-body-root [data-tip]:hover::after { + content: attr(data-tip); + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 30; + max-width: 280px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 10.5px; + font-weight: 500; + color: var(--fg); + background: var(--bg-elev); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 4px 8px; + box-shadow: var(--card-shadow); + pointer-events: none; +} diff --git a/src/renderer/css/22.css b/src/renderer/css/22.css new file mode 100644 index 0000000..5411421 --- /dev/null +++ b/src/renderer/css/22.css @@ -0,0 +1,112 @@ +.pop { + padding: 11px 12px 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +.pop-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + flex-shrink: 0; +} + +.pop-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.pop-tab { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.pop-tab.hidden { display: none !important; } + +.seg-tabs { + display: inline-flex; + gap: 2px; + padding: 2px; + background: var(--chip-bg); + border-radius: 7px; +} + +.seg-tabs .seg-btn { + border: none; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 500; + padding: 4px 9px; + border-radius: 5px; + cursor: pointer; + font-family: inherit; +} + +.seg-tabs .seg-btn:hover { color: var(--fg); } +.seg-tabs .seg-btn.active { background: var(--bg-elev); color: var(--fg); box-shadow: var(--card-shadow); } + +.pop-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + flex-shrink: 0; +} + +.pstat { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 7px; + padding: 5.5px 7px; +} + +.pstat-label { font-size: 10px; font-weight: 600; text-transform: uppercase; color: var(--caption); } +.pstat-val { font-size: 15px; font-weight: 700; margin-top: 2px; letter-spacing: -0.01em; } +.pstat-val.sm { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pstat-val.mono { font-family: var(--mono); } +.pstat-val .u { font-size: 10px; color: var(--muted); font-weight: 400; } + +.heatmap-panel { + flex: 1; + min-height: 0; + display: flex; + align-items: flex-start; + padding: 4px 0 6px; + overflow: hidden; +} + +.heatmap { + --hm-size: 12px; + --hm-gap: 3px; + display: grid; + grid-template-rows: repeat(7, var(--hm-size)); + grid-auto-flow: column; + grid-auto-columns: var(--hm-size); + gap: var(--hm-gap); + /* exact square grid so the parent can never squish the rows into pills */ + height: calc(7 * var(--hm-size) + 6 * var(--hm-gap)); + align-content: start; +} + +.hm-cell { + width: var(--hm-size); + height: var(--hm-size); + aspect-ratio: 1 / 1; + border-radius: 3px; + background: #d6d1c4; /* clearly-visible warm-gray empty cell on the warm-paper panel */ + transition: background-color 0.2s ease; +} +.hm-cell.lv0 { background: #d6d1c4; } +.hm-cell.lv1 { background: rgba(204, 120, 92, 0.34); } +.hm-cell.lv2 { background: rgba(204, 120, 92, 0.55); } +.hm-cell.lv3 { background: rgba(204, 120, 92, 0.76); } +.hm-cell.lv4 { background: var(--brand); } diff --git a/src/renderer/css/23.css b/src/renderer/css/23.css new file mode 100644 index 0000000..b8e4d32 --- /dev/null +++ b/src/renderer/css/23.css @@ -0,0 +1,169 @@ +[data-theme="dark"] .hm-cell { background: rgba(255, 255, 255, 0.14); } +[data-theme="dark"] .hm-cell.lv0 { background: rgba(255, 255, 255, 0.14); } +[data-theme="dark"] .hm-cell.lv1 { background: rgba(125, 122, 255, 0.32); } +[data-theme="dark"] .hm-cell.lv2 { background: rgba(125, 122, 255, 0.54); } +[data-theme="dark"] .hm-cell.lv3 { background: rgba(125, 122, 255, 0.76); } +[data-theme="dark"] .hm-cell.lv4 { background: #7d7aff; } + +/* range / tab pills never wrap (keeps "Last 7 days" etc. on one line, tidy across languages) */ +.seg-btn { white-space: nowrap; } + +/* heatmap hover tooltip — instant + styled (replaces the slow, ugly native title) */ +.hm-tip { + position: fixed; + z-index: 99999; + pointer-events: none; + padding: 5px 9px; + border-radius: 7px; + background: rgba(22, 24, 31, 0.97); + border: 1px solid rgba(255, 255, 255, 0.10); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.40); + opacity: 0; + transform: translateY(3px); + transition: opacity 0.09s ease, transform 0.09s ease; +} +.hm-tip.show { opacity: 1; transform: translateY(0); } +.hm-tip-d { font-size: 10px; font-weight: 600; color: rgba(255, 255, 255, 0.6); letter-spacing: 0.02em; } +.hm-tip-v { font-size: 12.5px; font-weight: 700; color: #fff; margin-top: 1px; white-space: nowrap; } + +.model-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + padding-bottom: 4px; +} + +.model-row { display: flex; align-items: center; gap: 8px; } +.model-name { width: 120px; font-size: 11px; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model-bar { flex: 1; height: 5px; background: var(--chip-bg); border-radius: 3px; overflow: hidden; } +.model-bar-fill { height: 100%; background: var(--brand); } +.model-tok { width: 44px; text-align: right; font-size: 10px; color: var(--caption); } + +.pop-actions { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + margin-top: auto; + padding: 8px 0 10px; + border-top: 1px solid var(--border); + background: inherit; +} + +.pop-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--muted); +} + +/* ── Scrollbars ── */ +::-webkit-scrollbar { width: 7px; height: 7px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: rgba(0,0,0,.1); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} +[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.2); border: 2px solid transparent; background-clip: padding-box; } +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.22); border: 2px solid transparent; background-clip: padding-box; } +/* ── Request inspector drawer (monitor) ── */ +.stream-row.clickable { cursor: pointer; } +.stream-row.clickable:hover { background: var(--chip-bg); } + +.drawer-overlay { + position: fixed; + inset: 0; + z-index: 50; + background: rgba(40, 37, 30, 0.28); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + display: flex; + justify-content: flex-end; + animation: fadeIn 0.16s var(--ease); +} +.drawer { + width: min(640px, 82vw); + height: 100%; + background: var(--bg-elev); + border-left: 1px solid var(--border); + box-shadow: -12px 0 40px rgba(40, 37, 30, 0.18); + display: flex; + flex-direction: column; + animation: drawerIn 0.24s var(--ease); +} +@keyframes drawerIn { from { transform: translateX(24px); opacity: 0.4; } to { transform: none; opacity: 1; } } +.drawer-head { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--border); +} +.drawer-title { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; font-family: var(--mono); font-size: 13px; } +.dr-method { font-weight: 700; color: var(--brand); } +.dr-status { font-weight: 700; padding: 1px 8px; border-radius: 99px; font-size: 12px; } +.dr-status.ok { color: var(--green-text); background: var(--green-soft); } +.dr-status.err { color: var(--red-text); background: var(--red-soft); } +.dr-model { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dr-model .arrow { color: var(--caption); margin: 0 2px; } +.dr-model .rewrite { color: var(--amber-text); } +.drawer-meta { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--border); } +.dr-chip { font-size: 11.5px; padding: 2.5px 9px; border-radius: 99px; background: var(--chip-bg); color: var(--fg); } +.drawer-tabs { display: flex; gap: 4px; padding: 10px 16px 0; border-bottom: 1px solid var(--border); } +.dr-tab { + border: none; + background: transparent; + color: var(--muted); + font: 600 13px/1 var(--sans); + padding: 8px 14px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.dr-tab:hover { color: var(--fg); } +.dr-tab.active { color: var(--brand); border-bottom-color: var(--brand); } +.drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 16px 24px; } +.dr-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + color: var(--fg); + margin: 16px 0 8px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.dr-sub { font-weight: 500; font-family: var(--mono); color: var(--caption); text-transform: none; letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dr-copy { margin-left: auto; } +.dr-kv { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } +.dr-kv-row { display: flex; gap: 10px; padding: 6px 10px; font-family: var(--mono); font-size: 12px; } +.dr-kv-row:nth-child(even) { background: var(--chip-bg); } +.dr-k { color: var(--brand); flex-shrink: 0; min-width: 150px; word-break: break-all; } +.dr-v { color: var(--fg); word-break: break-all; } +/* The global `pre` rule forces a dark code background; for the inspector we pair the + background with the ACTIVE hljs token theme so light theme = light bg + dark tokens + (readable), dark theme = dark bg + light tokens. Override needs !important to beat `pre`. */ +.dr-pre { + background: #f6f8fa !important; + color: #24292e; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + font-size: 12px; + line-height: 1.55; + max-height: 62vh; +} +[data-theme="dark"] .dr-pre { background: #0c0e12 !important; color: #e8edf4; border-color: rgba(255,255,255,.08); } +.dr-pre code { font-family: var(--mono); white-space: pre; color: inherit; } +.dr-empty { padding: 10px; color: var(--caption); font-size: 12.5px; } +.dr-trunc { font-size: 11.5px; color: var(--amber-text); margin-bottom: 6px; } diff --git a/src/renderer/css/24.css b/src/renderer/css/24.css new file mode 100644 index 0000000..974b105 --- /dev/null +++ b/src/renderer/css/24.css @@ -0,0 +1,18 @@ +/* ===== Post-restore reconciliation (where utility markup conflicts with restored CSS) ===== */ + +/* Switch knob: markup utilities set the `translate` property, which STACKS on the original + CSS `transform: translateX(15px)` → knob double-shifts (30px) and overruns onto the label. + Kill the utility translate so the original transform alone drives it. */ +.switch .track, +.switch .track::after { translate: none !important; } + +/* History-dir list: a post-migration feature the original CSS never covered, so its rows had + no spacing. Stack with a gap so they're not glued together. */ +.hist-dir-list { display: flex; flex-direction: column; gap: 10px; } +/* Light theme: white rows on a white card vanish — give them a faint fill + clearer edge */ +[data-theme="light"] .hist-dir-row { background: #f4f2ec; border-color: rgba(41, 38, 31, 0.14); } + +/* Same stacking issue for scale: these get their :active/:hover scale from the restored CSS + (transform), and the markup also carries scale utilities (the `scale` property) → they double. + Kill the utility scale only (translate is left alone, so card hover-lifts still work). */ +.nav-item, .btn, .hero-action, .fab { scale: none !important; } diff --git a/src/renderer/i18n.js b/src/renderer/i18n.js deleted file mode 100644 index ed232f5..0000000 --- a/src/renderer/i18n.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict'; - -/* - * window.I18n — renderer-side i18n runtime (loaded in index.html AND popover.html, after - * i18n-dict.js). No build step. All 5 supported locales (en/zh/zh-TW/ja/ko) are LTR — there - * is NO RTL handling here on purpose; adding Arabic/Hebrew later must be a deliberate change. - */ -(function () { - var D = (window.ccbudI18nDict) || { DICT: { en: {} }, LANGS: ['en'], LOCALE_TAG: { en: 'en-US' } }; - var lang = 'en'; - - function dict() { return D.DICT[lang] || D.DICT.en || {}; } - - function fill(s, params) { - if (!params) return s; - return s.replace(/\{(\w+)\}/g, function (_, k) { return params[k] != null ? params[k] : '{' + k + '}'; }); - } - - function t(key, params) { - var s = dict()[key]; - if (s == null) s = (D.DICT.en && D.DICT.en[key] != null) ? D.DICT.en[key] : key; // fallback: lang → en → key - return fill(s, params); - } - - function apply(root) { - root = root || document; - root.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.getAttribute('data-i18n')); }); - root.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder'))); }); - root.querySelectorAll('[data-i18n-title]').forEach(function (el) { - var v = t(el.getAttribute('data-i18n-title')); - el.setAttribute('title', v); - el.setAttribute('aria-label', v); - }); - } - - function setLang(l) { - lang = (D.LANGS.indexOf(l) >= 0) ? l : 'en'; - try { document.documentElement.setAttribute('lang', I18n.localeTag); } catch (_) {} - try { localStorage.setItem('ccbud-lang', lang); } catch (_) {} - } - - var I18n = { - t: t, - apply: apply, - setLang: setLang, - has: function (key) { return dict()[key] != null || (D.DICT.en && D.DICT.en[key] != null); }, - get lang() { return lang; }, - get localeTag() { return (D.LOCALE_TAG[lang]) || 'en-US'; }, - }; - window.I18n = I18n; -})(); diff --git a/src/renderer/index.html b/src/renderer/index.html index 84818a3..20e0b53 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,6 +7,7 @@ +
@@ -26,7 +27,7 @@ 服务
- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/src/renderer/input.css b/src/renderer/input.css index 8c94e15..112e193 100644 --- a/src/renderer/input.css +++ b/src/renderer/input.css @@ -1,2823 +1,29 @@ -@import "tailwindcss"; - -/* Make the `dark:` utility variant follow the app's data-theme toggle (not the OS setting), - so the popover body bg and the request-inspector code blocks track the in-app theme. */ -@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); - -@theme { - --color-brand: var(--brand); - --color-brand-soft: var(--brand-soft); - --color-brand-2: var(--brand-2); - --color-brand-3: var(--brand-3); - --color-bg-app: var(--bg-app); - --color-bg-sidebar: var(--bg-sidebar); - --color-bg-elev: var(--bg-elev); - --color-bg-input: var(--bg-input); - --color-fg: var(--fg); - --color-muted: var(--muted); - --color-caption: var(--caption); - --color-border-custom: var(--border); - --color-border-strong: var(--border-strong); - --color-primary: var(--primary); - --color-primary-hover: var(--primary-hover); - --color-primary-soft: var(--primary-soft); - --color-green: var(--green); - --color-green-soft: var(--green-soft); - --color-red: var(--red); - --color-red-soft: var(--red-soft); - --color-orange: var(--orange); - --color-amber: var(--amber); - --color-amber-soft: var(--amber-soft); - --color-chip-bg: var(--chip-bg); - --shadow-card: var(--card-shadow); - --shadow-card-hover: var(--card-shadow-hover); - --radius-md: var(--radius-md); - --radius-lg: var(--radius-lg); - --radius-sm: var(--radius-sm); -} - -/* ===== Restored original hand-written design system (pre-migration, the good one) ===== */ -/* ccbud — macOS 27 design system */ - -:root { - /* Claude design language — warm paper + terracotta (mirrors export-assets/skin.css, - the skin used by exported transcripts). Accents are terracotta, not Apple blue/purple. */ - --brand: #cc785c; - --brand-2: #d97757; - --brand-3: #bd5d3a; - --brand-soft: rgba(204, 120, 92, 0.12); - --brand-glow: rgba(204, 120, 92, 0.30); - --brand-text: #a44a2c; - - /* Warm "Ivory" paper canvas; white cards lift off it like the exported transcript. */ - --bg-app: rgba(250, 249, 245, 0.94); - --bg-sidebar: rgba(240, 238, 230, 0.80); - --bg-elev: #ffffff; - --bg-input: #ffffff; - --card-bg: #ffffff; - --card-shadow: 0 1px 2px rgba(40, 37, 30, .06), 0 4px 16px rgba(40, 37, 30, .08); - --card-shadow-hover: 0 3px 8px rgba(40, 37, 30, .10), 0 16px 36px rgba(40, 37, 30, .14); - - --fg: #29261f; /* warm charcoal (Claude --text) */ - --muted: #6e6a5f; /* warm dim text (Claude --text-dim); passes WCAG AA on #faf9f5 */ - --caption: #857f72; /* warm faint text; deepened from Claude --text-faint to keep AA */ - --border: rgba(41, 38, 31, 0.14); /* warm border — visible on white cards over paper */ - --border-strong: rgba(41, 38, 31, 0.2); - --window-border: rgba(0, 0, 0, 0.12); - - --primary: var(--brand-3); - --primary-hover: #a44a2c; - --primary-soft: rgba(189, 93, 58, 0.10); - - /* Vivid system colors stay for tiny FILLS/dots; text & borders use the calmer - `-text`/`-border` tokens below so light surfaces don't glare (Tailwind skin pattern: - vivid fill = ~500, text = ~700, border = ~200). */ - --green: #5b7f3f; /* Claude olive (skin.css) — replaces Apple #34C759 */ - --green-text: #3f5a2c; /* deep olive — WCAG AA on green-soft + paper */ - --green-soft: rgba(91, 127, 63, 0.12); - --green-border: rgba(91, 127, 63, 0.30); - --red: #b24632; /* Claude brick red (skin.css) — replaces Apple #FF3B30 */ - --red-text: #a23b29; - --red-soft: rgba(178, 70, 50, 0.10); - --red-border: rgba(178, 70, 50, 0.22); - --orange: #FF9500; - --amber: #FF9F0A; - --amber-text: #b3760a; - --amber-soft: rgba(255, 159, 10, 0.12); - - --chip-bg: rgba(41, 38, 31, 0.05); - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - - --sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "PingFang SC", sans-serif; - --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; - - --ease: cubic-bezier(0.23, 1, 0.32, 1); -} - -[data-theme="dark"] { - /* OPT: deeper graphite canvas + cards lifted a step with a top "lit" highlight */ - --bg-app: rgba(18, 20, 27, 0.95); - --bg-sidebar: rgba(12, 13, 18, 0.72); - --bg-elev: #22242e; - --bg-input: #16181f; - --card-bg: #22242e; - --card-shadow: inset 0 1px 0 rgba(255,255,255,.06), 0 1px 2px rgba(0,0,0,.32), 0 6px 18px rgba(0,0,0,.34), 0 16px 38px rgba(0,0,0,.30); - --card-shadow-hover: inset 0 1px 0 rgba(255,255,255,.08), 0 2px 8px rgba(0,0,0,.4), 0 22px 50px rgba(0,0,0,.55); - - --fg: rgba(255, 255, 255, 0.92); - --muted: rgba(255, 255, 255, 0.60); /* a11y: more contrast on dark cards/sidebar */ - --caption: rgba(255, 255, 255, 0.55); /* a11y: was 0.38, too dim */ - --border: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.14); - --window-border: rgba(255, 255, 255, 0.09); - - --brand-soft: rgba(99, 102, 241, 0.20); - --brand-text: #5856D6; - --primary: #4F9FFF; - --primary-hover: #6BB0FF; - --primary-soft: rgba(79, 159, 255, 0.14); - - /* On dark, the vivid hues already read well — keep `-text`/`-border` = the live color. */ - --green: #32D74B; - --green-text: #32D74B; - --green-soft: rgba(50, 215, 75, 0.14); - --green-border: rgba(48, 209, 88, 0.38); - --red: #FF6961; - --red-text: #FF6961; - --red-soft: rgba(255, 105, 97, 0.14); - --green-text: #54c47e; /* a11y: bright enough for dark soft-green backgrounds */ - --red-text: #ff8a80; - --red-border: rgba(255, 69, 58, 0.20); - --amber-text: #FF9F0A; - --amber-soft: rgba(255, 179, 64, 0.14); - - --chip-bg: rgba(255, 255, 255, 0.07); -} - -/* NOTE: do NOT add `margin:0`/`padding:0` here — this block is UNLAYERED and would beat - Tailwind's layered spacing utilities, flattening every utility-only element (this was the - real cause of the "everything cramped" look). Preflight (@layer base) already zeroes - margin/padding and is correctly overridden by utilities. */ -*, *::before, *::after { box-sizing: border-box; } - -body { - height: 100vh; - font-family: var(--sans); - font-size: 13px; - color: var(--fg); - background: transparent; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow: hidden; - -webkit-user-select: none; - user-select: none; -} - -.drag-region { -webkit-app-region: drag; } -.no-drag, .btn, .tool-btn, .nav-item, .fab, .switch, select, input, textarea, summary { -webkit-app-region: no-drag; } - -/* The app-wide `user-select: none` (chrome shouldn't be selectable) also blocked copying the - conversation transcript. Re-enable text selection for the message panels (main + request inspector). */ -.conv-detail, .conv-detail *, #reqDrawerBody, #reqDrawerBody * { -webkit-user-select: text; user-select: text; } -/* Gateway URL / export snippet are copyable content read-outs — keep them selectable so the user - can grab a fragment (the app-wide `user-select: none` would otherwise block it). */ -#endpoint, #exportBlock, .endpoint, .code-block { - -webkit-user-select: text; - user-select: text; -} - -/* ── Shell ── */ -.app-window { - display: flex; - height: 100vh; - background: var(--bg-app); - /* No CSS backdrop-filter here: --bg-app is ~94% opaque, so a 52px blur was nearly invisible yet - repainted the ENTIRE window every frame (the main resize/scroll jank). Native macOS vibrancy - ('under-window', set in main.js createWindow) supplies the subtle frosting that does show. */ - border: 1px solid var(--window-border); - border-radius: 14px; - overflow: hidden; - box-shadow: inset 0 0.5px 0 rgba(255,255,255,.12); -} - -/* ── Sidebar ── */ -.sidebar { - width: 196px; - display: flex; - flex-direction: column; - padding: 48px 10px 14px; - background: var(--bg-sidebar); - flex-shrink: 0; - position: relative; - overflow: hidden; /* clip labels mid-animation so they don't wrap while the sidebar is briefly narrow */ - transition: width 0.22s var(--ease); -} -/* The divider is a pseudo-element that starts BELOW the titlebar / traffic-light zone (rather than a - full-height border-right). When collapsed to 52px the border used to run right up against the macOS - traffic-light buttons, which looked jarring; starting it lower keeps the top clean. */ -.sidebar::after { - content: ''; - position: absolute; - top: 44px; - right: 0; - bottom: 0; - width: 1px; - background: var(--border); - pointer-events: none; -} - -.sidebar.collapsed { - width: 52px; - padding-left: 5px; - padding-right: 5px; -} - -/* Settings view must FILL the scroll viewport (not grow with content) so only the content column - scrolls while the sub-nav stays put. Overrides `.panel { flex: 0 0 auto }` (id beats class). */ -#view-settings { flex: 1 1 0%; min-height: 0; } - -/* Settings sub-nav (二级菜单) — a bounded left column, collapsible with auto-shrinking width */ -/* Settings view: left-align it (override .panel's `margin: 0 auto`) so the sub-nav sits flush after the - main sidebar instead of floating in the centre with a big empty gap on its left. */ -#view-settings { margin-left: 0; margin-right: auto; } - -.settings-subnav { - width: 148px; - align-self: stretch; /* divider runs the full height of the section */ - border-right: 1px solid var(--border); - padding-right: 16px; - margin-right: 2px; - min-height: 232px; - overflow: hidden; /* clip labels mid-animation instead of letting them wrap */ - transition: width 0.2s var(--ease); -} -/* Keep each item on one line — during the expand animation the sub-nav is briefly narrow, and without - this the labels wrap to two lines until it finishes. */ -.settings-subnav-item { white-space: nowrap; } -.settings-subnav.collapsed { - width: 44px; - padding-right: 8px; -} -.settings-subnav.collapsed .settings-subnav-label { display: none; } -.settings-subnav.collapsed .settings-subnav-item { - justify-content: center; - padding-left: 0; - padding-right: 0; - gap: 0; -} - -.sidebar-brand { - display: flex; - align-items: center; - gap: 9px; - padding: 0 6px 20px; -} - -.logo { - width: 30px; - height: 30px; - flex-shrink: 0; - filter: drop-shadow(0 2px 8px var(--brand-glow)); -} - -.brand-title { - font-size: 15px; - font-weight: 600; - letter-spacing: -0.02em; - line-height: 1.15; - white-space: nowrap; - transition: color 0.2s; -} - -.brand-title.running { color: var(--green-text); } - -.brand-sub { - font-size: 11.5px; - color: #59616f; /* a11y: --caption was too light over the sidebar (failed WCAG AA contrast) */ - letter-spacing: -0.01em; - margin-top: 1px; - white-space: nowrap; -} -/* a11y: sidebar text needs more contrast than the global muted/caption tokens. */ -[data-theme="dark"] .brand-sub { color: rgba(255, 255, 255, 0.56); } -[data-theme="dark"] .nav-item { color: rgba(255, 255, 255, 0.66); } - -.sidebar.collapsed .brand-text { display: none; } - -.sidebar-nav { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; -} - -.nav-item { - display: flex; - align-items: center; - gap: 9px; - padding: 7px 10px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: #4e5564; /* a11y: was --muted, too light over the sidebar (WCAG AA) */ - font: 500 13.5px/1.2 var(--sans); - white-space: nowrap; /* label stays one line during the collapse/expand width animation */ - cursor: pointer; - text-align: left; - transition: background 0.18s var(--ease), color 0.18s var(--ease), transform 0.12s; -} - -.nav-item:hover { - background: var(--chip-bg); - color: var(--fg); -} - -.nav-item:active { transform: scale(0.98); } - -.nav-item.active { - background: var(--brand-soft); - color: #a44a2c; /* a11y: deep terracotta on the light brand-soft tint (Claude accent) */ - font-weight: 600; -} -[data-theme="dark"] .nav-item.active { color: #c9c7ff; } - -.nav-icon { - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.85; -} - -.nav-item.active .nav-icon { opacity: 1; } - -.sidebar.collapsed .nav-item { - justify-content: center; - padding-left: 0; - padding-right: 0; -} - -.sidebar.collapsed .nav-label { display: none; } - -.sidebar-foot { - margin-top: auto; - padding-top: 12px; - border-top: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.status-chip { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3.5px 10px; - border-radius: 99px; - font-size: 11.5px; - font-weight: 600; - color: var(--muted); - background: var(--chip-bg); - letter-spacing: -0.01em; - white-space: nowrap; -} - -.status-chip.on { - color: var(--green-text); - background: var(--green-soft); -} - -.status-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: currentColor; -} - -.foot-tools { - display: flex; - gap: 4px; - flex-shrink: 0; -} - -.sidebar.collapsed .status-chip { display: none; } - -.sidebar.collapsed .sidebar-foot { - justify-content: center; -} -.sidebar.collapsed .foot-tools { - flex-direction: column; - align-items: center; - gap: 3px; -} - -/* ── Main ── */ -.main-panel { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - min-width: 0; -} - -.titlebar-drag { - height: 36px; - flex-shrink: 0; -} - -.main-scroll { - flex: 1 1 0; - min-height: 0; - height: 0; - overflow-y: auto; - overflow-x: hidden; - scrollbar-gutter: stable; - -webkit-overflow-scrolling: touch; -} - -.main-scroll:has(#view-conversations:not(.hidden)) { - overflow: hidden; - display: flex; - flex-direction: column; -} - -.panel { - flex: 0 0 auto; - max-width: 1120px; - margin: 0 auto; - padding: 0 32px 28px; - width: 100%; - display: flex; - flex-direction: column; - gap: 14px; - animation: panelIn 0.28s var(--ease); -} - -.panel-full { - flex: 1; - min-height: 0; - width: 100%; - overflow: hidden; - display: flex; - animation: panelIn 0.28s var(--ease); -} - -.hidden { display: none !important; } - -@keyframes panelIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: none; } -} - -/* ── Hero ── */ -.panel-hero { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 14px 16px; - padding: 16px 18px; - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 14px; - box-shadow: var(--card-shadow); - transition: border-color 0.22s var(--ease); -} - -.panel-hero.connected { - border-color: var(--green-border); -} - -.hero-body { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - min-width: 0; -} - -.hero-icon { - width: 38px; - height: 38px; - border-radius: 10px; - background: var(--chip-bg); - color: var(--muted); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.22s var(--ease); -} - -.panel-hero.connected .hero-icon { - background: var(--green-soft); - color: var(--green-text); -} - -.hero-title { - font-size: 16.5px; - font-weight: 600; - letter-spacing: -0.02em; - line-height: 1.2; -} - -.hero-desc { - font-size: 12.5px; - color: var(--muted); - margin-top: 2.5px; - line-height: 1.35; -} - -.hero-desc b { color: var(--fg); font-weight: 500; } - -.hero-action { - border: none; - border-radius: 9px; - padding: 8px 18px; - font: 600 12px/1 var(--sans); - letter-spacing: -0.01em; - cursor: pointer; - background: linear-gradient(180deg, var(--brand-2) 0%, var(--brand) 100%); - color: #fff; - box-shadow: 0 1px 2px rgba(204,120,92,.25), 0 4px 12px rgba(204,120,92,.18); - transition: transform 0.15s, box-shadow 0.15s, background 0.15s; - white-space: nowrap; -} - -.hero-action:hover { - box-shadow: 0 2px 4px rgba(204,120,92,.3), 0 8px 20px rgba(204,120,92,.22); -} - -.hero-action:active { transform: scale(0.98); } - -.panel-hero.connected .hero-action { - background: var(--red-soft); - color: var(--red-text); - box-shadow: none; - border: 1px solid var(--red-border); -} - -.panel-hero.connected .hero-action:hover { - background: var(--red); - color: #fff; - border-color: transparent; -} - -.hero-hint { - width: 100%; - padding: 7px 10px; - border-radius: 7px; - font-size: 12px; - line-height: 1.4; - color: var(--fg); - background: var(--primary-soft); - border-left: 2px solid var(--primary); -} - -.hero-hint.warn { - background: var(--amber-soft); - border-left-color: var(--amber); -} - -/* ── Toolbar ── */ -.panel-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 2px; -} - -.panel-label { - font-size: 12.5px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--caption); -} - -.toolbar-end { - display: flex; - align-items: center; - gap: 10px; -} - -.caption { - font-size: 12px; - color: var(--caption); -} - -.caption.warn { color: var(--amber-text); } - -.muted { color: var(--muted); } -.small { font-size: 11px; } -.mono { font-family: var(--mono); } - -/* ── Buttons ── */ -.btn { - background: var(--bg-elev); - color: var(--fg); - border: 1px solid var(--border); - border-radius: 7px; - padding: 5px 12px; - font: 500 12px/1 var(--sans); - cursor: pointer; - transition: background 0.14s, border-color 0.14s, transform 0.1s; -} - -.btn:hover { - background: var(--chip-bg); - border-color: var(--border-strong); -} - -.btn:active { transform: scale(0.985); } - -.btn-primary { - background: var(--primary); - border-color: transparent; - color: #fff; - font-weight: 600; -} - -.btn-primary:hover { background: var(--primary-hover); } - -.btn-sm { - padding: 4px 9px; - font-size: 11px; - border-radius: 6px; -} - -.btn.ghost { - background: transparent; - border-color: transparent; - color: var(--muted); -} - -.btn.ghost:hover { - background: var(--chip-bg); - color: var(--fg); -} - -.tool-btn { - width: 26px; - height: 26px; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--bg-elev); - color: var(--muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.14s; -} - -.tool-btn:hover { - color: var(--fg); - background: var(--chip-bg); - border-color: var(--border-strong); -} - -.tool-btn span { display: flex; align-items: center; justify-content: center; } - -.fab { - width: 26px; - height: 26px; - border: none; - border-radius: 50%; - cursor: pointer; - background: linear-gradient(180deg, #FFAB40 0%, var(--orange) 100%); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 8px rgba(255, 149, 0, .28); - transition: transform 0.14s var(--ease); -} - -.fab:hover { transform: scale(1.06); } - -/* ── Providers ── */ -.provider-list { - display: flex; - flex-direction: column; - gap: 5px; -} - -.provider { - display: grid; - grid-template-columns: 14px 36px 1fr minmax(72px, auto) auto; - align-items: center; - gap: 10px; - padding: 8px 11px 8px 7px; - min-height: 52px; - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 11px; - box-shadow: var(--card-shadow); - cursor: pointer; - position: relative; - transition: all 0.18s var(--ease); -} - -.provider:hover { - border-color: var(--border-strong); - box-shadow: var(--card-shadow-hover); -} - -.provider.active { - border-color: var(--green-border); - background: color-mix(in srgb, var(--card-bg) 92%, var(--green) 8%); -} - -.provider.dragging { opacity: 0.4; transform: scale(0.99); } -.provider.drag-over { border-color: var(--brand); background: var(--brand-soft); } - -.grip { - color: var(--caption); - cursor: grab; - font-size: 12px; - opacity: 0.3; - line-height: 1; - user-select: none; -} - -.provider:hover .grip { opacity: 0.65; } - -.prov-icon { - width: 36px; - height: 36px; - border-radius: 9px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 700; - font-size: 13px; - letter-spacing: -0.01em; - box-shadow: 0 1px 3px rgba(0,0,0,.1); -} - -.prov-icon.lg { width: 52px; height: 52px; font-size: 18px; border-radius: 12px; } -.prov-emoji { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 1.65em; line-height: 1; } - -/* Provider icon picker popover (emoji grid + image upload) */ -.icon-picker { position: fixed; z-index: 300; width: 272px; padding: 10px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 14px 40px rgba(0, 0, 0, 0.3); animation: panelIn 0.16s var(--ease); } -.icon-picker .ip-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; } -.icon-picker .ip-emoji { border: none; background: transparent; cursor: pointer; font-size: 19px; line-height: 1; padding: 4px 0; border-radius: 6px; transition: background 0.12s ease; } -.icon-picker .ip-emoji:hover { background: var(--chip-bg); } -.icon-picker .ip-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 9px; border-top: 1px solid var(--border); } -.icon-picker .ip-act { flex: 1; border: 1px solid var(--border); background: var(--bg-input); color: var(--fg); border-radius: 7px; padding: 5px 6px; font-size: 11px; font-weight: 500; cursor: pointer; transition: all 0.12s ease; } -.icon-picker .ip-act:hover { background: var(--chip-bg); border-color: var(--border-strong); } - -.pinfo { min-width: 0; } - -/* Provider rows respond to their OWN width (container query) so relaxing the window min-width - never lets the model-alias chips collide with the name/URL. Below ~760px the chips drop to a - full-width row beneath the name instead of overflowing into it. */ -.provider-list { container-type: inline-size; } -@container (max-width: 760px) { - .provider { - grid-template-columns: 14px 36px 1fr auto; - grid-template-areas: "grip icon info actions" "models models models models"; - row-gap: 7px; - } - .provider > .grip { grid-area: grip; } - .provider > .prov-icon { grid-area: icon; } - .provider > .pinfo { grid-area: info; } - .provider > .pactions { grid-area: actions; } - .provider > .pmodels { grid-area: models; justify-content: flex-start; max-width: none; } -} - -.pname { - display: flex; - align-items: center; - gap: 6px; - font-weight: 600; - font-size: 14.5px; - letter-spacing: -0.01em; -} - -.badge-active { - font-size: 10.5px; - font-weight: 600; - color: var(--green-text); - background: var(--green-soft); - border-radius: 99px; - padding: 1.5px 7px; -} - -.pmeta { - margin-top: 2.5px; - font-size: 12px; - font-family: var(--mono); - color: var(--caption); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.pmodels { - display: flex; - gap: 4px; - flex-wrap: wrap; - justify-content: flex-end; - max-width: 200px; -} - -.tag { - font-size: 11px; - font-family: var(--mono); - background: var(--chip-bg); - border-radius: 4px; - padding: 1.5px 5.5px; - color: var(--fg); - white-space: nowrap; -} - -.tag.map { - color: var(--brand-text); - background: var(--brand-soft); - font-weight: 500; -} - -.pactions { - display: flex; - gap: 1px; - opacity: 1; -} - -.pactions button { - width: 26px; - height: 26px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.1s; -} - -.pactions button:hover { - background: var(--chip-bg); - color: var(--fg); -} - -.pactions .danger:hover { - background: var(--red-soft); - color: var(--red-text); -} - -/* ── Empty states ── */ -.state-empty { - text-align: center; - padding: 32px 20px; - border: 1px dashed var(--border); - border-radius: 12px; - color: var(--muted); - font-size: 12px; - line-height: 1.5; -} - -.state-empty p { margin-bottom: 12px; } - -.state-icon { - display: flex; - justify-content: center; - margin-bottom: 10px; - color: var(--caption); -} - -.state-inline { - padding: 20px 16px; - text-align: center; - font-size: 11.5px; - color: var(--caption); -} - -/* ── Disclosure ── */ -.disclosure { - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--card-bg); - overflow: hidden; -} - -.disclosure > summary { - padding: 10px 14px; - cursor: pointer; - font-size: 12.5px; - font-weight: 500; - color: var(--muted); - list-style: none; - outline: none; - user-select: none; -} - -.disclosure > summary::-webkit-details-marker { display: none; } - -.disclosure[open] > summary { - border-bottom: 1px solid var(--border); -} - -.disclosure-body { - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.settings-head { - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 4px; - margin-bottom: 4px; -} -/* keep the subtitle aligned under "设置" (past the collapse button + gap) */ -.settings-head-sub { padding-left: 36px; } - -.settings-page { - display: flex; - flex-direction: column; - gap: 14px; - max-width: 640px; -} - -.settings-card { - background: var(--card-bg); - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - box-shadow: var(--card-shadow); - padding: 16px 18px; - display: flex; - flex-direction: column; - gap: 12px; -} -/* Items inside a settings card are separated only by whitespace, so their edges blur - together. Add a hairline between each direct child to make every item's boundary clear — - but keep a title and its description (and consecutive descriptions) as one group. */ -.settings-card > * + * { - padding-top: 12px; - border-top: 1px solid var(--border); -} -.settings-card-title + .caption, -.settings-card-header + .caption, -.caption + .caption { - padding-top: 0; - border-top: none; -} - -.settings-card-title { - font-size: 13px; - font-weight: 600; - color: var(--fg); -} - -.endpoint-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.endpoint { - flex: 1; - min-width: 180px; - font-family: var(--mono); - font-size: 12px; - color: var(--brand); - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 7px; - padding: 7px 10px; -} - -.port-label { - font-size: 12px; - color: var(--muted); - display: flex; - align-items: center; - gap: 5px; -} - -.port-input { - width: 72px; - padding: 5px 7px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--fg); - font-family: var(--mono); - font-size: 12px; - outline: none; -} - -.port-input:focus, .field input:focus, -.conv-detail-toolbar input:focus, .token-row input:focus { - border-color: var(--primary); -} - -.code-block, .export-block { - font-family: var(--mono); - font-size: 12px; - line-height: 1.55; - background: #0c0e12 !important; - color: #e8edf4 !important; - border: 1px solid rgba(255,255,255,.08); - border-radius: 8px; - padding: 11px 13px; - overflow-x: auto; -} - -.connect-actions { - display: flex; - align-items: center; - gap: 10px; -} - -.settings-row { - display: flex; - align-items: center; - gap: 16px; - flex-wrap: wrap; - padding-top: 12px; - border-top: 1px solid var(--border); -} - -.settings-row.no-border { - padding-top: 0; - border-top: none; -} - -.token-row { - display: inline-flex; - gap: 6px; - align-items: center; - max-width: 300px; -} - -.token-row input, .mini-select { - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - padding: 5px 8px; - color: var(--fg); - font-size: 12px; - outline: none; -} - -.token-row input { font-family: var(--mono); flex: 1; } - -.mini-select { - font-family: inherit; - font-size: 12px; - appearance: none; - -webkit-appearance: none; - padding-right: 28px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none'%3E%3Cpath d='M2 3.5L5 6.5L8 3.5' stroke='%238b93a5' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 10px center; - background-size: 10px; -} - -/* Request/response body search highlight */ -.dr-mark { background: rgba(255, 213, 0, 0.34); color: inherit; border-radius: 2px; } -.dr-mark.cur { background: #ff9f0a; color: #1a1a1a; } - -.switch { - display: inline-flex; - align-items: center; - gap: 7px; - cursor: pointer; - font-size: 12.5px; -} - -.switch input { display: none; } - -.switch .track { - width: 32px; - height: 17px; - border-radius: 99px; - background: var(--border-strong); - position: relative; - transition: background 0.18s var(--ease); - flex-shrink: 0; -} - -.switch .track::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 13px; - height: 13px; - border-radius: 50%; - background: #fff; - box-shadow: 0 1px 2px rgba(0,0,0,.18); - transition: transform 0.18s var(--ease); -} - -.switch input:checked + .track { background: var(--primary); } -.switch input:checked + .track::after { transform: translateX(15px); } -.switch-label { color: var(--fg); opacity: 0.88; } - -/* ── Alerts ── */ -.alert { - font-size: 12px; - padding: 9px 12px; - border-radius: 8px; - border: 1px solid transparent; -} - -.alert-err, .alert.err { - color: var(--red-text); - background: var(--red-soft); - border-color: var(--red-border); -} - -.alert.ok { - color: var(--green-text); - background: var(--green-soft); - border-color: var(--green-border); -} - -.alert.pending { - color: var(--amber-text); - background: var(--amber-soft); -} - -/* ── Metrics ── */ -.metric-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; -} - -.metric { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 12px 14px; - box-shadow: var(--card-shadow); - display: flex; - flex-direction: column; - gap: 4px; -} - -.metric-label { - font-size: 9.5px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--caption); -} - -.metric-value { - font-size: 20px; - font-weight: 700; - letter-spacing: -0.02em; - display: flex; - align-items: center; - gap: 6px; -} - -.metric-value.sm { font-size: 13.5px; word-break: break-all; } -.metric-value.mono { font-family: var(--mono); } -.metric-value .unit { font-size: 10px; color: var(--muted); font-weight: 400; } - -.metric-sub { - font-size: 10.5px; - color: var(--caption); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.pulse-dot, .live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--muted); - flex-shrink: 0; -} - -.pulse-dot.on, .live-dot.on { - background: var(--green); - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { box-shadow: 0 0 0 0 rgba(91, 127, 63, 0.5); } - 70% { box-shadow: 0 0 0 5px rgba(91, 127, 63, 0); } - 100% { box-shadow: 0 0 0 0 rgba(91, 127, 63, 0); } -} - -/* ── Stream ── */ -.stream-list { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius-md); - overflow: hidden; - box-shadow: var(--card-shadow); -} - -.stream-row { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 14px; - border-bottom: 1px solid var(--border); - font-size: 11.5px; - transition: background 0.12s; -} - -.stream-row:hover { background: var(--chip-bg); } -.stream-row:last-child { border-bottom: none; } - -.stream-row .sdot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.sdot.ok { background: var(--green); } -.sdot.err { background: var(--red); } - -.stream-row .method { - font-family: var(--mono); - font-size: 10px; - color: var(--caption); - width: 40px; -} - -.stream-row .models { - flex: 1; - min-width: 0; - font-family: var(--mono); - font-size: 11px; - display: flex; - align-items: center; - gap: 5px; - overflow: hidden; -} - -.stream-row .models .req { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.stream-row .models .arrow { color: var(--brand); opacity: 0.55; } -.stream-row .models .out { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.stream-row .rewrite { color: var(--brand); font-size: 10px; } -.stream-row .agent-tag { - font-size: 9px; - font-weight: 700; - padding: 1px 4px; - border-radius: 4px; - line-height: 1.2; - flex-shrink: 0; -} -.stream-row .agent-tag.sub { - color: var(--muted); - background: var(--chip-bg); - border: 1px solid var(--border-light); -} -.stream-row .prov { color: var(--caption); font-size: 11px; max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.stream-row .code { font-family: var(--mono); font-weight: 600; width: 34px; text-align: right; } -.code.ok { color: var(--green-text); } -.code.err { color: var(--red-text); } -.stream-row .ms { font-family: var(--mono); color: var(--caption); width: 48px; text-align: right; } -.stream-row .ts { font-family: var(--mono); color: var(--caption); font-size: 10px; width: 56px; text-align: right; } - -.raw-log-wrap summary { padding: 9px 14px; cursor: pointer; font-size: 12px; font-weight: 600; outline: none; } - -.raw-log { - font-family: var(--mono); - font-size: 10.5px; - color: var(--caption); - padding: 8px 14px 12px; - max-height: 150px; - overflow-y: auto; - line-height: 1.55; -} - -/* Status badge shown next to the "网关日志" summary, so the panel reads as live even when empty. */ -.raw-log-badge { - display: inline-flex; - align-items: center; - gap: 5px; - font-family: var(--mono); - font-size: 10.5px; - font-weight: 500; - color: var(--muted); -} -.raw-log-badge .rl-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--caption); - flex-shrink: 0; -} -.raw-log-badge.on { color: var(--green-text); } -.raw-log-badge.on .rl-dot { background: var(--green); } - -.raw-log-empty { color: var(--caption); padding: 3px 0; font-style: italic; } - -.raw-log-line { - display: flex; - gap: 8px; - padding: 2px 0; - white-space: pre-wrap; - word-break: break-word; -} -.raw-log-line .rl-lv { - flex-shrink: 0; - width: 42px; - text-transform: uppercase; - font-weight: 600; - color: var(--muted); -} -.raw-log-line .rl-msg { color: var(--fg); flex: 1; min-width: 0; } -.raw-log-line .rl-t { color: var(--caption); flex-shrink: 0; padding-left: 12px; text-align: right; } -.raw-log-line.lv-error .rl-lv { color: var(--red-text); } -.raw-log-line.lv-warn .rl-lv { color: var(--amber-text); } -.raw-log-line.lv-info .rl-lv { color: var(--green-text); } - -/* ── Conversations ── */ -.conv-layout { - display: flex; - width: 100%; - height: 100%; - overflow: hidden; -} - -/* Export toast (transient confirmation after a JSONL/HTML export) */ -.conv-toast { - position: fixed; - bottom: 28px; - left: 50%; - transform: translateX(-50%) translateY(10px); - background: var(--fg); - color: var(--bg-app); - font-size: 12.5px; - font-weight: 500; - padding: 9px 16px; - border-radius: 9px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); - opacity: 0; - pointer-events: none; - transition: opacity 0.18s var(--ease), transform 0.18s var(--ease); - z-index: 400; -} -.conv-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } -.conv-toast.err { background: var(--red); color: #fff; } - -/* Tool-card category accent (left rail) — lets you scan what each step is doing */ -.tool-card { border-left-width: 3px; } -.tool-exec { border-left-color: #f59e0b; } /* run / Bash */ -.tool-read { border-left-color: #3b82f6; } /* read */ -.tool-write { border-left-color: #5a9a55; } /* edit / write — soft olive (skin.css) */ -.tool-search { border-left-color: #a855f7; } /* grep / glob */ -.tool-task { border-left-color: #ec4899; } /* subagent */ -.tool-net { border-left-color: #06b6d4; } /* web */ -.tool-todo { border-left-color: #6366f1; } /* todos */ -.tool-mcp { border-left-color: #14b8a6; } /* mcp */ -.tool-default { border-left-color: var(--border-strong); } -/* result-size chip pushed to the right edge of the result summary */ -.tool-res-size { margin-left: auto; font-family: ui-monospace, monospace; font-size: 9.5px; font-weight: 600; color: var(--caption); background: var(--chip-bg); padding: 1px 6px; border-radius: 5px; } - -/* Codex Skill context-load event: tool-card visual language, neutral timeline semantics. */ -.skill-name { flex-shrink: 0; font-family: var(--mono); font-size: calc(10.5px * var(--conv-fs, 1)); color: var(--fg); } -.skill-snapshot > summary { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - cursor: pointer; - list-style: none; - color: var(--muted); - font-size: calc(10.5px * var(--conv-fs, 1)); - font-weight: 600; -} -.skill-snapshot > summary::-webkit-details-marker { display: none; } -.skill-snapshot[open] > summary { border-bottom: 1px solid var(--border); } -.skill-caret { display: inline-block; transition: transform 0.14s var(--ease); } -.skill-snapshot[open] > summary .skill-caret { transform: rotate(90deg); } -.skill-snapshot-body { padding: 8px 10px 10px; } -.skill-snapshot-body pre { margin: 0; } -.skill-source { display: flex; align-items: baseline; gap: 7px; min-width: 0; margin-bottom: 7px; color: var(--caption); font-size: calc(10px * var(--conv-fs, 1)); } -.skill-source code { overflow: hidden; color: var(--muted); font-family: var(--mono); text-overflow: ellipsis; white-space: nowrap; } -.skill-no-snapshot { padding: 6px 10px; color: var(--caption); font-size: calc(10.5px * var(--conv-fs, 1)); } - -/* Collapsed session sidebar: only the expand button may occupy the 34px strip. Without this the import - (+) button stays in the flex row, overflows the narrow strip, and justify-center shoves the expand - button left under the primary nav — leaving an empty, unclickable strip. !important beats `flex`. */ -.conv-sidebar.collapsed .conv-search > :not(#btnCollapseConvList) { display: none !important; } - -.conv-sidebar { - width: var(--conv-left-w, 248px); /* drag-resizable; JS sets the var, .collapsed overrides below */ - min-width: 200px; - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - flex-shrink: 0; - /* No width transition: animating the width reflows the (large) message area every frame — noticeable - jank once a thread has many messages loaded. Collapse instantly (a single reflow) instead. */ - overflow: hidden; -} - -.conv-sidebar.collapsed { - width: 34px; - min-width: 34px; - overflow: visible; -} - -.conv-sidebar.collapsed .conv-search { - padding: 0; - height: 100%; - border-bottom: none; - display: flex; - align-items: center; - justify-content: center; -} - -.conv-sidebar.collapsed .conv-search .search-field { display: none; } - -.conv-sidebar.collapsed .conv-search .tool-btn { - width: 100%; - height: 100%; - min-height: 72px; - border-radius: 0; - border: none; - background: transparent; -} - -.conv-sidebar.collapsed .conv-list { display: none; } - -.conv-search { - padding: 7px 8px; - border-bottom: 1px solid var(--border); - display: flex; - gap: 5px; - align-items: center; - min-width: 0; -} - -.conv-search .tool-btn { - flex-shrink: 0; -} - -/* Collapse/expand morphs these buttons' size/border/radius. `.tool-btn` sets `transition: all`, so those - layout changes animate while the sidebar width snaps instantly → visible jitter in the search row. - Restrict the two collapse toggles to color transitions (id beats `.tool-btn`) so layout snaps cleanly. */ -#btnCollapseConvList, #btnCollapseConvNav { transition-property: color, background-color, border-color; } - -.search-field { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 4px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 7px; - padding: 0 4px 0 8px; - transition: border-color 0.12s; -} - -.search-field:focus-within { - border-color: var(--primary); -} - -.search-field input { - flex: 1; - min-width: 0; - border: none; - background: transparent; - padding: 5px 0; - color: var(--fg); - font-size: 11.5px; - outline: none; -} - -.search-clear { - flex-shrink: 0; - border: none; - background: transparent; - color: var(--caption); - font: 500 10px/1 var(--sans); - padding: 3px 6px; - border-radius: 4px; - cursor: pointer; - white-space: nowrap; - transition: color 0.12s, background 0.12s; -} - -.search-clear:hover { - color: var(--fg); - background: var(--chip-bg); -} - -.conv-list { flex: 1; overflow-y: auto; } - -.conv-item { - padding: 10px 12px; - border-bottom: 1px solid var(--border); - cursor: pointer; - transition: background 0.12s; - display: flex; - flex-direction: column; - gap: 3px; -} - -.conv-item:hover { background: var(--chip-bg); } - -.conv-item.active { - background: var(--brand-soft); - border-left: 2.5px solid var(--brand); - padding-left: 9.5px; -} - -.conv-item-top { display: flex; align-items: center; gap: 5px; } - -.conv-title { - font-size: 13.5px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.conv-item-sub { - font-size: 11.5px; - color: var(--caption); - font-family: var(--mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* project → sessions tree */ -.conv-proj { border-bottom: 1px solid var(--border); } -.conv-proj-head { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - cursor: pointer; - position: sticky; - top: 0; - z-index: 1; - background: var(--bg-sidebar); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - user-select: none; -} -.conv-proj-head:hover { background: var(--chip-bg); } -.conv-proj-caret { font-size: 10px; color: var(--caption); width: 10px; flex-shrink: 0; } -.conv-proj-name { - font-size: 12.5px; - font-weight: 700; - color: var(--fg); - letter-spacing: -0.01em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -} -.conv-proj-count { - font-size: 10.5px; - font-weight: 600; - color: var(--muted); - background: var(--chip-bg); - padding: 1px 7px; - border-radius: 99px; - flex-shrink: 0; -} -.conv-proj-sessions .conv-item { padding-left: 22px; border-bottom: 1px solid var(--border); } -.conv-proj-sessions .conv-item:last-child { border-bottom: none; } -.conv-proj-sessions .conv-item.active { padding-left: 19.5px; } -.conv-model { color: var(--brand); } - -.conv-item-meta { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--caption); -} - -.conv-live { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--green); - animation: pulse 1.6s infinite; - flex-shrink: 0; -} - -.conv-badge { - font-size: 10.5px; - padding: 1.5px 6px; - border-radius: 99px; - background: var(--chip-bg); -} - -.conv-badge.disk { - color: var(--amber-text); - background: var(--amber-soft); -} - -/* ---- big-search content hit: highlighted snippet on the session row ---- */ -.conv-item-snippet { - font-size: 11px; - color: var(--muted); - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-all; -} -.conv-item-snippet mark { - background: rgba(255, 200, 50, 0.40); - color: inherit; - border-radius: 2px; - padding: 0 1px; -} -/* the match lives inside a subagent — clicking the row opens straight into it */ -.conv-snip-agent { color: var(--brand); font-weight: 600; white-space: nowrap; } -.conv-snip-n { color: var(--caption); font-size: 10px; } - -/* ---- user tags + rename/add-tag customization ---- */ -.conv-item-tags { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } -.conv-tag { - display: inline-flex; - align-items: center; - gap: 1px; - max-width: 150px; - padding: 1px 3px 1px 7px; - border-radius: 99px; - background: var(--chip-bg); - color: var(--fg); - font-size: 10.5px; - line-height: 1.45; - cursor: pointer; - transition: background 0.12s, color 0.12s; -} -.conv-tag:hover { background: var(--brand-soft); color: var(--brand-text); } -.conv-tag.active { background: var(--brand-soft); color: var(--brand-text); font-weight: 600; } -.conv-tag-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.conv-tag-x { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - padding: 0; - border: none; - border-radius: 99px; - background: transparent; - color: var(--caption); - font-size: 12px; - line-height: 1; - cursor: pointer; - transition: color 0.12s, background 0.12s; -} -.conv-tag-x:hover { color: var(--red); background: var(--red-soft); } -.conv-tag-edit, -.conv-title-edit { - font: inherit; - color: var(--fg); - background: var(--bg-input); - border: 1px solid var(--brand); - outline: none; - padding: 1px 5px; - border-radius: 5px; -} -.conv-tag-edit { width: 100px; font-size: 10.5px; border-radius: 99px; } -.conv-title-edit { width: 100%; font-size: 13.5px; font-weight: 600; } -.conv-tagfilter { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - font-size: 11px; - color: var(--caption); - border-bottom: 1px solid var(--border); -} -.conv-ctx-menu { - position: fixed; - z-index: 100; - display: flex; - flex-direction: column; - gap: 1px; - min-width: 160px; - padding: 4px; - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: 9px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24); -} -.conv-ctx-menu.hidden { display: none; } -.conv-ctx-item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 6px 10px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--fg); - font-size: 12px; - text-align: left; - cursor: pointer; -} -.conv-ctx-item:hover { background: var(--chip-bg); } -.conv-ctx-item.conv-ctx-danger { color: var(--red-text); } -.conv-ctx-item.conv-ctx-danger:hover { background: var(--red-soft); } - -/* drag-a-.jsonl-to-import overlay */ -.conv-drop-overlay { - position: fixed; - inset: 0; - z-index: 200; - display: none; - align-items: center; - justify-content: center; - background: var(--brand-soft); - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); - pointer-events: none; /* let drag events fall through to the real target (stable depth count) */ -} -.conv-drop-overlay.show { display: flex; } -.conv-drop-card { - display: flex; - align-items: center; - gap: 10px; - padding: 18px 28px; - border-radius: 14px; - background: var(--bg-elev); - border: 2px dashed var(--brand); - color: var(--fg); - font-size: 15px; - font-weight: 600; - box-shadow: 0 16px 50px rgba(0, 0, 0, 0.3); -} -/* icon sizing — the `download` glyph stands in for the old 📥 on the import badge / dir chip / overlay */ -.conv-drop-card svg { width: 20px; height: 20px; color: var(--brand); } -.conv-badge-import svg { width: 11px; height: 11px; } -.dir-chip-ico { display: inline-flex; align-items: center; } -.dir-chip-ico svg { width: 12px; height: 12px; } -/* protocol badge — shows a provider's wire protocol / whether requests get translated. Direct - (Anthropic passthrough) is quiet; translated (OpenAI Chat/Responses) uses the brand accent. */ -.proto-badge { - display: inline-flex; align-items: center; - font-size: 10px; font-weight: 600; line-height: 1; - padding: 2px 6px; border-radius: 999px; white-space: nowrap; - letter-spacing: 0; text-transform: none; - border: 1px solid transparent; -} -.proto-badge-direct { background: var(--chip-bg); color: var(--muted); } -.proto-badge-xlate { - background: var(--brand-soft); color: var(--brand); - border-color: color-mix(in srgb, var(--brand) 30%, transparent); -} -/* segmented single-select for the upstream protocol — three equal buttons, active = brand pill */ -.proto-seg { - display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; - padding: 3px; border-radius: 9px; - background: var(--bg-input); border: 1px solid var(--border-custom); -} -.proto-seg-btn { - appearance: none; border: 1px solid transparent; background: transparent; - color: var(--muted); font-size: 12.5px; font-weight: 500; - padding: 7px 6px; border-radius: 7px; cursor: pointer; white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; - transition: background 140ms, color 140ms, border-color 140ms; -} -.proto-seg-btn:hover { color: var(--fg); background: var(--chip-bg); } -.proto-seg-btn.selected { - background: var(--brand-soft); color: var(--brand); font-weight: 600; - border-color: color-mix(in srgb, var(--brand) 32%, transparent); -} -/* directory-filter chips: the ACTIVE bucket reads as a brand pill (same selection language as - the nav / session tabs); the count bubble tints along. Trash goes red instead. */ -.dir-chip.active { - background: var(--brand-soft); - color: var(--brand); - border-color: color-mix(in srgb, var(--brand) 40%, transparent); - font-weight: 600; -} -.dir-chip.active:hover { background: var(--brand-soft); color: var(--brand); } -.dir-chip.active .dir-chip-n { background: color-mix(in srgb, var(--brand) 20%, transparent); } -.dir-chip-trash.active { - background: var(--red-soft); - color: var(--red); - border-color: color-mix(in srgb, var(--red) 40%, transparent); -} -.dir-chip-trash.active:hover { background: var(--red-soft); color: var(--red); } -.dir-chip-trash.active .dir-chip-n { background: color-mix(in srgb, var(--red) 20%, transparent); } - -/* hover tooltip for truncated fields (overview stats / session titles / project names) */ -.cc-tip { - position: fixed; - z-index: 300; - width: max-content; /* size to the content (one line when it fits) … */ - max-width: min(360px, 88vw); /* … capped narrow so long titles wrap, not bannerize */ - padding: 6px 10px; - border-radius: 8px; - background: color-mix(in srgb, var(--bg-elev) 92%, transparent); - backdrop-filter: blur(14px) saturate(1.4); - -webkit-backdrop-filter: blur(14px) saturate(1.4); - border: 1px solid var(--border); - color: var(--fg); - font-size: 11px; - line-height: 1.5; - white-space: normal; - overflow-wrap: anywhere; /* break long unspaced paths only when past the cap */ - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0, 0, 0, 0.1); - pointer-events: none; - opacity: 0; - transform: translateY(3px); - transition: opacity 0.12s ease, transform 0.12s ease; -} -.cc-tip.show { opacity: 1; transform: translateY(0); } - -.conv-main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 380px; -} - -.conv-detail-toolbar { - display: flex; - align-items: center; - gap: 6px; - padding: 5px 11px; - border-bottom: 1px solid var(--border); - background: var(--bg-elev); - flex-shrink: 0; - min-width: 0; -} - -.search-icon { - display: flex; - color: var(--caption); - flex-shrink: 0; -} - -.conv-detail-toolbar input { - flex: 1; - min-width: 0; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - padding: 4px 8px; - color: var(--fg); - font-size: 11.5px; - outline: none; -} - -.conv-detail-search-controls { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -.search-count { - font-size: 10px; - color: var(--caption); - font-family: var(--mono); - min-width: 32px; - text-align: center; -} - -.conv-detail { - flex: 1; - overflow-y: auto; - padding: 28px 40px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.conv-detail > .msg { - max-width: none; /* fill the middle column (resizable); no fixed reading cap */ - width: 100%; -} - -/* GFM tables in rendered markdown (marked emits
; without this they look like raw text). */ -.conv-detail table { - border-collapse: collapse; - margin: 10px 0; - font-size: calc(12.5px * var(--conv-fs, 1)); - display: block; /* scroll wide tables instead of overflowing the message column */ - width: fit-content; - max-width: 100%; - overflow-x: auto; -} -.conv-detail th, .conv-detail td { - border: 1px solid var(--border-strong); - padding: 5px 10px; - text-align: left; - vertical-align: top; -} -.conv-detail thead th { background: var(--chip-bg); font-weight: 600; color: var(--fg); white-space: nowrap; } -.conv-detail tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--fg) 3%, transparent); } - -.conv-detail > .conv-empty, -.conv-detail > .state-empty { - max-width: 360px; - margin: auto; -} - -.conv-empty { color: var(--muted); font-size: 12px; text-align: center; line-height: 1.5; } - -.search-highlight { - background: rgba(255, 200, 50, 0.35); - border-radius: 2px; - padding: 0 1px; -} - -.search-highlight.current { - background: #f1c40f; - color: #111; - box-shadow: 0 0 0 1.5px rgba(241, 196, 15, 0.5); -} - -/* CSS Custom Highlight API — in-conversation search paints matches with NO DOM mutation (Range-based), - so typing doesn't reflow a huge thread per keystroke. (::highlight only supports a few paint props.) */ -::highlight(cd-search) { background-color: rgba(255, 200, 50, 0.40); } -::highlight(cd-current) { background-color: #f1c40f; color: #111; } - -/* Messages */ -.msg { - display: flex; - flex-direction: column; - gap: 5px; - animation: panelIn 0.18s var(--ease); -} - -.msg-role { - font-size: calc(10px * var(--conv-fs, 1)); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--caption); - display: flex; - align-items: center; - gap: 5px; -} - -.msg.user .msg-body { - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: 11px; - padding: 12px; - box-shadow: var(--card-shadow); -} -.msg.user .msg-body > .blk-text:first-child > :first-child { margin-top: 0; } - -.msg.assistant .msg-body { - padding: 2px 0 2px 12px; - border-left: 2px solid var(--border-strong); -} - -.msg.assistant.streaming .msg-body { border-left-color: var(--green); } - -.live-pill { - font-size: 9px; - font-weight: 600; - color: var(--green-text); - background: var(--green-soft); - border-radius: 99px; - padding: 1px 5px; - text-transform: none; -} - -/* 会话正文字号 (设置 › 常规 › 会话): --conv-fs is a scale factor set on :root by the renderer - (13px body == 1, absent == default). Every reading-surface size in the message timeline - multiplies by it, so 大/特大/自定义 scale the whole log proportionally — layout stays real px - (no zoom), keeping the scroll/anchor math exact. */ -.msg-body { font-size: calc(13px * var(--conv-fs, 1)); line-height: 1.58; } - -.blk-text p { margin-bottom: 8px; } -.blk-text p:last-child { margin-bottom: 0; } -.blk-text h1, .blk-text h2, .blk-text h3 { font-size: calc(14px * var(--conv-fs, 1)); font-weight: 700; margin: 14px 0 6px; } -.blk-text ul, .blk-text ol { margin: 6px 0; padding-left: 18px; } -.blk-text code { font-family: var(--mono); font-size: calc(11px * var(--conv-fs, 1)); background: var(--chip-bg); padding: 1px 4px; border-radius: 3px; } -.blk-text pre { margin: 8px 0; } - -/* The settings-page live preview reads the same var, so it always matches the real timeline. */ -.conv-font-preview-text { font-size: calc(13px * var(--conv-fs, 1)); line-height: 1.58; color: var(--fg); } - -.msg-img { max-width: 300px; border-radius: 8px; border: 1px solid var(--border); margin: 4px 0; } - -.img-redacted { - font-size: calc(11px * var(--conv-fs, 1)); - color: var(--muted); - padding: 7px 9px; - background: var(--chip-bg); - border-radius: 6px; - display: inline-block; -} - -.turn-meta { - display: flex; - gap: 4px; - flex-wrap: wrap; - margin-top: 6px; -} - -.turn-meta span { - font-size: calc(9.5px * var(--conv-fs, 1)); - font-family: var(--mono); - color: var(--caption); - background: var(--chip-bg); - border-radius: 4px; - padding: 1px 5px; -} - -pre { - background: #0c0e12 !important; - border: 1px solid rgba(255,255,255,.07); - border-radius: 7px; - padding: 10px; - overflow-x: auto; - font-family: var(--mono); - font-size: 11px; - line-height: 1.48; - color: #e8edf4; -} - -pre code { background: none !important; padding: 0 !important; font-size: inherit; color: inherit; } -pre.wrap { white-space: pre-wrap; word-break: break-all; } -pre.cmd { color: var(--green); } - -/* Unified code blocks (tool results: Read syntax-highlighted by language, Write) with a GitHub-style - line-number gutter. Markdown code blocks reuse the gutter via .cb-has-gutter (added post-highlight - in highlight()). The hljs GitHub-Dark theme paints token colors; we own the frame + gutter. */ -pre.cb { padding: 0 !important; border-radius: 8px; overflow: hidden; line-height: 1.55; font-size: 11.5px; } -pre.cb > code { display: block; overflow-x: auto; padding: 9px 12px !important; white-space: pre; } -pre.cb.cb-plain > code { white-space: pre-wrap; word-break: break-word; } -/* gutter layout — applies to .cb tool blocks AND marked-rendered markdown code blocks */ -pre.cb-has-gutter, .blk-text pre.cb-has-gutter { display: flex; padding: 0 !important; } -pre.cb-has-gutter > code { flex: 1 1 auto; min-width: 0; overflow-x: auto; padding: 9px 12px !important; white-space: pre; } -.cb-gutter { - flex: 0 0 auto; - padding: 9px 10px 9px 14px; - text-align: right; - color: var(--caption); - border-right: 1px solid var(--border); - user-select: none; - -webkit-user-select: none; - white-space: pre; - font-variant-numeric: tabular-nums; - line-height: inherit; - opacity: 0.65; -} - -/* Markdown file viewer (Read/Write of .md): a rendered preview ↔ highlighted source, via tabs. */ -.md-doc { border-radius: 8px; overflow: hidden; } -.md-tabs { display: flex; gap: 3px; margin-bottom: 6px; } -.md-tab { - font-size: 10.5px; - font-weight: 600; - padding: 3px 11px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--caption); - cursor: pointer; - transition: color 0.12s, background 0.12s; -} -.md-tab:hover { color: var(--fg); } -.md-tab.active { color: var(--brand-text); background: var(--brand-soft); } -.md-pane.md-preview { - padding: 12px 16px; - background: var(--bg-elev); - border: 1px solid var(--border); - border-radius: 8px; - font-size: calc(12.5px * var(--conv-fs, 1)); - line-height: 1.6; - overflow-x: auto; -} -.md-pane.md-preview > :first-child { margin-top: 0; } -.md-pane.md-preview > :last-child { margin-bottom: 0; } - -/* Code blocks force a dark bg globally (intended, looks right in dark theme). In LIGHT theme that dark - bg pairs with the ACTIVE light hljs token palette (dark token colors) → low contrast / hard to read. - Give light-theme conversation code blocks a light surface so those tokens read (mirrors .dr-pre). - Dark theme is deliberately left untouched. Specificity (0,2,1) + !important beats the global `pre` - !important and the inline Tailwind bg/text utilities; hljs token spans keep their own colors. */ -[data-theme="light"] .conv-detail pre { - background: #f6f8fa !important; - color: #24292e; - border-color: var(--border); -} - -/* Code blocks in the timeline follow the 会话正文字号 factor too — `pre` is global (drawer, - settings export block keep their own sizes), so the scale is scoped to .conv-detail. */ -.conv-detail pre { font-size: calc(11px * var(--conv-fs, 1)); } -.conv-detail pre.cb { font-size: calc(11.5px * var(--conv-fs, 1)); } - -.thinking { - background: rgba(255, 159, 10, 0.04); - border: 1px solid rgba(255, 159, 10, 0.12); - border-radius: 7px; - margin: 6px 0; -} - -.thinking summary { - padding: 7px 10px; - cursor: pointer; - font-size: calc(11px * var(--conv-fs, 1)); - font-weight: 500; - color: var(--amber-text); - outline: none; -} - -.thinking-body { - padding: 0 10px 8px; - font-size: calc(11.5px * var(--conv-fs, 1)); - color: var(--muted); - line-height: 1.48; - border-top: 1px solid rgba(255, 159, 10, 0.08); - margin-top: 3px; - padding-top: 7px; -} - -/* Resizable 3-column conversation layout. Left/right panels have min widths + drag handles; their - width is driven by a CSS var (set by JS, persisted) so the .collapsed override can still win. The - middle column is flex-grow with no fixed width, so it absorbs all remaining space. */ -.conv-resizer { flex: 0 0 5px; align-self: stretch; cursor: col-resize; background: transparent; position: relative; z-index: 15; } -.conv-resizer::after { content: ''; position: absolute; inset: 0 2px; border-radius: 2px; transition: background 0.12s; } -.conv-resizer:hover::after, .conv-resizer.dragging::after { background: var(--brand); } -.conv-layout.resizing { cursor: col-resize; user-select: none; } -.conv-layout.resizing .conv-sidebar, .conv-layout.resizing .conv-nav { transition: none; } /* no lag while dragging */ -.conv-layout:has(.conv-sidebar.collapsed) .conv-resizer-left, -.conv-layout:has(.conv-nav.collapsed) .conv-resizer-right { display: none; } - -/* Inline subagent transcript nested under the call that spawned it (expand-at-call-site). */ -.subagent-inline { - border-top: 1px solid var(--border-strong); - border-left: 2.5px solid var(--brand); - scroll-margin-top: 12px; -} -.subagent-inline-body { - display: flex; - flex-direction: column; - gap: 14px; - padding: 14px 16px 16px; -} -.subagent-inline[open] > summary .sub-caret { transform: rotate(90deg); } -/* Landing flash when jumped to from the subagent picker — a bright ring that fades, hard to miss. */ -.subagent-inline.sub-flash { border-radius: 4px; animation: subFlash 2.2s var(--ease); } -@keyframes subFlash { - 0% { box-shadow: 0 0 0 3px var(--brand), 0 0 16px 3px var(--brand-soft); } - 55% { box-shadow: 0 0 0 3px var(--brand), 0 0 16px 3px var(--brand-soft); } - 100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); } -} - -.tool-card { - border: 1px solid var(--border-strong); - border-radius: 8px; - margin: 8px 0; - overflow: hidden; - background: var(--card-bg); - box-shadow: var(--card-shadow); -} - -.tool-head { - display: flex; - align-items: center; - gap: 7px; - padding: 7px 10px; - background: var(--chip-bg); - border-bottom: 1px solid var(--border); - font-size: calc(11px * var(--conv-fs, 1)); - font-weight: 600; -} - -.tool-icon { font-size: calc(11px * var(--conv-fs, 1)); } -.tool-name { font-family: var(--mono); font-weight: 600; } -.tool-input { padding: 8px 10px; } -.tool-input pre { margin: 0; } - -.tool-result { border-top: 1px solid var(--border); } -.tool-result summary { padding: 5px 10px; cursor: pointer; font-size: calc(10.5px * var(--conv-fs, 1)); font-weight: 600; color: var(--green-text); outline: none; } -.tool-result.err summary { color: var(--red-text); } -.tool-result pre { margin: 0 10px 8px; } -.tool-pending { padding: 5px 10px; font-size: calc(10.5px * var(--conv-fs, 1)); color: var(--muted); border-top: 1px solid var(--border); } - -.diff { - font-family: var(--mono); - font-size: calc(10.5px * var(--conv-fs, 1)); - border-radius: 5px; - overflow: hidden; - border: 1px solid var(--border); - margin-top: 3px; -} - -.d-del { background: var(--red-soft); color: var(--red-text); padding: 2px 7px; white-space: pre-wrap; } -.d-add { background: var(--green-soft); color: var(--green-text); padding: 2px 7px; white-space: pre-wrap; } - -.todos { display: flex; flex-direction: column; gap: 2px; margin-top: 3px; } -.todo { font-size: calc(11.5px * var(--conv-fs, 1)); display: flex; gap: 7px; } -.todo.completed { color: var(--muted); text-decoration: line-through; } -.todo.in_progress { color: var(--primary); font-weight: 600; } -.todo-box { width: 13px; } - -/* Conv nav */ -.conv-nav { - width: var(--conv-right-w, 220px); /* drag-resizable; JS sets the var, .collapsed overrides below */ - min-width: 180px; - border-left: 1px solid var(--border); - display: flex; - flex-direction: column; - flex-shrink: 0; - /* No width transition — see .conv-sidebar: avoids per-frame reflow of the message area on collapse. */ -} - -.conv-nav.collapsed { - width: 34px; - min-width: 34px; - overflow: visible; -} - -.conv-nav-top { - display: flex; - justify-content: flex-end; - padding: 5px 7px 2px; -} - -.conv-nav.collapsed .conv-nav-top { - padding: 0; - height: 100%; - align-items: center; - justify-content: center; -} - -.conv-nav.collapsed .conv-nav-top .tool-btn { - width: 100%; - height: 100%; - min-height: 56px; - border-radius: 0; - border: none; - background: transparent; -} - -.conv-nav.collapsed .conv-nav-head, -.conv-nav.collapsed .conv-stats, -.conv-nav.collapsed .conv-toc { display: none; } - -.conv-nav-head { - padding: 12px 12px 5px; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--caption); -} - -.conv-stats { padding: 0 12px 6px; } - -.stat-row { - display: flex; - justify-content: space-between; - gap: 8px; - font-size: 12px; - padding: 4.5px 0; - border-bottom: 1px solid var(--border); -} - -.stat-row:last-child { border-bottom: none; } -.stat-row .k { color: var(--caption); } -.stat-row .v { font-family: var(--mono); font-size: 11.5px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; } - -.conv-toc { overflow-y: auto; flex: 1; padding: 0 7px 10px; } - -.toc-item { - font-size: 12px; - color: var(--caption); - padding: 4px 7px; - border-radius: 5px; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: all 0.1s; -} - -.toc-item:hover { background: var(--chip-bg); color: var(--fg); } - -/* ── Sheet (modal) ── */ -.overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.28); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); -} - -.sheet { - width: 580px; - max-width: 92vw; - max-height: 86vh; - overflow: hidden; /* the body scrolls, not the whole sheet */ - background: var(--bg-elev); - backdrop-filter: blur(40px); - border: 1px solid var(--window-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0,0,0,.18); - display: flex; - flex-direction: column; -} - -.sheet-head { - display: flex; - align-items: center; - gap: 8px; - padding: 14px 18px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; - background: inherit; - z-index: 2; -} - -.sheet-head h3 { - font-size: 14px; - font-weight: 600; - letter-spacing: -0.01em; -} - -.sheet-body { - padding: 18px 20px; - display: flex; - flex-direction: column; - gap: 16px; - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; -} -/* Keep each section at its natural height so the body overflows and SCROLLS, - instead of flex-compressing a child (e.g. the overflow:hidden
, - whose auto min-size becomes 0) and clipping its content unreachably. */ -.sheet-body > * { flex-shrink: 0; } - -.sheet-foot { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 18px; - border-top: 1px solid var(--border); - flex-shrink: 0; - background: inherit; - z-index: 2; -} - -.sheet-foot .spacer { flex: 1; } - -.preset-block { display: flex; flex-direction: column; gap: 7px; } - -.preset-grid { display: flex; flex-wrap: wrap; gap: 5px; } - -.preset-chip { - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 99px; - padding: 4.5px 12px; - font-size: 12px; - font-weight: 500; - color: var(--fg); - cursor: pointer; - transition: all 0.12s; - font-family: inherit; -} - -.preset-chip:hover { border-color: var(--brand); color: var(--brand); } -.preset-chip.selected { background: var(--brand); border-color: transparent; color: #fff; } - -.icon-center { display: flex; justify-content: center; padding: 2px 0; } - -.field { - display: flex; - flex-direction: column; - gap: 5px; - flex: 1; -} - -.field-label { - font-size: 11px; - font-weight: 600; - color: var(--caption); - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.field input, .input-with-btn input { - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 7px; - padding: 7.5px 11px; - color: var(--fg); - font-size: 13px; - font-family: var(--mono); - width: 100%; - outline: none; - transition: border-color 0.12s; -} - -.field-row { display: flex; gap: 10px; } -.input-with-btn { display: flex; gap: 7px; align-items: center; } -.input-with-btn input { flex: 1; } - -.mappings { - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px; - display: flex; - flex-direction: column; - gap: 7px; -} - -.mappings-details > summary { cursor: pointer; font-size: 12.5px; color: var(--muted); outline: none; padding: 11px 14px; } - -.map-rows { display: flex; flex-direction: column; gap: 5px; } - -.map-row { - display: flex; - align-items: center; - gap: 7px; -} - -.map-row input { - flex: 1; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - padding: 5.5px 8px; - color: var(--fg); - font-family: var(--mono); - font-size: 12px; - outline: none; -} - -.map-row .map-arrow { color: var(--caption); } - -.map-row .m-del { - width: 24px; - height: 24px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -/* ── Popover (tray) ── */ -.pop-body-root { - background: rgba(252, 252, 254, 0.98); - height: 100vh; - overflow: hidden; - border-radius: 13px; -} - -[data-theme="dark"] .pop-body-root { - background: rgba(22, 23, 28, 0.98); -} - -/* WKWebView draws a blue focus ring on focused buttons (Chromium didn't, so it only showed - up after the Tauri move). The popover is mouse-driven — suppress the ring. */ -.pop-body-root :focus, -.pop-body-root :focus-visible { - outline: none; -} - -/* Light theme: the pale chip-bg seg group + white active pill blur into the popover bg. - Give the group a border and the active pill a clear ring so the top row reads. */ -#popTabs, #popRanges { - border: 1px solid var(--border); -} -#popTabs .seg-btn.active, #popRanges .seg-btn.active { - box-shadow: 0 1px 2px rgba(40, 37, 30, 0.1), 0 0 0 1px var(--border-strong); -} - -/* Instant CSS tooltip for truncated metric cards (model/provider): renders just below the card - on hover — immediate, unlike the slow ~1s native title. */ -.pop-body-root [data-tip] { position: relative; } -.pop-body-root [data-tip]:hover::after { - content: attr(data-tip); - position: absolute; - right: 0; - top: calc(100% + 4px); - z-index: 30; - max-width: 280px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 10.5px; - font-weight: 500; - color: var(--fg); - background: var(--bg-elev); - border: 1px solid var(--border-strong); - border-radius: 6px; - padding: 4px 8px; - box-shadow: var(--card-shadow); - pointer-events: none; -} - -.pop { - padding: 11px 12px 0; - height: 100%; - display: flex; - flex-direction: column; - gap: 8px; - overflow: hidden; -} - -.pop-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - flex-shrink: 0; -} - -.pop-content { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} - -.pop-tab { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.pop-tab.hidden { display: none !important; } - -.seg-tabs { - display: inline-flex; - gap: 2px; - padding: 2px; - background: var(--chip-bg); - border-radius: 7px; -} - -.seg-tabs .seg-btn { - border: none; - background: transparent; - color: var(--muted); - font-size: 12px; - font-weight: 500; - padding: 4px 9px; - border-radius: 5px; - cursor: pointer; - font-family: inherit; -} - -.seg-tabs .seg-btn:hover { color: var(--fg); } -.seg-tabs .seg-btn.active { background: var(--bg-elev); color: var(--fg); box-shadow: var(--card-shadow); } - -.pop-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 5px; - flex-shrink: 0; -} - -.pstat { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 7px; - padding: 5.5px 7px; -} - -.pstat-label { font-size: 10px; font-weight: 600; text-transform: uppercase; color: var(--caption); } -.pstat-val { font-size: 15px; font-weight: 700; margin-top: 2px; letter-spacing: -0.01em; } -.pstat-val.sm { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pstat-val.mono { font-family: var(--mono); } -.pstat-val .u { font-size: 10px; color: var(--muted); font-weight: 400; } - -.heatmap-panel { - flex: 1; - min-height: 0; - display: flex; - align-items: flex-start; - padding: 4px 0 6px; - overflow: hidden; -} - -.heatmap { - --hm-size: 12px; - --hm-gap: 3px; - display: grid; - grid-template-rows: repeat(7, var(--hm-size)); - grid-auto-flow: column; - grid-auto-columns: var(--hm-size); - gap: var(--hm-gap); - /* exact square grid so the parent can never squish the rows into pills */ - height: calc(7 * var(--hm-size) + 6 * var(--hm-gap)); - align-content: start; -} - -.hm-cell { - width: var(--hm-size); - height: var(--hm-size); - aspect-ratio: 1 / 1; - border-radius: 3px; - background: #d6d1c4; /* clearly-visible warm-gray empty cell on the warm-paper panel */ - transition: background-color 0.2s ease; -} -.hm-cell.lv0 { background: #d6d1c4; } -.hm-cell.lv1 { background: rgba(204, 120, 92, 0.34); } -.hm-cell.lv2 { background: rgba(204, 120, 92, 0.55); } -.hm-cell.lv3 { background: rgba(204, 120, 92, 0.76); } -.hm-cell.lv4 { background: var(--brand); } - -[data-theme="dark"] .hm-cell { background: rgba(255, 255, 255, 0.14); } -[data-theme="dark"] .hm-cell.lv0 { background: rgba(255, 255, 255, 0.14); } -[data-theme="dark"] .hm-cell.lv1 { background: rgba(125, 122, 255, 0.32); } -[data-theme="dark"] .hm-cell.lv2 { background: rgba(125, 122, 255, 0.54); } -[data-theme="dark"] .hm-cell.lv3 { background: rgba(125, 122, 255, 0.76); } -[data-theme="dark"] .hm-cell.lv4 { background: #7d7aff; } - -/* range / tab pills never wrap (keeps "Last 7 days" etc. on one line, tidy across languages) */ -.seg-btn { white-space: nowrap; } - -/* heatmap hover tooltip — instant + styled (replaces the slow, ugly native title) */ -.hm-tip { - position: fixed; - z-index: 99999; - pointer-events: none; - padding: 5px 9px; - border-radius: 7px; - background: rgba(22, 24, 31, 0.97); - border: 1px solid rgba(255, 255, 255, 0.10); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.40); - opacity: 0; - transform: translateY(3px); - transition: opacity 0.09s ease, transform 0.09s ease; -} -.hm-tip.show { opacity: 1; transform: translateY(0); } -.hm-tip-d { font-size: 10px; font-weight: 600; color: rgba(255, 255, 255, 0.6); letter-spacing: 0.02em; } -.hm-tip-v { font-size: 12.5px; font-weight: 700; color: #fff; margin-top: 1px; white-space: nowrap; } - -.model-list { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 6px; - overflow-y: auto; - padding-bottom: 4px; -} - -.model-row { display: flex; align-items: center; gap: 8px; } -.model-name { width: 120px; font-size: 11px; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.model-bar { flex: 1; height: 5px; background: var(--chip-bg); border-radius: 3px; overflow: hidden; } -.model-bar-fill { height: 100%; background: var(--brand); } -.model-tok { width: 44px; text-align: right; font-size: 10px; color: var(--caption); } - -.pop-actions { - display: flex; - align-items: center; - gap: 5px; - flex-shrink: 0; - margin-top: auto; - padding: 8px 0 10px; - border-top: 1px solid var(--border); - background: inherit; -} - -.pop-status { - display: inline-flex; - align-items: center; - gap: 5px; - font-size: 11px; - color: var(--muted); -} - -/* ── Scrollbars ── */ -::-webkit-scrollbar { width: 7px; height: 7px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(0,0,0,.1); - border-radius: 99px; - border: 2px solid transparent; - background-clip: padding-box; -} -[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border: 2px solid transparent; background-clip: padding-box; } -::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.2); border: 2px solid transparent; background-clip: padding-box; } -[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.22); border: 2px solid transparent; background-clip: padding-box; } -/* ── Request inspector drawer (monitor) ── */ -.stream-row.clickable { cursor: pointer; } -.stream-row.clickable:hover { background: var(--chip-bg); } - -.drawer-overlay { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(40, 37, 30, 0.28); - -webkit-backdrop-filter: blur(2px); - backdrop-filter: blur(2px); - display: flex; - justify-content: flex-end; - animation: fadeIn 0.16s var(--ease); -} -.drawer { - width: min(640px, 82vw); - height: 100%; - background: var(--bg-elev); - border-left: 1px solid var(--border); - box-shadow: -12px 0 40px rgba(40, 37, 30, 0.18); - display: flex; - flex-direction: column; - animation: drawerIn 0.24s var(--ease); -} -@keyframes drawerIn { from { transform: translateX(24px); opacity: 0.4; } to { transform: none; opacity: 1; } } -.drawer-head { - display: flex; - align-items: center; - gap: 10px; - padding: 16px 16px 12px; - border-bottom: 1px solid var(--border); -} -.drawer-title { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; font-family: var(--mono); font-size: 13px; } -.dr-method { font-weight: 700; color: var(--brand); } -.dr-status { font-weight: 700; padding: 1px 8px; border-radius: 99px; font-size: 12px; } -.dr-status.ok { color: var(--green-text); background: var(--green-soft); } -.dr-status.err { color: var(--red-text); background: var(--red-soft); } -.dr-model { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dr-model .arrow { color: var(--caption); margin: 0 2px; } -.dr-model .rewrite { color: var(--amber-text); } -.drawer-meta { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--border); } -.dr-chip { font-size: 11.5px; padding: 2.5px 9px; border-radius: 99px; background: var(--chip-bg); color: var(--fg); } -.drawer-tabs { display: flex; gap: 4px; padding: 10px 16px 0; border-bottom: 1px solid var(--border); } -.dr-tab { - border: none; - background: transparent; - color: var(--muted); - font: 600 13px/1 var(--sans); - padding: 8px 14px; - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; -} -.dr-tab:hover { color: var(--fg); } -.dr-tab.active { color: var(--brand); border-bottom-color: var(--brand); } -.drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 16px 24px; } -.dr-section-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 700; - color: var(--fg); - margin: 16px 0 8px; - text-transform: uppercase; - letter-spacing: 0.03em; -} -.dr-sub { font-weight: 500; font-family: var(--mono); color: var(--caption); text-transform: none; letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dr-copy { margin-left: auto; } -.dr-kv { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } -.dr-kv-row { display: flex; gap: 10px; padding: 6px 10px; font-family: var(--mono); font-size: 12px; } -.dr-kv-row:nth-child(even) { background: var(--chip-bg); } -.dr-k { color: var(--brand); flex-shrink: 0; min-width: 150px; word-break: break-all; } -.dr-v { color: var(--fg); word-break: break-all; } -/* The global `pre` rule forces a dark code background; for the inspector we pair the - background with the ACTIVE hljs token theme so light theme = light bg + dark tokens - (readable), dark theme = dark bg + light tokens. Override needs !important to beat `pre`. */ -.dr-pre { - background: #f6f8fa !important; - color: #24292e; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 12px; - overflow-x: auto; - font-size: 12px; - line-height: 1.55; - max-height: 62vh; -} -[data-theme="dark"] .dr-pre { background: #0c0e12 !important; color: #e8edf4; border-color: rgba(255,255,255,.08); } -.dr-pre code { font-family: var(--mono); white-space: pre; color: inherit; } -.dr-empty { padding: 10px; color: var(--caption); font-size: 12.5px; } -.dr-trunc { font-size: 11.5px; color: var(--amber-text); margin-bottom: 6px; } - -/* ===== Post-restore reconciliation (where utility markup conflicts with restored CSS) ===== */ - -/* Switch knob: markup utilities set the `translate` property, which STACKS on the original - CSS `transform: translateX(15px)` → knob double-shifts (30px) and overruns onto the label. - Kill the utility translate so the original transform alone drives it. */ -.switch .track, -.switch .track::after { translate: none !important; } - -/* History-dir list: a post-migration feature the original CSS never covered, so its rows had - no spacing. Stack with a gap so they're not glued together. */ -.hist-dir-list { display: flex; flex-direction: column; gap: 10px; } -/* Light theme: white rows on a white card vanish — give them a faint fill + clearer edge */ -[data-theme="light"] .hist-dir-row { background: #f4f2ec; border-color: rgba(41, 38, 31, 0.14); } - -/* Same stacking issue for scale: these get their :active/:hover scale from the restored CSS - (transform), and the markup also carries scale utilities (the `scale` property) → they double. - Kill the utility scale only (translate is left alone, so card hover-lifts still work). */ -.nav-item, .btn, .hero-action, .fab { scale: none !important; } +/* + * ccbud styles — entry manifest. The design system is split into topic partials under + * css/ so no stylesheet exceeds the repo's 220-line module limit; @import order is the + * cascade order and must not be reshuffled. Build with `npm run build:css`. + */ +@import "./css/01.css"; +@import "./css/02.css"; +@import "./css/03.css"; +@import "./css/04.css"; +@import "./css/05.css"; +@import "./css/06.css"; +@import "./css/07.css"; +@import "./css/08.css"; +@import "./css/09.css"; +@import "./css/10.css"; +@import "./css/11.css"; +@import "./css/12.css"; +@import "./css/13.css"; +@import "./css/14.css"; +@import "./css/15.css"; +@import "./css/16.css"; +@import "./css/17.css"; +@import "./css/18.css"; +@import "./css/19.css"; +@import "./css/20.css"; +@import "./css/21.css"; +@import "./css/22.css"; +@import "./css/23.css"; +@import "./css/24.css"; diff --git a/src/renderer/js/core/bridge.js b/src/renderer/js/core/bridge.js new file mode 100644 index 0000000..6d1879f --- /dev/null +++ b/src/renderer/js/core/bridge.js @@ -0,0 +1,129 @@ +/* + * Tauri IPC bridge — the single module that talks to the backend. Exposes the same + * `window.ccbud` API object as before (the Rust self-check evals scripts that read it), + * and exports it for ES-module consumers. + * + * Backend commands are snake_case Tauri commands (see src-tauri/src/lib.rs); event names + * keep their original "namespace:event" form so the view modules' onX handlers are stable. + */ +const T = window.__TAURI__; +if (!T) console.error('[ccbud] Tauri API not found — window.__TAURI__ missing'); +const invoke = T ? T.core.invoke : () => Promise.reject(new Error('no tauri')); +const listen = T ? T.event.listen : () => {}; +const inv = (cmd, args) => invoke(cmd, args || {}); +const on = (event, cb) => { listen(event, (e) => cb(e.payload)); }; +let droppedPaths = []; + +function fileName(path) { + return String(path || '').split(/[\\/]/).filter(Boolean).pop() || ''; +} +function rememberDrop(payload) { + const paths = Array.isArray(payload && payload.paths) ? payload.paths + : Array.isArray(payload) ? payload + : []; + if (paths.length) droppedPaths = paths.map(String); +} +try { + listen('tauri://drag-drop', (e) => rememberDrop(e.payload)); + listen('tauri://file-drop', (e) => rememberDrop(e.payload)); +} catch (_) {} + +export const api = { + getConfig: () => inv('config_get'), + saveConfig: (cfg) => inv('config_save', { cfg }), + onConfigChanged: (cb) => on('config:changed', cb), + + upsertProvider: (p) => inv('provider_upsert', { p }), + deleteProvider: (id) => inv('provider_delete', { id }), + setActive: (id) => inv('provider_set_active', { id }), + testProvider: (p) => inv('provider_test', { p }), + + pluginList: () => inv('plugin_list'), + pluginStatus: (id) => inv('plugin_status', { id }), + pluginSetEnabled: (id, enabled) => inv('plugin_set_enabled', { id, enabled }), + pluginAction: (id, action, values) => inv('plugin_action', { id, action, values: values || {} }), + pluginActionLoad: (id, action) => inv('plugin_action_load', { id, action }), + pluginInstall: (title) => inv('plugin_install', { title }), + pluginUninstall: (id) => inv('plugin_uninstall', { id }), + pluginOpenDir: () => inv('plugin_open_dir'), + pluginInstallGit: (url) => inv('plugin_install_git', { url }), + pluginCheckUpdate: (id) => inv('plugin_check_update', { id }), + pluginUpdate: (id) => inv('plugin_update', { id }), + + connect: () => inv('claude_connect'), + disconnect: () => inv('claude_disconnect'), + setConnectTarget: (target, on) => inv('set_connect_target', { target, on }), + + desktopReplay: (file, prompt) => inv('desktop_replay', { file, prompt }), + chatgptReplay: (file, prompt) => inv('chatgpt_replay', { file, prompt }), + + serverStatus: () => inv('server_status'), + usageGet: (range) => inv('usage_get', { range }), + + monitorGet: (id) => inv('monitor_get', { id }), + gatewaySetEnabled: (on) => inv('gateway_set_enabled', { on }), + monitorClear: () => inv('monitor_clear'), + logsGet: () => inv('logs_get'), + logsClear: () => inv('logs_clear'), + + openMain: () => inv('app_open_main'), + quitApp: () => inv('app_quit'), + setSettingsMode: (on) => inv('window_settings_mode', { on }), + setViewMinWidth: (w) => inv('window_view_min_width', { w }), + + historyProjects: () => inv('history_projects'), + historyList: () => inv('history_list'), + historyGet: (file) => inv('history_get', { file }), + historySearch: (query) => inv('history_search', { query }), + historyDirs: () => inv('history_dirs'), + historyPickDir: () => inv('history_pick_dir'), + historySetActive: (id) => inv('history_set_active', { id }), + historyImport: () => inv('history_import'), + historyImportPaths: (paths) => inv('history_import_paths', { paths }), + historyRemoveImport: (file) => inv('history_remove_import', { file }), + historySetMeta: (file, patch) => inv('history_set_meta', { file, patch }), + historyDeleteForever: (file) => inv('history_delete_forever', { file }), + historyExportRaw: (file) => inv('history_export_raw', { file }), + historyExportHtml: (payload) => inv('history_export_html', { payload }), + pathForFile: (file) => { + const name = file && file.name; + if (!name) return ''; + return droppedPaths.find((p) => fileName(p) === name) || ''; + }, + onHistoryChanged: (cb) => on('history:changed', cb), + + copy: (t) => inv('util_copy', { text: t }), + openExternal: (u) => inv('util_open_external', { url: u }), + + updateState: () => inv('update_state'), + updateCheck: () => inv('update_check'), + updateDownload: () => inv('update_download'), + updateApply: () => inv('update_apply'), + updateSetAuto: (patch) => inv('update_set_auto', { patch }), + onUpdateState: (cb) => on('update:state', cb), + onUpdateStaged: (cb) => on('update:staged', cb), + onUpdateOpenPane: (cb) => on('update:openPane', cb), + + onLog: (cb) => on('gateway:log', cb), + onRequest: (cb) => on('gateway:request', cb), + onStatus: (cb) => on('gateway:status', cb), + onPopoverShow: (cb) => on('popover:show', cb), +}; +// The Rust self-check (CCBUD_SELFCHECK) evals scripts against window.ccbud — keep the global. +window.ccbud = api; + +// Window dragging: map `.drag-region` elements onto Tauri's `data-tauri-drag-region` +// (its bundled handler starts a window drag on mousedown over an element carrying that attr; +// `.no-drag` children like buttons/inputs are untouched since the event target is the child). +function wireDrag(root) { + (root || document).querySelectorAll('.drag-region').forEach((el) => { + el.setAttribute('data-tauri-drag-region', ''); + }); +} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => wireDrag()); +else wireDrag(); +// Re-apply for any drag bars the views inject after first paint (lazy view mounts, etc.). +try { + const mo = new MutationObserver(() => wireDrag()); + mo.observe(document.documentElement, { childList: true, subtree: true }); +} catch (_) {} diff --git a/src/renderer/js/core/dom.js b/src/renderer/js/core/dom.js new file mode 100644 index 0000000..fb73503 --- /dev/null +++ b/src/renderer/js/core/dom.js @@ -0,0 +1,76 @@ +/* Tiny DOM + formatting primitives shared by every view module. */ +import { icons } from './icons.js'; + +export const $ = (id) => document.getElementById(id); + +export function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +export function show(el, on) { if (el) el.classList.toggle('hidden', !on); } + +/** Fill every [data-icon] slot under root from the shared SVG icon set. */ +export function injectIcons(root) { + (root || document).querySelectorAll('[data-icon]').forEach((el) => { + const name = el.dataset.icon; + if (icons[name]) el.innerHTML = icons[name]; + }); +} + +// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays +// consistent across language switches and the timestamp never wraps with a 12-hour "PM" suffix. +export function fmtTime(d) { + const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d)); + const p = (n) => String(n).padStart(2, '0'); + return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`; +} + +export function fmtNum(n) { + n = n || 0; + if (n < 1000) return String(n); + if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; + if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '') + 'M'; + return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; +} + +export function fmtBytes(n) { + n = n || 0; + if (n < 1024) return n + ' B'; + if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1048576).toFixed(2) + ' MB'; +} + +export function hashHue(s) { + let h = 0; + for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; + return h; +} + +// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio="none". +export function sparkSVG(vals) { + const W = 300, H = 46, pad = 4; + const data = (vals && vals.length) ? vals : [0, 0]; + const n = data.length; + const max = Math.max(1, ...data); + const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad)); + const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2); + const pts = data.map((v, i) => [xs(i), ys(v)]); + let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; + const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; + const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; + line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`; + } + const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`; + return ``; +} + +/** Copy-with-feedback for small "复制" buttons (restores the original label after 1.5s). */ +export function copyFeedback(btn, text, copiedLabel, copyFn) { + const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent); + copyFn(text); + btn.textContent = copiedLabel; + clearTimeout(btn._t); + btn._t = setTimeout(() => (btn.textContent = orig), 1500); +} diff --git a/src/renderer/js/core/i18n.js b/src/renderer/js/core/i18n.js new file mode 100644 index 0000000..38f087a --- /dev/null +++ b/src/renderer/js/core/i18n.js @@ -0,0 +1,84 @@ +/* + * window.I18n — renderer-side i18n runtime. The dictionary is split per language and + * domain (src/shared/i18n/*, copied to shared/i18n/ at build time); ONLY the active + * language (+ the `en` fallback) is loaded at startup — a fifth of the old single-file + * dictionary on the cold-start path. All supported locales are LTR — there is NO RTL + * handling here on purpose; adding Arabic/Hebrew later must be a deliberate change. + */ +import { loadScript } from './loader.js'; + +const P = () => window.ccbudI18nParts || { LANGS: ['en'], LOCALE_TAG: { en: 'en-US' }, PARTS: { en: [] } }; +let lang = 'en'; +const loaded = new Set(); + +async function ensureParts() { + if (!window.ccbudI18nParts) await loadScript('shared/i18n/parts.js'); +} +async function ensureLang(l) { + await ensureParts(); + if (loaded.has(l) || !P().PARTS[l]) return; + await Promise.all(P().PARTS[l].map((f) => loadScript('shared/i18n/' + f))); + loaded.add(l); +} + +function dict() { + const langs = window.ccbudI18nLangs || {}; + return langs[lang] || langs.en || {}; +} +function enDict() { + return (window.ccbudI18nLangs || {}).en || {}; +} + +function fill(s, params) { + if (!params) return s; + return s.replace(/\{(\w+)\}/g, (_, k) => (params[k] != null ? params[k] : '{' + k + '}')); +} + +function t(key, params) { + let s = dict()[key]; + if (s == null) s = enDict()[key] != null ? enDict()[key] : key; // fallback: lang → en → key + return fill(s, params); +} + +function apply(root) { + root = root || document; + root.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.getAttribute('data-i18n')); }); + root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder'))); }); + root.querySelectorAll('[data-i18n-title]').forEach((el) => { + const v = t(el.getAttribute('data-i18n-title')); + el.setAttribute('title', v); + el.setAttribute('aria-label', v); + }); +} + +/** Switch the UI language, loading its dictionary parts on demand. */ +async function setLang(l) { + await ensureParts(); + lang = P().LANGS.indexOf(l) >= 0 ? l : 'en'; + await Promise.all([ensureLang('en'), ensureLang(lang)]); + try { document.documentElement.setAttribute('lang', I18n.localeTag); } catch (_) {} + try { localStorage.setItem('ccbud-lang', lang); } catch (_) {} +} + +/** The boot language: persisted choice, else the OS locale mapped onto a supported one. */ +export function detectLang() { + let l = ''; + try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {} + if (!l) { + const nav = (navigator.language || 'en').toLowerCase(); + l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh') + : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en'; + } + return l; +} + +export const I18n = { + t, + apply, + setLang, + has: (key) => dict()[key] != null || enDict()[key] != null, + get lang() { return lang; }, + get localeTag() { return P().LOCALE_TAG[lang] || 'en-US'; }, +}; +// Compat global — the Rust self-check and any not-yet-migrated inline consumers read window.I18n. +window.I18n = I18n; diff --git a/src/renderer/icons.js b/src/renderer/js/core/icons.js similarity index 93% rename from src/renderer/icons.js rename to src/renderer/js/core/icons.js index f4d4f91..84fd274 100644 --- a/src/renderer/icons.js +++ b/src/renderer/js/core/icons.js @@ -1,7 +1,5 @@ -'use strict'; - -/* ccbud icon system — SF Symbols–style SVG primitives */ -window.ccbudIcons = { +/* ccbud icon system — SF Symbols–style SVG primitives. */ +export const icons = { logo(size = 28) { return `
+

${escapeHtml(title || '')}

+

${escapeHtml(message || '')}

+
+ + +
+
`; + document.body.appendChild(ov); + const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); }; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } }; + ov.querySelector('.cd-ok').addEventListener('click', () => done(true)); + ov.querySelector('.cd-cancel').addEventListener('click', () => done(false)); + ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); }); + document.addEventListener('keydown', onKey); + setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0); + }); +} +// Compat global (the conversations view historically reached it via window.confirmDialog). +window.confirmDialog = confirmDialog; diff --git a/src/renderer/js/main.js b/src/renderer/js/main.js new file mode 100644 index 0000000..93c1c08 --- /dev/null +++ b/src/renderer/js/main.js @@ -0,0 +1,85 @@ +/* + * App entry point. Cold-start contract: this module and the core modules it imports are the + * ONLY JS parsed before first paint. Everything else — the other four views, their markup, the + * markdown/highlight vendor bundles, and all but the active language's dictionary — loads on + * demand (see core/loader.js, views/registry.js). + */ +import './core/bridge.js'; +import { $, injectIcons } from './core/dom.js'; +import { icons } from './core/icons.js'; +import { I18n, detectLang } from './core/i18n.js'; +import { idlePrefetch, ensureVendor } from './core/loader.js'; +import { toggleTheme } from './core/theme.js'; +import { refresh, bindBackendEvents, pushLocalLog, state } from './core/state.js'; +import { api } from './core/bridge.js'; +import { switchView, registerView, prefetchView } from './views/registry.js'; +import providersView from './views/providers/index.js'; +import { initMonitorFeed } from './views/monitor/feed.js'; +import { applyConvFont } from './views/settings/conv-font.js'; + +/** Sidebar chrome shared by every view: nav, theme, collapse. */ +function bindShell() { + if ($('appLogo') && icons.logo) $('appLogo').innerHTML = icons.logo(30); + injectIcons(); + + $('tabs').addEventListener('click', (e) => { + const btn = e.target.closest('.nav-item, .seg-btn'); + if (btn && btn.dataset.view) switchView(btn.dataset.view); + }); + $('btnTheme').addEventListener('click', toggleTheme); + + // Main sidebar collapse (affects all views) + const sidebar = document.querySelector('.sidebar'); + const collapseBtn = $('btnCollapseSidebar'); + if (collapseBtn && sidebar) { + try { + if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') { + sidebar.classList.add('collapsed'); + const icon = collapseBtn.querySelector('[data-icon]'); + if (icon && icons.chevronRight) icon.innerHTML = icons.chevronRight; + } + } catch (_) {} + collapseBtn.addEventListener('click', () => { + const isCollapsed = sidebar.classList.toggle('collapsed'); + const icon = collapseBtn.querySelector('[data-icon]'); + if (icon) icon.innerHTML = isCollapsed ? (icons.chevronRight || '›') : (icons.chevronLeft || '‹'); + try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {} + }); + } +} + +/** Update events are subscribed at BOOT, not at Settings mount, so a staged hot update is + still recorded (and the tray's "检查更新" still works) before that view is ever opened. */ +function bindUpdateEvents() { + if (api.onUpdateStaged) api.onUpdateStaged(() => pushLocalLog({ level: 'info', msg: I18n.t('about.stagedLog') })); + if (api.onUpdateOpenPane) api.onUpdateOpenPane(async () => { + await switchView('settings'); + const settings = (await import('./views/settings/index.js')).default; + settings.openAboutAndCheck(); + }); +} + +async function boot() { + // Language first: the dictionary parts for the active locale must be in place before any + // view renders a translated string. theme-boot.js already stamped theme + . + await I18n.setLang(detectLang()); + I18n.apply(document); + + bindShell(); + registerView('providers', providersView); + providersView.mount(); + // Gateway traffic counts from launch, even before the 监控 view is first opened. + initMonitorFeed(); + bindBackendEvents(); + bindUpdateEvents(); + + await refresh(); + applyConvFont(); // config-driven --conv-fs, so 会话 is correct the first time it opens + + // Warm the two heaviest lazy paths while the app is idle, so the first click feels instant. + idlePrefetch(() => { ensureVendor().catch(() => {}); }); + idlePrefetch(() => prefetchView('conversations'), 6000); +} + +boot(); +export { state }; diff --git a/src/renderer/js/popover/heatmap.js b/src/renderer/js/popover/heatmap.js new file mode 100644 index 0000000..a83f66a --- /dev/null +++ b/src/renderer/js/popover/heatmap.js @@ -0,0 +1,67 @@ +/* Popover activity heatmap + its instant styled tooltip. */ +import { api } from '../core/bridge.js'; +import { I18n } from '../core/i18n.js'; +import { fmtNum } from '../core/dom.js'; + +const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +function fmtDate(d) { + try { + const dt = new Date(`${d}T00:00:00`); + if (isNaN(dt)) return d; + return dt.toLocaleDateString(I18n.localeTag, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch (_) { return d; } +} + +// Instant, styled heatmap tooltip (replaces the slow/ugly native title). +let _tip = null; +function showHeatTip(cell) { + if (!_tip) { _tip = document.createElement('div'); _tip.className = 'hm-tip'; document.body.appendChild(_tip); } + _tip.innerHTML = `
${esc(cell.dataset.date || '')}
${esc(cell.dataset.val || '')}
`; + _tip.classList.add('show'); + const r = cell.getBoundingClientRect(); + const tw = _tip.offsetWidth, th = _tip.offsetHeight; + let x = Math.max(6, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 6)); + let y = r.top - th - 7; + if (y < 4) y = r.bottom + 7; + _tip.style.left = `${Math.round(x)}px`; + _tip.style.top = `${Math.round(y)}px`; +} +function hideHeatTip() { if (_tip) _tip.classList.remove('show'); } + +const LEVEL_BGS = { + 0: 'bg-[#c6ccd8] dark:bg-white/14', + 1: 'bg-[#5856d6]/34 dark:bg-[#7d7aff]/32', + 2: 'bg-[#5856d6]/55 dark:bg-[#7d7aff]/54', + 3: 'bg-[#5856d6]/76 dark:bg-[#7d7aff]/76', + 4: 'bg-brand dark:bg-[#7d7aff]', +}; + +export async function renderHeatmap() { + let u; + try { + u = await api.usageGet('all'); + } catch (e) { + console.error('usageGet(all) failed', e); + u = { heatmap: [] }; + } + const hm = document.getElementById('heatmap'); + if (!hm) return; + hm.innerHTML = ''; + if (u && u.heatmap) { + for (const c of u.heatmap) { + const cell = document.createElement('div'); + cell.className = `hm-cell lv${c.level} rounded-[3px] transition-colors duration-200 ${LEVEL_BGS[c.level] || LEVEL_BGS[0]}`; + cell.dataset.date = fmtDate(c.date); + cell.dataset.val = `${fmtNum(c.tokens)} ${I18n.t('pop.tokensUnit')}`; + hm.appendChild(cell); + } + } +} + +export function bindHeatmap() { + const hm = document.getElementById('heatmap'); + if (!hm) return; + hm.addEventListener('mouseover', (e) => { const c = e.target.closest('.hm-cell'); if (c) showHeatTip(c); }); + hm.addEventListener('mouseleave', hideHeatTip); +} diff --git a/src/renderer/js/popover/main.js b/src/renderer/js/popover/main.js new file mode 100644 index 0000000..bdb9ed1 --- /dev/null +++ b/src/renderer/js/popover/main.js @@ -0,0 +1,65 @@ +/* Popover window entry point (tray usage panel). Same lazy-i18n contract as the main window. */ +import '../core/bridge.js'; +import { $ } from '../core/dom.js'; +import { api } from '../core/bridge.js'; +import { I18n, detectLang } from '../core/i18n.js'; +import { applyTheme } from '../core/theme.js'; +import { renderHeatmap, bindHeatmap } from './heatmap.js'; +import { renderStats, renderStatus } from './stats.js'; + +let range = '7d'; +let heatmapReady = false; + +async function render() { + if (!heatmapReady) { await renderHeatmap(); heatmapReady = true; } + await renderStats(range); +} + +function setTab(t) { + document.querySelectorAll('#popTabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.tab === t)); + $('tab-overview').classList.toggle('hidden', t !== 'overview'); + $('tab-models').classList.toggle('hidden', t !== 'models'); +} +function setRange(r) { + range = r; + document.querySelectorAll('#popRanges .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.range === r)); + renderStats(range); +} + +function bind() { + $('popTabs').addEventListener('click', (e) => { if (e.target.dataset.tab) setTab(e.target.dataset.tab); }); + $('popRanges').addEventListener('click', (e) => { if (e.target.dataset.range) setRange(e.target.dataset.range); }); + $('popConnect').addEventListener('click', async (e) => { + e.target.disabled = true; + try { await api.gatewaySetEnabled(!e.target.dataset.running); } catch (_) {} + e.target.disabled = false; + renderStatus(); + }); + $('popOpen').addEventListener('click', () => api.openMain()); + $('popQuit').addEventListener('click', () => api.quitApp()); + bindHeatmap(); + if (api.onPopoverShow) { + api.onPopoverShow(async () => { + // The popover is a separate window: re-read theme + language from shared localStorage + // (written by the main window) on every show, so a change propagates on next open. + applyTheme(localStorage.getItem('ccbud-theme') || 'light'); + await applyLang(); + heatmapReady = false; + await render(); + renderStatus(); + }); + } +} + +async function applyLang() { + await I18n.setLang(detectLang()); + I18n.apply(document); +} + +(async () => { + try { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); } + await applyLang(); + bind(); + await render(); + renderStatus(); +})(); diff --git a/src/renderer/js/popover/stats.js b/src/renderer/js/popover/stats.js new file mode 100644 index 0000000..d8877b5 --- /dev/null +++ b/src/renderer/js/popover/stats.js @@ -0,0 +1,64 @@ +/* Popover overview tiles + per-model usage bars. */ +import { api } from '../core/bridge.js'; +import { I18n } from '../core/i18n.js'; +import { $, fmtNum, escapeHtml } from '../core/dom.js'; + +function hourLabel(h) { + if (h == null) return '—'; + try { return new Date(2000, 0, 1, h).toLocaleTimeString(I18n.localeTag, { hour: 'numeric' }); } + catch (_) { const ap = h < 12 ? 'AM' : 'PM'; const hh = h % 12 === 0 ? 12 : h % 12; return `${hh} ${ap}`; } +} + +/** Set a tile's text and mirror the full value into the parent's tooltip when truncated. */ +function setTile(id, value, full) { + const el = $(id); + if (!el) return; + el.textContent = value; + if (el.parentElement) { + if (full) el.parentElement.setAttribute('data-tip', full); + else el.parentElement.removeAttribute('data-tip'); + } +} + +export async function renderStats(range) { + let u = null; + try { u = await api.usageGet(range); } catch (e) { console.error('usageGet failed', e); } + if (!u) { + // a failed scan must LOOK failed — zeros would read as "no usage" + $('sTokens').textContent = '—'; + $('sReq').textContent = '—'; + return; + } + $('sTokens').textContent = fmtNum(u.tokens); + $('sReq').textContent = (u.requests || 0).toLocaleString(); + $('sDays').textContent = u.activeDays || 0; + setTile('sProv', u.favoriteProvider || '—', u.favoriteProvider && u.favoriteProvider !== '—' ? u.favoriteProvider : ''); + $('sCur').innerHTML = `${u.currentStreak || 0}${escapeHtml(I18n.t('time.unitDay'))}`; + $('sLong').innerHTML = `${u.longestStreak || 0}${escapeHtml(I18n.t('time.unitDay'))}`; + $('sPeak').textContent = u.peakHour == null ? '—' : hourLabel(u.peakHour); + setTile('sModel', u.favoriteModel || '—', u.favoriteModel && u.favoriteModel !== '—' ? u.favoriteModel : ''); + + const ml = $('modelList'); + if (!ml) return; + ml.innerHTML = ''; + const byModel = u.byModel || []; + if (!byModel.length) ml.innerHTML = `
${escapeHtml(I18n.t('pop.noData'))}
`; + for (const m of byModel.slice(0, 12)) { + const row = document.createElement('div'); + row.className = 'model-row flex items-center gap-2'; + row.innerHTML = ` +
${escapeHtml(m.model)}
+
+
${fmtNum(m.tokens)}
`; + ml.appendChild(row); + } +} + +export async function renderStatus() { + const s = await api.serverStatus(); + const dot = $('popStatus').querySelector('.pulse-dot, .live-dot'); + dot.className = 'pulse-dot w-1.75 h-1.75 rounded-full shrink-0 ' + (s.running ? 'on bg-green animate-[pulse_2s_infinite]' : 'off bg-muted'); + $('popStatusText').textContent = s.running ? I18n.t('status.gwRunning') : I18n.t('status.gwStopped'); + $('popConnect').textContent = s.running ? I18n.t('pop.svcStop') : I18n.t('pop.svcStart'); + $('popConnect').dataset.running = s.running ? '1' : ''; +} diff --git a/src/renderer/js/views/conversations/actions.js b/src/renderer/js/views/conversations/actions.js new file mode 100644 index 0000000..e96d839 --- /dev/null +++ b/src/renderer/js/views/conversations/actions.js @@ -0,0 +1,130 @@ +/* Toolbar actions: copy path, Claude/ChatGPT replay, export, import, toast. */ +import { $ } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { L } from './format.js'; +import { cs } from './state.js'; + +export function toast(msg, ok) { + let t = document.querySelector('.conv-toast'); + if (!t) { t = document.createElement('div'); t.className = 'conv-toast'; t.setAttribute('data-clarity-mask', 'true'); document.body.appendChild(t); } + t.textContent = msg; + t.classList.toggle('err', ok === false); + t.classList.add('show'); + clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 2200); +} + +export function hideExportMenu() { const m = $('convExportMenu'); if (m) m.classList.add('hidden'); } + +// Absolute .jsonl path for the session currently in the panel — the active subagent's file when +// one is selected, else the main session file. Used by the "copy path" button so a transcript can +// be handed to another Claude Code session for replay / agent debugging. +function currentJsonlPath() { + if (cs.activeAgent !== 'main' && cs.currentDetail && cs.currentDetail.subagents) { + const s = cs.currentDetail.subagents[cs.activeAgent]; + if (s && s.file) return s.file; + } + return cs.openFile; +} + +export function doCopyPath() { + const p = currentJsonlPath(); + if (!p) return; + try { api.copy(p); } catch (_) {} + toast(L('conv.pathCopied')); +} + +export async function doReplay(btn) { + const p = currentJsonlPath(); + if (!p || !api.desktopReplay) return; + if (btn) btn.disabled = true; + toast(L('conv.replayOpening')); + let res; + const prompt = L('desktop.replayPrompt').slice(0, 13000); // q is truncated ~14k by Claude + try { res = await api.desktopReplay(p, prompt); } catch (e) { res = { ok: false, reason: 'failed' }; } + if (btn) btn.disabled = false; + if (res && res.ok) return; // Claude Desktop now opening with the file + prompt + const reason = res && res.reason; + toast( + reason === 'notInstalled' ? L('conv.replayNoApp') + : reason === 'unsupported' ? L('conv.replayUnsupported') + : reason === 'permission' ? L('conv.replayPermission') + : reason === 'cancelled' ? L('conv.replayOpening') + : L('conv.replayFail'), + false + ); +} + +// Same shape as doReplay, but for the ChatGPT desktop app: the backend opens a +// codex://new deep link with the review prompt and the transcripts' directory as +// the workspace, so the task can read the JSONL files listed in the prompt. +export async function doChatgpt(btn) { + const p = currentJsonlPath(); + if (!p || !api.chatgptReplay) return; + if (btn) btn.disabled = true; + toast(L('conv.chatgptOpening')); + let res; + const prompt = L('desktop.chatgptPrompt').slice(0, 13000); + try { res = await api.chatgptReplay(p, prompt); } catch (e) { res = { ok: false, reason: 'failed' }; } + if (btn) btn.disabled = false; + if (res && res.ok) return; // ChatGPT now opening with the prompt + workspace + const reason = res && res.reason; + toast( + reason === 'notInstalled' ? L('conv.chatgptNoApp') + : reason === 'unsupported' ? L('conv.replayUnsupported') + : L('conv.chatgptFail'), + false + ); +} + +// HTML export is built backend-side (exporthtml.rs): it needs fs access to the on-disk subagent +// dialogues and emits a self-contained, Claude-styled viewer app. +export async function doExport(kind) { + hideExportMenu(); + if (!cs.openFile) return; + try { + if (kind === 'jsonl') { + const r = await api.historyExportRaw(cs.openFile); + if (r && r.canceled) return; + // A session with subagents comes back as a .zip bundle (r.bundled) — say so, so the .zip + // (rather than the expected .jsonl) isn't a surprise. + if (r && r.path) toast(L(r.bundled ? 'conv.exportOkZip' : 'conv.exportOk')); + else toast(L('conv.exportFail'), false); + } else if (kind === 'html') { + const r = await api.historyExportHtml(cs.openFile); + if (r && r.canceled) return; + toast(r && r.path ? L('conv.exportOk') : L('conv.exportFail'), !!(r && r.path)); + } + } catch (_) { toast(L('conv.exportFail'), false); } +} + +/* r = { imported, skipped, failed } | { canceled } from history_import / history_import_paths. + Toast a summary, jump to the imports dir on success, refresh the list. */ +export async function applyImportResult(r, refresh) { + if (!r || r.canceled) return; + if (!r.imported) { + toast(r.skipped ? L('conv.importSkip', { n: r.skipped }) : L('conv.importNone'), r.failed ? false : undefined); + } else { + const parts = [L('conv.importDone', { n: r.imported })]; + if (r.skipped) parts.push(L('conv.importSkip', { n: r.skipped })); + if (r.failed) parts.push(L('conv.importFail', { n: r.failed })); + toast(parts.join(' · ')); + try { if (api.historySetActive) await api.historySetActive('__imported__'); } catch (_) {} + } + await refresh(); +} + +// Collapse the action buttons into a "⋯" menu when the toolbar is too narrow to fit them +// alongside a 200px-min search box. +export function updateToolbarLayout() { + const tb = document.querySelector('.conv-detail-toolbar'); + const actions = $('convActions'); + const moreWrap = $('convMoreWrap'); + if (!tb || !actions || !moreWrap) return; + actions.classList.remove('hidden'); + moreWrap.classList.add('hidden'); + hideExportMenu(); + if (tb.scrollWidth > tb.clientWidth + 1) { + actions.classList.add('hidden'); + moreWrap.classList.remove('hidden'); + } +} diff --git a/src/renderer/js/views/conversations/agents.js b/src/renderer/js/views/conversations/agents.js new file mode 100644 index 0000000..6039d90 --- /dev/null +++ b/src/renderer/js/views/conversations/agents.js @@ -0,0 +1,62 @@ +/* Moving the main panel between threads (main ↔ subagent) and focusing a subagent at its call site. */ +import { $ } from '../../core/dom.js'; +import { cs, activeMessages, DETAIL_WIN } from './state.js'; +import { paintWindow, jumpToMessage } from './window.js'; +import { renderAgentTabs, renderSidePanels } from './panels.js'; +import { clearDetailSearchHighlights } from './search.js'; +import { subChain, expandChain } from './subagents.js'; + +// Move the panel to another thread KEEPING search state — used by cross-agent search navigation +// and the big-search auto-locate, where the jump that follows paints the window. Resets the window +// to "unpainted" so the follow-up jumpToMessage always renders fresh. +export function setPanelAgent(key) { + if (!cs.currentDetail || key === cs.activeAgent) return; + cs.agentMenuOpen = false; + cs.activeAgent = key; + cs.vStart = 0; cs.vEnd = 0; + renderAgentTabs(cs.currentDetail); + renderSidePanels(cs.currentDetail); +} + +// User-driven move of the main panel to a different session (main thread or a subagent). Resets +// the render window + search and repaints from the bottom, exactly like opening a fresh conversation. +export function switchAgent(key) { + cs.agentMenuOpen = false; + if (key === cs.activeAgent) { renderAgentTabs(cs.currentDetail); return; } + clearDetailSearchHighlights(); + const ds = $('convDetailSearch'); if (ds) ds.value = ''; + setPanelAgent(key); + const total = activeMessages().length; + cs.vEnd = total; cs.vStart = Math.max(0, total - DETAIL_WIN); + paintWindow(); + const host = $('convDetail'); if (host) host.scrollTop = host.scrollHeight; +} + +// Bring a subagent into view AT ITS CALL SITE: jump the main thread to the outermost spawning turn, +// then expand each disclosure down the chain (filling lazily) and scroll/flash the target. Falls back +// to the standalone full-panel view when the call site is unknown, so orphan subagents stay reachable. +export function focusSubagent(key) { + cs.agentMenuOpen = false; + const menu = document.querySelector('#convAgentTabs .conv-agent-menu'); + if (menu) menu.classList.add('hidden'); // close the picker immediately as click feedback + if (!cs.currentDetail || !(cs.currentDetail.subagents || {})[key]) return; + const chain = subChain(key); + if (!chain.length) { switchAgent(key); return; } // call site unknown → standalone full-panel view + if (cs.activeAgent !== 'main') setPanelAgent('main'); // search docs span all threads — no rebuild + const top = cs.subIndex.callSite.get(chain[0]); // { thread:'main', mi } + jumpToMessage(top.mi, 'center'); + const det = expandChain(chain); + renderAgentTabs(cs.currentDetail); + if (!det) { switchAgent(key); return; } // couldn't place it inline → don't leave the click doing nothing + // Land on the spawning CALL (the tool card), not the middle of the now-tall subagent body, so the + // "why did this subagent appear" context reads top-down. Flash the whole block so it's unmistakable. + const anchor = det.closest('.tool-card') || det; + anchor.scrollIntoView({ block: 'start' }); + const host = $('convDetail'); + if (host) host.scrollTop = Math.max(0, host.scrollTop - 48); + det.classList.remove('sub-flash'); + requestAnimationFrame(() => { + det.classList.add('sub-flash'); + setTimeout(() => det.classList.remove('sub-flash'), 2200); + }); +} diff --git a/src/renderer/js/views/conversations/code.js b/src/renderer/js/views/conversations/code.js new file mode 100644 index 0000000..1c1ed3d --- /dev/null +++ b/src/renderer/js/views/conversations/code.js @@ -0,0 +1,78 @@ +/* Code block rendering: language mapping, gutters, highlight.js application, markdown docs. */ +import { esc, md, truncate } from './format.js'; +import { L } from './format.js'; + +// File extension → highlight.js language id (so code blocks get language-specific highlighting). +export const EXT_LANG = { + js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', + py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', scala: 'scala', swift: 'swift', + c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', cs: 'csharp', m: 'objectivec', mm: 'objectivec', + php: 'php', pl: 'perl', lua: 'lua', r: 'r', dart: 'dart', ex: 'elixir', exs: 'elixir', erl: 'erlang', clj: 'clojure', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell', + json: 'json', jsonc: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini', ini: 'ini', conf: 'ini', env: 'ini', + html: 'xml', htm: 'xml', xml: 'xml', svg: 'xml', vue: 'xml', xhtml: 'xml', + css: 'css', scss: 'scss', sass: 'scss', less: 'less', styl: 'stylus', + md: 'markdown', markdown: 'markdown', sql: 'sql', graphql: 'graphql', gql: 'graphql', proto: 'protobuf', + tf: 'terraform', tsv: 'plaintext', csv: 'plaintext', +}; + +export function langFromPath(p) { + if (!p) return ''; + const base = String(p).split(/[\\/]/).pop().toLowerCase(); + if (base === 'dockerfile') return 'dockerfile'; + if (base === 'makefile' || base === 'gnumakefile') return 'makefile'; + const dot = base.lastIndexOf('.'); + return (dot >= 0 ? EXT_LANG[base.slice(dot + 1)] : '') || ''; +} + +// Strip `cat -n` prefixes ("␠␠␠12\t…", as Claude Code's Read returns) so we render our own gutter. +export function stripCatN(text) { + return /^\s*\d+\t/.test(text) ? text.replace(/^\s*\d+\t/gm, '') : text; +} + +// A styled code block. lang='' → plain (no syntax highlight, no gutter). highlight()+gutter are +// applied after insertion (see highlight()). Shared by tool cards and message rendering. +export function codeBlock(text, lang) { + const cls = lang ? 'language-' + esc(lang) : 'nohljs'; + return `
${esc(text)}
`; +} +export function codePre(text, lang) { return codeBlock(truncate(text, 12000), lang || ''); } + +// Markdown file: rendered preview (default) ↔ highlighted source, toggled by tabs. marked renders the +// preview; highlight() lights up code blocks inside both panes (source is highlighted even while hidden). +export function mdDoc(text) { + return '
' + + `
` + + `
${md(text)}
` + + `` + + '
'; +} +export const isMdPath = (p) => langFromPath(p) === 'markdown'; + +// Add a GitHub-style line-number gutter to a code block (after highlighting, so token spans are +// intact). Skipped for plain blocks (terminal/JSON output) and once already applied. +function addGutter(pre) { + if (!pre || pre.dataset.gutter || pre.classList.contains('cb-plain')) return; + const code = pre.querySelector('code'); + if (!code) return; + let n = (code.textContent || '').replace(/\n+$/, '').split('\n').length; + if (n < 1) n = 1; + let s = ''; + for (let i = 1; i <= n; i++) s += i + (i < n ? '\n' : ''); + const g = document.createElement('span'); + g.className = 'cb-gutter'; + g.setAttribute('aria-hidden', 'true'); + g.textContent = s; + pre.insertBefore(g, code); + pre.classList.add('cb-has-gutter'); + pre.dataset.gutter = '1'; +} + +export function highlight(root) { + if (!window.hljs) return; + root.querySelectorAll('pre code').forEach((code) => { + if (code.classList.contains('nohljs')) return; // plain output (terminal / JSON) — no highlight, no gutter + if (!code.dataset.highlighted) { try { window.hljs.highlightElement(code); } catch (_) {} } + addGutter(code.parentElement); // GitHub-style line-number gutter + }); +} diff --git a/src/renderer/js/views/conversations/codex-format.js b/src/renderer/js/views/conversations/codex-format.js new file mode 100644 index 0000000..37a720e --- /dev/null +++ b/src/renderer/js/views/conversations/codex-format.js @@ -0,0 +1,84 @@ +/* Harness-injected content normalization for user turns (Codex bootstrap, reminders, commands). */ + +// Codex records its initial AGENTS instructions and environment snapshot as two text blocks in +// one user message. Turn that XML-ish transport shape into compact Markdown for the transcript. +export function formatCodexBootstrap(text) { + const source = String(text || ''); + const agents = /^\s*#\s+AGENTS\.md instructions for ([^\r\n]+)[\s\S]*?]*>([\s\S]*?)<\/INSTRUCTIONS>/i.exec(source); + if (!agents) return null; + + const env = /]*>([\s\S]*?)<\/environment_context>/i.exec(source); + const parts = ['# AGENTS.md instructions for ' + agents[1].trim()]; + const instructions = agents[2].trim(); + if (instructions) { + const lines = instructions.split(/\r?\n/).filter((line) => line.trim()); + parts.push(lines.length === 1 + ? '**INSTRUCTIONS:** ' + lines[0].trim() + : '**INSTRUCTIONS:**\n\n' + instructions); + } + + if (env) { + const block = env[1]; + const tag = (name) => { + const match = new RegExp('<' + name + '\\b[^>]*>([\\s\\S]*?)<\\/' + name + '>', 'i').exec(block); + return match ? match[1].trim() : ''; + }; + const attr = (name, attribute) => { + const match = new RegExp("<" + name + "\\b[^>]*\\b" + attribute + "=[\"']([^\"']+)[\"']", "i").exec(block); + return match ? match[1].trim() : ''; + }; + const code = (value) => { + const tick = String.fromCharCode(96); + return value ? tick + value + tick : ''; + }; + const roots = []; + const rootRe = /]*>([\s\S]*?)<\/root>/gi; + let root; + while ((root = rootRe.exec(block)) !== null) { + if (root[1].trim()) roots.push(code(root[1].trim())); + } + const fields = [ + ['environment_context', code(tag('cwd'))], + ['shell', tag('shell')], + ['current_date', tag('current_date')], + ['timezone', tag('timezone')], + ['workspace_roots', roots.join(', ')], + ['permission_profile', attr('permission_profile', 'type')], + ['file_system', attr('file_system', 'type')], + ].filter((field) => field[1]); + if (fields.length) { + parts.push(fields.map((field) => '**' + field[0] + ':** ' + field[1]).join(' \n')); + } + } + + let rest = source.replace(agents[0], ''); + if (env) rest = rest.replace(env[0], ''); + rest = rest.trim(); + if (rest) parts.push(rest); + return parts.join('\n\n').trim(); +} + +// Strip harness-injected blocks from user turns while keeping their human-facing content. +// A task notification is an XML envelope whose is the actual Markdown response; +// its IDs, status, summary, usage, and other transport metadata are not useful in the thread. +// Codex normalization turns a standalone envelope into a neutral `skill_load` card. +// Keep this raw-envelope suppression only as a legacy fallback, so it can never leak into a +// user bubble when older/imported data bypasses that normalizer. +// Returns '' when a turn contains injected metadata only. +export function stripInjected(text) { + let source = String(text || ''); + const bootstrap = formatCodexBootstrap(source); + if (bootstrap != null) source = bootstrap; + // Only suppress the standalone Codex injection. Keep ordinary prose intact when a user is + // discussing or quoting markup alongside their own text. + if (/^\s*]*>[\s\S]*<\/skill>\s*$/i.test(source)) return ''; + return source + .replace(/]*>[\s\S]*?<\/task-notification>/gi, (block) => { + const result = /]*>([\s\S]*?)<\/result>/i.exec(block); + return result ? `\n${result[1].trim()}\n` : ''; + }) + .replace(/[\s\S]*?<\/system-reminder>/g, '') + .replace(/[\s\S]*?<\/command-[a-z-]+>/g, '') + .replace(/[\s\S]*?<\/local-command-[a-z]+>/g, '') + .trim(); +} diff --git a/src/renderer/js/views/conversations/detail.js b/src/renderer/js/views/conversations/detail.js new file mode 100644 index 0000000..c0413cc --- /dev/null +++ b/src/renderer/js/views/conversations/detail.js @@ -0,0 +1,178 @@ +/* Open a conversation, (re)load its transcript, and drive the windowed repaint + live follow. */ +import { $ } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { ensureVendor } from '../../core/loader.js'; +import { esc, readErrorKey, L } from './format.js'; +import { cs, activeMessages, openSessionLive, DETAIL_WIN } from './state.js'; +import { renderList } from './list.js'; +import { paintWindow, isNearBottom } from './window.js'; +import { performDetailSearch, clearDetailSearchHighlights, updateSearchCount } from './search.js'; +import { renderSidePanels, renderAgentTabs, syncConvNav } from './panels.js'; + +/** Drop all transcript-derived UI while a different session loads or the current read fails. + openFile/openId deliberately stay untouched, so the selected row and navigation rail remain + present and a later retry can recover in place without showing data from the previous session. */ +export function clearLoadedDetail() { + cs.currentDetail = null; + cs.searchDocs = null; + cs.subIndex = null; + renderAgentTabs(null); + const stats = $('convStats'); if (stats) stats.innerHTML = ''; + const toc = $('convToc'); if (toc) toc.innerHTML = ''; +} + +export async function openConversation(id, file) { + const ds = $('convDetailSearch'); + if (ds) ds.value = ''; + clearDetailSearchHighlights(); + cs.openId = id; cs.openFile = file || null; + syncConvNav(); + cs.activeAgent = 'main'; // new conversation always opens on its main thread + cs.detailRetry = null; + clearLoadedDetail(); + cs.vStart = 0; cs.vEnd = 0; // reset the render window for the new conversation + cs.lastRender = { file: null, count: -1 }; + ['convExportBtn', 'convCopyPathBtn', 'convReplayBtn', 'convChatgptBtn', 'convMoreBtn'].forEach((btnId) => { + const b = $(btnId); if (b) b.disabled = !cs.openFile; + }); + renderList(); + // Big sessions take a beat to read+parse off disk — show a loading hint during the async fetch + // (this wait is genuinely async/IPC, so the hint paints; the later render is what's kept bounded). + const host = $('convDetail'); + if (host && cs.openFile) host.innerHTML = `
${esc(L('conv.loading'))}
`; + await rerenderDetail(true); + // Opened from a big-search content hit: restore the query in the message search box, move the + // panel to the matched thread (subagent hits switch automatically), and land on the match. + // The openFile identity check drops the jump when another session was opened mid-load. + if (cs.pendingLocate && cs.openFile && cs.openFile === (file || null) && cs.currentDetail) { + const pl = cs.pendingLocate; cs.pendingLocate = null; + if (ds) ds.value = pl.query; + performDetailSearch(pl.query, { agent: pl.agent || 'main' }); + } +} + +/** Total rendered-content length across main + subagent threads — the change key for re-render skipping. */ +function contentShape(detail) { + const messages = detail.messages || []; + const msgLen = (m) => { + if (!m || !m.content) return 0; + if (typeof m.content === 'string') return m.content.length; + if (Array.isArray(m.content)) return m.content.reduce((sum, b) => sum + (b.text ? b.text.length : 0) + (b.thinking ? b.thinking.length : 0), 0); + return 0; + }; + let contentLen = messages.reduce((acc, m) => acc + msgLen(m), 0); + // Fold subagent growth into the change key too: while a subagent streams, the main thread can + // sit idle, and the skip-guard would otherwise freeze the nested subagent view mid-run. + const subs = detail.subagents || {}; + let subCount = 0; + for (const k of Object.keys(subs)) { + const sm = (subs[k] && subs[k].messages) || []; + subCount += sm.length; + contentLen += sm.reduce((acc, m) => acc + msgLen(m), 0); + } + return { count: messages.length, contentLen, subCount }; +} + +/** Show a read failure and schedule the right retry cadence. */ +function showLoadError(host, loadError) { + const kind = loadError && loadError.kind; + const key = !loadError ? 'conv.notFound' : readErrorKey(kind); + host.innerHTML = `
${esc(L(key))}
`; + clearLoadedDetail(); + // A missing/moved path is not expected to recover in place. Permission failures re-probe at + // the timer's steady 4s; other read/IPC failures back off (4s → 60s cap) per attempt. + if (key === 'conv.notFound') { + cs.detailRetry = null; + } else { + const file = cs.openFile; + const attempts = (cs.detailRetry && cs.detailRetry.file === file ? cs.detailRetry.attempts : 0) + 1; + const delay = kind === 'permissionDenied' ? 0 : Math.min(4000 * 2 ** (attempts - 1), 60000); + cs.detailRetry = { file, attempts, nextAt: Date.now() + delay }; + } + cs.lastRender = { file: null, count: -1 }; +} + +export async function rerenderDetail(force) { + if (!cs.openFile) return; + const requestedFile = cs.openFile; + // The live/error retry timer can fire while a slower helper-backed Qoder read is still running. + // One request for the currently-selected file is enough; a genuinely different selection may + // start immediately and its newer sequence invalidates this result. + if (cs.detailRequest && cs.detailRequest.file === requestedFile) { + if (force) cs.detailRequest.force = true; // preserve a language-change/re-open forced paint + return; + } + const request = { seq: ++cs.detailRequestSeq, file: requestedFile, force: !!force }; + cs.detailRequest = request; + let detail = null; + let ipcReadFailed = false; + // Markdown + syntax highlighting are only needed once a transcript actually renders. + const vendor = ensureVendor().catch(() => {}); + try { detail = await api.historyGet(requestedFile); } catch (_) { ipcReadFailed = true; } + await vendor; + if (cs.detailRequest === request) cs.detailRequest = null; + // A→B (or A→B→A) can leave older IPC calls in flight. Never let their success/error overwrite + // the latest selection, even when the path happens to match again after an intervening click. + if (cs.openFile !== requestedFile || request.seq !== cs.detailRequestSeq) return; + force = request.force; + const host = $('convDetail'); + if (!host) return; + // A failed read is distinct from a missing/moved transcript. Keep openFile intact so the + // selected row + navigation rail remain open and a later retry (for example after granting + // macOS access to Qoder's data) can recover in place. + const loadError = ipcReadFailed ? { kind: 'readFailed' } : (detail && detail.error); + if (loadError || !detail) { showLoadError(host, loadError); return; } + cs.detailRetry = null; + cs.currentDetail = detail; + cs.subIndex = null; // call-site map is rebuilt lazily against the freshly-loaded subagents + + const shape = contentShape(detail); + // Skip needless re-renders: on-disk turns are written whole, so a stable message count + // and content length means nothing changed — preserves scroll + expanded thinking/result panels. + if (!force && cs.lastRender.file === cs.openFile && cs.lastRender.count === shape.count + && cs.lastRender.contentLen === shape.contentLen && cs.lastRender.subCount === shape.subCount + && host.querySelector('.msg')) return; + + const total = activeMessages().length; // window/paint follow the ACTIVE session (main or subagent) + const wasBottom = isNearBottom(host); + cs.searchDocs = null; // content changed (or fresh open) — search docs rebuild lazily on next use + if (force) { + clearDetailSearchHighlights(); + // A still-running session opens at the newest turns (trailing window, pinned to the bottom) so + // it live-follows. A finished history conversation opens at the START — leading window scrolled + // to the top — so the first human message is what you see, not the tail. (Subagent threads, which + // have no live-follow semantics, also read top-down.) + if (cs.activeAgent === 'main' && openSessionLive()) { + cs.vEnd = total; cs.vStart = Math.max(0, total - DETAIL_WIN); + paintWindow(); + host.scrollTop = host.scrollHeight; + } else { + cs.vStart = 0; cs.vEnd = Math.min(total, DETAIL_WIN); + paintWindow(); + host.scrollTop = 0; + } + } else if (wasBottom) { + // live-follow at the bottom: extend the window to the newest and stay pinned to the bottom + cs.vEnd = total; cs.vStart = Math.max(0, total - DETAIL_WIN); + paintWindow(); + host.scrollTop = host.scrollHeight; + } else { + // scrolled up reading history: don't repaint (preserves scroll + expanded panels); new turns are + // appended past the window and surface via the "load later" affordance / next jump. + cs.vEnd = Math.min(cs.vEnd, total); + } + // A live-updating session with an active search: refresh counts/highlights against the new + // content without moving the view, keeping the current position when it still exists. + // (force paths cleared the search above.) + if (cs.searchQuery) { + const cur = cs.searchIndex >= 0 ? cs.searchOcc[cs.searchIndex] : null; + performDetailSearch(cs.searchQuery, { silent: true }); + if (cur) { + const i = cs.searchOcc.findIndex((o) => o.agent === cur.agent && o.mi === cur.mi); + if (i >= 0) { cs.searchIndex = i; updateSearchCount(); } + } + } + renderSidePanels(detail); + renderAgentTabs(detail); + cs.lastRender = { file: cs.openFile, ...shape }; +} diff --git a/src/renderer/js/views/conversations/events-detail.js b/src/renderer/js/views/conversations/events-detail.js new file mode 100644 index 0000000..69ffa77 --- /dev/null +++ b/src/renderer/js/views/conversations/events-detail.js @@ -0,0 +1,122 @@ +/* Detail-pane event wiring: TOC, agent tabs, message search, window loading, toolbar actions. */ +import { $ } from '../../core/dom.js'; +import { cs } from './state.js'; +import { jumpToMessage, loadEarlier, loadLater } from './window.js'; +import { performDetailSearch, gotoDetailSearchMatch, clearDetailSearchHighlights } from './search.js'; +import { setPanelAgent, switchAgent, focusSubagent } from './agents.js'; +import { fillSubBody } from './subagents.js'; +import { doCopyPath, doReplay, doChatgpt, doExport, hideExportMenu, updateToolbarLayout } from './actions.js'; + +const goto = (i) => gotoDetailSearchMatch(i, setPanelAgent); + +function bindAgentTabs() { + // Session tabs: [主会话] [子代理 (N) ▾]. The dropdown lists subagents; picking one jumps the main + // thread to where it was spawned and expands it inline there (focusSubagent), so it reads in context. + const tabs = $('convAgentTabs'); + tabs.addEventListener('click', (e) => { + if (e.target.closest('[data-agent-dd]')) { + cs.agentMenuOpen = !cs.agentMenuOpen; + const menu = tabs.querySelector('.conv-agent-menu'); + if (menu) menu.classList.toggle('hidden', !cs.agentMenuOpen); + return; + } + const it = e.target.closest('[data-agent]'); + if (it) { if (it.dataset.agent === 'main') switchAgent('main'); else focusSubagent(it.dataset.agent); } + }); + // Close the subagent menu when clicking outside the tab bar. + document.addEventListener('click', (e) => { + if (!cs.agentMenuOpen) return; + if (e.target.closest('#convAgentTabs')) return; + cs.agentMenuOpen = false; + const menu = document.querySelector('#convAgentTabs .conv-agent-menu'); + if (menu) menu.classList.add('hidden'); + }); +} + +function bindDetailSearch() { + // Typing searches (and highlights) without pulling the view to another agent; Enter CONFIRMS — + // it jumps straight to the first match, switching to its thread if needed — and further Enter + // presses step next/previous (Shift). ↑/↓ step across agents too. + const dsearch = $('convDetailSearch'); + let t; + dsearch.addEventListener('input', () => { clearTimeout(t); t = setTimeout(() => performDetailSearch(dsearch.value.trim()), 200); }); + dsearch.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const q = dsearch.value.trim(); + clearTimeout(t); + if (q !== cs.searchQuery) performDetailSearch(q, { first: true }); + else if (cs.searchOcc.length) goto(cs.searchIndex < 0 ? 0 : cs.searchIndex + (e.shiftKey ? -1 : 1)); + } + if (e.key === 'Escape') { dsearch.value = ''; clearDetailSearchHighlights(); } + }); + $('convDetailSearchPrev').addEventListener('click', () => { if (cs.searchOcc.length) goto(cs.searchIndex < 0 ? -1 : cs.searchIndex - 1); }); + $('convDetailSearchNext').addEventListener('click', () => { if (cs.searchOcc.length) goto(cs.searchIndex < 0 ? 0 : cs.searchIndex + 1); }); + $('convDetailSearchClear').addEventListener('click', () => { const inp = $('convDetailSearch'); if (inp) inp.value = ''; clearDetailSearchHighlights(); }); +} + +function bindDetailHost() { + // Load-earlier / load-later (delegated; #convDetail is stable, its innerHTML isn't). + $('convDetail').addEventListener('click', (e) => { + // Markdown preview ↔ source toggle (Read/Write of .md files). + const mdTab = e.target.closest('.md-tab'); + if (mdTab) { + const doc = mdTab.closest('.md-doc'); + if (doc) { + const which = mdTab.dataset.mdTab; + doc.querySelectorAll('.md-tab').forEach((tab) => tab.classList.toggle('active', tab === mdTab)); + const prev = doc.querySelector('.md-preview'); if (prev) prev.classList.toggle('hidden', which !== 'preview'); + const src = doc.querySelector('.md-source'); if (src) src.classList.toggle('hidden', which !== 'source'); + } + return; + } + if (e.target.closest('[data-load-earlier]')) { loadEarlier(); return; } + if (e.target.closest('[data-load-later]')) { loadLater(); return; } + // Lazily render an inline subagent transcript the first time its disclosure is opened (its + // children render the same way, so the tree fills one level per click — never all at once). + const sum = e.target.closest('.subagent-inline > summary'); + if (sum) fillSubBody(sum.parentElement); + }); +} + +function bindToolbar() { + $('convCopyPathBtn').addEventListener('click', doCopyPath); + const replayBtn = $('convReplayBtn'); + replayBtn.addEventListener('click', () => doReplay(replayBtn)); + const chatgptBtn = $('convChatgptBtn'); + chatgptBtn.addEventListener('click', () => doChatgpt(chatgptBtn)); + + const moreBtn = $('convMoreBtn'); + const moreMenu = $('convMoreMenu'); + moreBtn.addEventListener('click', (e) => { e.stopPropagation(); if (moreBtn.disabled) return; moreMenu.classList.toggle('hidden'); }); + moreMenu.addEventListener('click', (e) => { + const it = e.target.closest('[data-more]'); if (!it) return; + moreMenu.classList.add('hidden'); + const a = it.dataset.more; + if (a === 'replay') doReplay(); + else if (a === 'chatgpt') doChatgpt(); + else if (a === 'copyPath') doCopyPath(); + else if (a === 'jsonl') doExport('jsonl'); + else if (a === 'html') doExport('html'); + }); + document.addEventListener('click', (e) => { if (!e.target.closest('.conv-more-wrap')) moreMenu.classList.add('hidden'); }); + + // Responsive toolbar: collapse the action buttons into the "⋯" menu when space is tight. + const toolbar = document.querySelector('.conv-detail-toolbar'); + if (toolbar && window.ResizeObserver) new ResizeObserver(() => updateToolbarLayout()).observe(toolbar); + updateToolbarLayout(); + + // Export menu (JSONL / HTML) + const exportBtn = $('convExportBtn'); + exportBtn.addEventListener('click', (e) => { e.stopPropagation(); if (exportBtn.disabled) return; const m = $('convExportMenu'); if (m) m.classList.toggle('hidden'); }); + $('convExportMenu').addEventListener('click', (e) => { const it = e.target.closest('[data-export]'); if (it) doExport(it.dataset.export); }); + document.addEventListener('click', (e) => { if (!e.target.closest('.conv-export-wrap')) hideExportMenu(); }); +} + +export function bindDetailEvents() { + $('convToc').addEventListener('click', (e) => { const it = e.target.closest('.toc-item'); if (it) jumpToMessage(+it.dataset.go, 'start'); }); + bindAgentTabs(); + bindDetailSearch(); + bindDetailHost(); + bindToolbar(); +} diff --git a/src/renderer/js/views/conversations/events-list.js b/src/renderer/js/views/conversations/events-list.js new file mode 100644 index 0000000..e99a441 --- /dev/null +++ b/src/renderer/js/views/conversations/events-list.js @@ -0,0 +1,102 @@ +/* Left-list event wiring: open, tag filter/edit, restore/remove, project collapse, dir chips. */ +import { $ } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { cs, persistCollapsed } from './state.js'; +import { refreshList, renderList, scheduleContentSearch } from './list.js'; +import { openConversation } from './detail.js'; +import { syncConvNav } from './panels.js'; +import { applyImportResult } from './actions.js'; +import { deleteTag, restoreSession, deleteForever, startEditTag, showCtxMenu, hideCtxMenu } from './meta-edit.js'; + +const metaDeps = { refresh: refreshList, renderList, syncConvNav }; + +async function onListClick(e) { + if (e.target.closest('[data-clear-tagfilter]')) { e.stopPropagation(); cs.tagFilter = null; renderList(); return; } + const delTag = e.target.closest('[data-del-tag]'); + if (delTag) { e.stopPropagation(); await deleteTag(delTag.dataset.file, delTag.dataset.delTag, metaDeps); return; } + const tagChip = e.target.closest('.conv-tag'); + if (tagChip && tagChip.dataset.tag) { + e.stopPropagation(); + // Defer the filter toggle so a double-click (edit) can cancel it — otherwise the first click + // of the dblclick would re-render the list and destroy the chip before dblclick fires. + const tag = tagChip.dataset.tag; + clearTimeout(cs.tagClickTimer); + cs.tagClickTimer = setTimeout(() => { cs.tagFilter = (cs.tagFilter === tag) ? null : tag; renderList(); }, 220); + return; + } + const restoreBtn = e.target.closest('[data-restore]'); + if (restoreBtn) { e.stopPropagation(); await restoreSession(restoreBtn.dataset.restore, metaDeps); return; } + const delFvr = e.target.closest('[data-delete-forever]'); + if (delFvr) { e.stopPropagation(); await deleteForever(delFvr.dataset.deleteForever, metaDeps); return; } + const rm = e.target.closest('[data-remove-import]'); + if (rm) { + e.stopPropagation(); + const file = rm.dataset.removeImport; + if (!file || !api.historyRemoveImport) return; + let res; try { res = await api.historyRemoveImport(file); } catch (_) { res = null; } // confirms in the backend + if (!res || !res.ok) return; // cancelled or failed → leave the list as-is + if (file === cs.openFile) { cs.openId = null; cs.openFile = null; syncConvNav(); } + await refreshList(); + return; + } + const head = e.target.closest('.conv-proj-head'); + if (head) { + const key = head.dataset.proj; + if (cs.collapsed.has(key)) cs.collapsed.delete(key); else cs.collapsed.add(key); + persistCollapsed(); + renderList(); + return; + } + const item = e.target.closest('.conv-item'); + if (item) { + // Opening from a content hit carries the query along, so the conversation lands right on + // the match — switching to the matching subagent first when that's where it lives. + const hit = (cs.search && cs.contentHits) ? cs.contentHits.get(item.dataset.file) : null; + cs.pendingLocate = hit ? { query: cs.search, agent: hit.agent || 'main' } : null; + openConversation(item.dataset.id, item.dataset.file); + } +} + +export function bindListEvents() { + const list = $('convList'); + list.addEventListener('click', onListClick); + // Right-click a conversation → rename / add-tag menu. + list.addEventListener('contextmenu', (e) => { + const item = e.target.closest('.conv-item'); + if (!item) return; + e.preventDefault(); + showCtxMenu(e.clientX, e.clientY, item.dataset.file, item.dataset.id, metaDeps); + }); + // Double-click a tag chip → edit it in place. + list.addEventListener('dblclick', (e) => { + const label = e.target.closest('.conv-tag-label'); + if (!label) return; + e.preventDefault(); e.stopPropagation(); + clearTimeout(cs.tagClickTimer); // cancel the pending single-click filter toggle + const chip = label.closest('.conv-tag'); + if (chip && chip.dataset.tag) startEditTag(chip.dataset.file, chip.dataset.tag, chip, metaDeps); + }); + list.addEventListener('scroll', hideCtxMenu, true); + document.addEventListener('click', (e) => { if (!e.target.closest('.conv-ctx-menu')) hideCtxMenu(); }); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCtxMenu(); }); + + const sb = $('convSearch'); + sb.addEventListener('input', (e) => { cs.search = e.target.value.trim(); scheduleContentSearch(); renderList(); }); + $('convClear').addEventListener('click', () => { + const i = $('convSearch'); + if (i) { i.value = ''; cs.search = ''; scheduleContentSearch(); renderList(); i.focus(); } + }); + const imp = $('convImportBtn'); + if (api.historyImport) imp.addEventListener('click', async () => { + imp.disabled = true; + let r; try { r = await api.historyImport(); } catch (_) { r = null; } + imp.disabled = false; + await applyImportResult(r, refreshList); + }); + $('convDirSwitch').addEventListener('click', async (e) => { + const btn = e.target.closest('[data-dir]'); + if (!btn) return; + try { if (api.historySetActive) await api.historySetActive(btn.dataset.dir); } catch (_) {} + await refreshList(); + }); +} diff --git a/src/renderer/js/views/conversations/format.js b/src/renderer/js/views/conversations/format.js new file mode 100644 index 0000000..39d5468 --- /dev/null +++ b/src/renderer/js/views/conversations/format.js @@ -0,0 +1,105 @@ +/* 对话 view formatting primitives — text escaping, sizes, times, source labels. */ +import { I18n } from '../../core/i18n.js'; + +export const L = (k, p) => I18n.t(k, p); +export const localeTag = () => I18n.localeTag; + +export function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +// Middle-ellipsis a long path so the start (/Users…) and meaningful tail (…/work) both stay visible. +export const midEllip = (s, max) => { + s = String(s == null ? '' : s); + if (s.length <= max) return s; + const k = max - 1, h = Math.ceil(k / 2), t = Math.floor(k / 2); + return s.slice(0, h) + '…' + s.slice(s.length - t); +}; + +// Non-Claude session sources (meta.source): list-row chip label + assistant display name. +// Claude ('disk') deliberately has no chip — it's the app's home turf. +export const SOURCE_NAMES = { codex: 'Codex', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity', qoder: 'Qoder' }; +export const isForeignSource = (s) => !!SOURCE_NAMES[s]; + +// conv.permissionDenied walks the user through macOS System Settings — that guidance only fits +// macOS (the helper-backed Qoder read path); other platforms show the generic read-failure copy. +const IS_MAC = /mac/i.test(navigator.platform || ''); +export const readErrorKey = (kind) => (kind === 'permissionDenied' && IS_MAC + ? 'conv.permissionDenied' + : kind === 'notFound' ? 'conv.notFound' : 'conv.readFailed'); + +export function fmtTok(n) { + n = n || 0; + if (n < 1000) return String(n); + if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; + return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; +} + +// Qoder Credits are billing units, not tokens or currency. Keep their fractional precision for +// individual turns, while abbreviating only large conversation totals. +export function fmtCredits(n) { + n = Number(n); + if (!Number.isFinite(n)) return '—'; + const trim = (s) => s.replace(/(\.\d*?[1-9])0+$|\.0+$/, '$1'); + if (Math.abs(n) >= 1000) return trim((n / 1000).toFixed(Math.abs(n) < 10000 ? 1 : 0)) + 'K'; + if (Math.abs(n) >= 100) return trim(n.toFixed(1)); + if (Math.abs(n) >= 1) return trim(n.toFixed(2)); + return trim(n.toFixed(3)); +} + +export function truncate(s, n) { + s = String(s == null ? '' : s); + return s.length > n ? s.slice(0, n) + L('conv.charsMore', { n: s.length - n }) : s; +} + +// Size shown in KB until it's large enough to read better as MB / GB. +export function fmtSizeKB(kb) { + kb = kb || 0; + if (kb < 1024) return kb + ' KB'; + if (kb < 1024 * 1024) return (kb / 1024).toFixed(1).replace(/\.0$/, '') + ' MB'; + return (kb / 1024 / 1024).toFixed(2).replace(/\.0+$/, '') + ' GB'; +} + +export function md(text) { + try { return window.marked ? window.marked.parse(String(text || '')) : esc(text); } catch (_) { return esc(text); } +} + +export function normContent(c) { + if (typeof c === 'string') return c ? [{ type: 'text', text: c }] : []; + return Array.isArray(c) ? c : []; +} + +export function projName(cwd) { return cwd ? cwd.split('/').filter(Boolean).pop() : null; } + +export function relTime(ts) { + if (!ts) return ''; + const d = Date.now() - ts; + if (d < 60000) return L('time.justNow'); + if (d < 3600000) return L('time.minutesAgo', { n: Math.floor(d / 60000) }); + if (d < 86400000) return L('time.hoursAgo', { n: Math.floor(d / 3600000) }); + if (d < 7 * 86400000) return L('time.daysAgo', { n: Math.floor(d / 86400000) }); + return new Date(ts).toLocaleDateString(localeTag()); +} + +export function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +export function shortPath(p) { + if (!p) return ''; + const s = String(p).split('/'); + return s.length > 3 ? '…/' + s.slice(-2).join('/') : p; +} + +export function resultSummary(txt) { + const b = txt ? txt.length : 0; + if (!b) return ''; + return b < 1024 ? b + ' B' : (b / 1024).toFixed(1) + ' KB'; +} + +export function contentToText(c) { + if (typeof c === 'string') return c; + if (Array.isArray(c)) return c.map((x) => (x && (x.text != null ? x.text : (typeof x.content === 'string' ? x.content : ''))) || '').join(' '); + return ''; +} + +/** Escape a tool_use id for use inside a [data-sub="…"] attribute selector (ids may contain ':'). */ +export function cssAttr(s) { return String(s).replace(/(["\\])/g, '\\$1'); } diff --git a/src/renderer/js/views/conversations/index.js b/src/renderer/js/views/conversations/index.js new file mode 100644 index 0000000..6b4fe7d --- /dev/null +++ b/src/renderer/js/views/conversations/index.js @@ -0,0 +1,49 @@ +/* + * 会话 view — reads Claude Code's on-disk session history (~/.claude/projects) directly and + * renders it claude-code-history-viewer style: projects → sessions tree, a rich message timeline + * (text / thinking / per-tool cards + results / diffs / code / images), live-follow for active + * sessions, per-session stats, and in-conversation search. Also browses the other coding CLIs' + * stores (Codex / Grok / Copilot / Antigravity / Qoder) through the same pipeline. + * + * This is the heaviest view, so it is mounted lazily (registry.js) — its markup, its markdown / + * highlight vendor bundles, and every module below stay off the cold-start path entirely. + */ +import { $, injectIcons } from '../../core/dom.js'; +import { I18n } from '../../core/i18n.js'; +import { cs } from './state.js'; +import { CONVERSATIONS_HTML } from './template.js'; +import { refreshList, renderList, renderDirSwitch } from './list.js'; +import { rerenderDetail } from './detail.js'; +import { syncConvNav } from './panels.js'; +import { initTooltips } from './tooltip.js'; +import { initConvResizers, initConvCollapse } from './layout.js'; +import { bindListEvents } from './events-list.js'; +import { bindDetailEvents } from './events-detail.js'; +import { bindLiveFollow, bindDropImport } from './live.js'; + +export default { + id: 'conversations', + mount(host) { + host.insertAdjacentHTML('beforeend', CONVERSATIONS_HTML); + const section = $('view-conversations'); + I18n.apply(section); + injectIcons(section); + + initTooltips(); + bindListEvents(); + bindDetailEvents(); + initConvResizers(); + initConvCollapse(); + syncConvNav(); // nothing selected at startup → no right rail + bindLiveFollow(); + bindDropImport(); + + // Compat surface for the language switch in Settings (and any external caller). + window.ccbudConversations = { + onShow() { refreshList(); if (cs.openFile) rerenderDetail(false); }, + // Re-render everything this view owns when the UI language changes. + setLang() { renderDirSwitch(); renderList(); if (cs.openFile) rerenderDetail(true); }, + }; + }, + onShow() { refreshList(); if (cs.openFile) rerenderDetail(false); }, +}; diff --git a/src/renderer/js/views/conversations/layout.js b/src/renderer/js/views/conversations/layout.js new file mode 100644 index 0000000..25b7ba7 --- /dev/null +++ b/src/renderer/js/views/conversations/layout.js @@ -0,0 +1,75 @@ +/* Panel geometry: drag-to-resize the left/right rails and collapse them (persisted). */ +import { $ } from '../../core/dom.js'; +import { icons } from '../../core/icons.js'; + +// Drag-to-resize the left/right panels (middle absorbs the rest). Widths persist; collapse wins via CSS. +export function initConvResizers() { + const layout = document.querySelector('.conv-layout'); + const sidebar = document.querySelector('.conv-sidebar'); + const nav = document.querySelector('.conv-nav'); + if (!layout || !sidebar || !nav) return; + const MIN_LEFT = 200, MIN_RIGHT = 180, MIN_MAIN = 320; // MIN_MAIN keeps the middle usable, not a fixed width + const num = (v, d) => { const n = parseInt(v, 10); return isFinite(n) ? n : d; }; + let leftW = num(localStorage.getItem('ccbud-conv-leftw'), 248); + let rightW = num(localStorage.getItem('ccbud-conv-rightw'), 220); + const apply = () => { sidebar.style.setProperty('--conv-left-w', leftW + 'px'); nav.style.setProperty('--conv-right-w', rightW + 'px'); }; + apply(); + const startDrag = (side, handle, e) => { + e.preventDefault(); + const total = layout.getBoundingClientRect().width; + const startX = e.clientX, sL = leftW, sR = rightW; + layout.classList.add('resizing'); handle.classList.add('dragging'); + const onMove = (ev) => { + const dx = ev.clientX - startX; + if (side === 'left') leftW = Math.max(MIN_LEFT, Math.min(total - rightW - MIN_MAIN, sL + dx)); + else rightW = Math.max(MIN_RIGHT, Math.min(total - leftW - MIN_MAIN, sR - dx)); + apply(); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); + layout.classList.remove('resizing'); handle.classList.remove('dragging'); + try { localStorage.setItem('ccbud-conv-leftw', String(leftW)); localStorage.setItem('ccbud-conv-rightw', String(rightW)); } catch (_) {} + }; + document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); + }; + layout.querySelectorAll('.conv-resizer').forEach((r) => r.addEventListener('mousedown', (e) => startDrag(r.dataset.resize, r, e))); +} + +// Left sidebar: ‹ when expanded (collapse leftward), › when collapsed (expand rightward). +const setChevron = (btn, isCol) => { + const icon = btn && btn.querySelector('[data-icon]'); + if (icon) icon.innerHTML = isCol ? (icons.chevronRight || '›') : (icons.chevronLeft || '‹'); +}; +// Right nav is the mirror image: › when expanded (collapse rightward), ‹ when collapsed. +const setChevronNav = (btn, isCol) => { + const icon = btn && btn.querySelector('[data-icon]'); + if (icon) icon.innerHTML = isCol ? (icons.chevronLeft || '‹') : (icons.chevronRight || '›'); +}; + +/** Collapse toggles for the conversation list sidebar and the right nav panel. */ +export function initConvCollapse() { + const convSidebar = document.querySelector('.conv-sidebar'); + const collapseListBtn = $('btnCollapseConvList'); + if (collapseListBtn && convSidebar) { + try { if (localStorage.getItem('ccbud-convlist-collapsed') === '1') { convSidebar.classList.add('collapsed'); setChevron(collapseListBtn, true); } } catch (_) {} + collapseListBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isCol = convSidebar.classList.toggle('collapsed'); + setChevron(collapseListBtn, isCol); + try { localStorage.setItem('ccbud-convlist-collapsed', isCol ? '1' : '0'); } catch (_) {} + }); + } + + const convNav = document.querySelector('.conv-nav'); + const collapseNavBtn = $('btnCollapseConvNav'); + if (collapseNavBtn && convNav) { + setChevronNav(collapseNavBtn, false); // default expanded → › (collapse rightward) + try { if (localStorage.getItem('ccbud-convnav-collapsed') === '1') { convNav.classList.add('collapsed'); setChevronNav(collapseNavBtn, true); } } catch (_) {} + collapseNavBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isCol = convNav.classList.toggle('collapsed'); + setChevronNav(collapseNavBtn, isCol); + try { localStorage.setItem('ccbud-convnav-collapsed', isCol ? '1' : '0'); } catch (_) {} + }); + } +} diff --git a/src/renderer/js/views/conversations/list-order.js b/src/renderer/js/views/conversations/list-order.js new file mode 100644 index 0000000..cb4f919 --- /dev/null +++ b/src/renderer/js/views/conversations/list-order.js @@ -0,0 +1,69 @@ +/* + * Row ordering. Codex assigns one session_id to the whole root/subagent tree. Keep that tree + * together in the list, but key each node by its canonical first SessionMeta.id. The first + * bucket encounter is already the newest activity in the backend order; within it, root precedes + * recursively nested children so parallel agents no longer look like duplicate top-level + * conversations. + */ + +/** Bucket sessions by codex thread (or standalone row) and record each bucket's newest activity. */ +function bucketSessions(sessions) { + const buckets = new Map(); + (sessions || []).forEach((session, index) => { + const grouped = session.source === 'codex' && session.canonicalThreadIdValid && session.rootSessionId; + // Keep live/configured/imported stores independent. A copied snapshot may share both root + // and thread ids with a live rollout, but must never replace it merely because its copy mtime + // is newer. + const key = grouped + ? `codex:${session.dirId || ''}:${session.rootSessionId}` + : `row:${session.id || ''}:${session.file || index}`; + if (!buckets.has(key)) buckets.set(key, { index, activity: 0, sessions: [] }); + const bucket = buckets.get(key); + bucket.activity = Math.max(bucket.activity, session.lastActivity || 0); + bucket.sessions.push(session); + }); + return buckets; +} + +const newest = (a, b) => (b.lastActivity || 0) - (a.lastActivity || 0) + || (b.createdAt || 0) - (a.createdAt || 0) + || String(a.threadId || a.id || '').localeCompare(String(b.threadId || b.id || '')) + || String(a.file || '').localeCompare(String(b.file || '')); + +/** Depth-first flatten of one codex bucket: each root, then its children, recursively. */ +function flattenCodexBucket(bucket, ordered) { + const byParent = new Map(); + bucket.sessions.forEach((session) => { + const parent = session.parentThreadId || ''; + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent).push(session); + }); + byParent.forEach((children) => children.sort(newest)); + const seen = new Set(); + const append = (session) => { + const id = session.canonicalThreadIdValid + ? (session.threadId || session.sessionId || session.id) + : `${session.id || ''}:${session.file || ''}`; + if (seen.has(id)) return; + seen.add(id); ordered.push(session); + (byParent.get(id) || []).forEach(append); + }; + bucket.sessions + .filter((session) => !session.isSubagent || session.threadId === session.rootSessionId) + .sort(newest) + .forEach(append); + bucket.sessions.sort((a, b) => (a.agentDepth || 0) - (b.agentDepth || 0) || newest(a, b)).forEach(append); +} + +export function orderSessionRows(sessions) { + const buckets = bucketSessions(sessions); + const ordered = []; + [...buckets.values()].sort((a, b) => b.activity - a.activity || a.index - b.index).forEach((bucket) => { + if (bucket.sessions.length === 1 || bucket.sessions[0].source !== 'codex') { + ordered.push(...bucket.sessions); + return; + } + flattenCodexBucket(bucket, ordered); + }); + return ordered; +} diff --git a/src/renderer/js/views/conversations/list-row.js b/src/renderer/js/views/conversations/list-row.js new file mode 100644 index 0000000..fdcd7ec --- /dev/null +++ b/src/renderer/js/views/conversations/list-row.js @@ -0,0 +1,71 @@ +/* One conversation row in the left list: badges, tags, content-hit snippet, times. */ +import { icons } from '../../core/icons.js'; +import { esc, relTime, fmtSizeKB, SOURCE_NAMES, isForeignSource, readErrorKey, L } from './format.js'; +import { cs, isLive } from './state.js'; +import { markSnippet } from './search.js'; + +// Two timestamps: the session's start (createdAt, the sort key) and — only when it meaningfully +// differs — the last-updated time, so an edited/active session shows both without redundancy. +function metaTimes(c) { + const created = c.createdAt || c.lastActivity; + const start = `${esc(relTime(created))}`; + const updated = (c.lastActivity && created && c.lastActivity - created > 60000) + ? `${esc(L('conv.updatedPrefix'))} ${esc(relTime(c.lastActivity))}` + : ''; + return start + updated; +} + +/** Trash-mode restore/delete-forever, or the imported-copy remove affordance. */ +function rowActions(c) { + // Recycle bin rows swap the import-remove affordance for restore + delete-forever; everywhere else + // imported copies (which live only in the app store) keep their remove affordance. + const inTrash = cs.activeDir === '__trash__'; + // A LIVE session of another CLI (codex/grok/copilot/antigravity/qoder, not an imported + // copy) is that tool's file — it can be restored but NEVER permanently deleted, since the + // app must not rm another tool's data. + const foreign = isForeignSource(c.source) && !c.imported; + const restoreBtn = ``; + const deleteForeverBtn = ``; + if (inTrash) return restoreBtn + (foreign ? '' : deleteForeverBtn); + return c.imported ? `` : ''; +} + +export function sessionItem(c) { + const live = isLive(c.lastActivity) ? '' : ''; + const subLabel = [L('conv.subagent'), c.agentNickname].filter(Boolean).join(' · '); + const sub = c.isSubagent ? `${esc(subLabel)}` : ''; + const imp = c.imported ? `${icons.download || ''}${esc(L('conv.imported'))}` : ''; + // Non-Claude sources carry a small origin chip so a mixed project group stays readable. + const srcName = SOURCE_NAMES[c.source]; + const srcBadge = srcName ? `${esc(srcName)}` : ''; + // A row whose transcript couldn't be read explains itself on hover instead of sitting as a + // silent untitled entry (the reason only became visible after clicking before). + const rerr = c.readError; + const errBadge = rerr ? `` : ''; + const rm = rowActions(c); + const model = c.model ? `${esc(c.model)}` : ''; + // User tags (deletable: x; double-click to edit; click to filter). The import badge stays + // separate and non-deletable. Empty when the conversation has no custom tags. + const tags = (c.tags || []).map((t) => + `${esc(t)}`).join(''); + const tagsRow = tags ? `
${tags}
` : ''; + // Content-search hit: show WHERE the query matched — a highlighted snippet, badged with the + // subagent's type when the match lives inside one (clicking auto-opens there). + const hit = (cs.search && cs.contentHits) ? cs.contentHits.get(c.file) : null; + const snipRow = hit && hit.snippet + ? `
${hit.agent && hit.agent !== 'main' ? `🤖 ${esc(hit.agentType || L('conv.subagent'))} ` : ''}${markSnippet(hit.snippet, cs.search)}${hit.count > 1 ? ` ×${hit.count}` : ''}
` + : ''; + // Full title on hover; when a custom title overrides the auto one, also surface the original first line. + const fullTitle = c.title || L('conv.untitled'); + const tip = (c.autoTitle && c.title && c.autoTitle !== c.title) ? (fullTitle + ' · ' + c.autoTitle) : fullTitle; + const treeDepth = c.source === 'codex' && c.isSubagent ? Math.max(1, Math.min(Number(c.agentDepth) || 1, 5)) : 0; + const treeIndent = 22 + treeDepth * 13; + const treeMark = treeDepth ? '' : ''; + return `
+
${treeMark}${live}${esc(fullTitle)}${rm}
+
${model}${srcBadge}${errBadge}${sub}${imp}
+ ${snipRow} + ${tagsRow} +
${metaTimes(c)}${c.sizeKB ? '' + fmtSizeKB(c.sizeKB) + '' : ''}
+
`; +} diff --git a/src/renderer/js/views/conversations/list.js b/src/renderer/js/views/conversations/list.js new file mode 100644 index 0000000..ea17ea6 --- /dev/null +++ b/src/renderer/js/views/conversations/list.js @@ -0,0 +1,121 @@ +/* Left list: project → session tree, dir chips, filtering, and the async content search. */ +import { $ } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { icons } from '../../core/icons.js'; +import { esc, midEllip, L } from './format.js'; +import { cs } from './state.js'; +import { sessionItem } from './list-row.js'; +import { orderSessionRows } from './list-order.js'; + +export async function refreshList() { + try { cs.projects = (await api.historyProjects()) || []; } catch (_) { cs.projects = []; } + // Fetch dir stats up front so activeDir (which renderList reads for trash mode) is set before + // the list renders — and pass the data into renderDirSwitch to avoid a second round-trip. + let dirData = null; + if (api.historyDirs) { try { dirData = await api.historyDirs(); } catch (_) { dirData = null; } } + cs.activeDir = (dirData && dirData.active) || 'all'; + renderDirSwitch(dirData); + renderList(); +} + +export async function renderDirSwitch(pre) { + const host = $('convDirSwitch'); + if (!host || !api.historyDirs) return; + let data = pre; + if (!data) { try { data = await api.historyDirs(); } catch (_) { data = { dirs: [], active: 'all' }; } } + const active = data.active || 'all'; + const allDirs = data.dirs || []; + // Recycle bin: a synthetic bucket of soft-deleted sessions. Surface its chip whenever something + // is in it (or it's the active view), so single-dir users still get an entry point. + const trashEntry = allDirs.find((d) => d.id === '__trash__' || d.trash); + const trashN = trashEntry ? (trashEntry.sessions || 0) : 0; + const showTrash = trashN > 0 || active === '__trash__'; + // Hide the synthetic 导入 chip until something is actually imported (keeps the bar clean / + // unchanged for single-dir users). The + button is the import entry point regardless. + const dirs = allDirs.filter((d) => d.id !== '__trash__' && !d.trash && !(d.imported && !d.sessions)); + const showDirs = dirs.length > 1; + if (!showDirs && !showTrash) { host.classList.add('hidden'); host.innerHTML = ''; return; } + const opts = [{ id: 'all', label: L('conv.all') }].concat(showDirs ? dirs.map((d) => ({ id: d.id, label: d.id === '__imported__' ? L('conv.importedDir') : d.label, imported: d.imported, sessions: d.sessions })) : []); + host.classList.remove('hidden'); + let html = opts.map((o) => ``).join(''); + if (showTrash) { + html += ``; + } + host.innerHTML = html; +} + +function filteredProjects() { + if (!cs.search && !cs.tagFilter) return cs.projects; + const q = cs.search.toLowerCase(); + return cs.projects + .map((p) => { + const sessions = p.sessions.filter((s) => { + if (cs.tagFilter && (s.tags || []).indexOf(cs.tagFilter) < 0) return false; + if (!q) return true; + if ((s.title || '').toLowerCase().includes(q) || + (s.model || '').toLowerCase().includes(q) || + (p.name || '').toLowerCase().includes(q) || + (s.tags || []).some((t) => t.toLowerCase().includes(q))) return true; + // Content match (async backend scan of message bodies, incl. subagents) — see + // scheduleContentSearch; these rows carry a snippet in sessionItem. + return !!(cs.contentHits && cs.contentHits.has(s.file)); + }); + return sessions.length ? Object.assign({}, p, { sessions }) : null; + }) + .filter(Boolean); +} + +// Big-search content matching: ask the backend to scan session BODIES (message text, thinking, +// tool calls/results — main thread, every subagent transcript, codex rollouts) for the query. +// Debounced per keystroke; responses for a superseded query are dropped. Field filtering above +// stays instant — content hits merge into the same list as they arrive. +export function scheduleContentSearch() { + clearTimeout(cs.contentTimer); + cs.contentSeq++; + cs.contentHits = null; + cs.contentSearching = false; + const q = cs.search; + if (!q || !api.historySearch) return; + cs.contentSearching = true; + const seq = cs.contentSeq; + cs.contentTimer = setTimeout(async () => { + let res = null; + try { res = await api.historySearch(q); } catch (_) { res = null; } + if (seq !== cs.contentSeq) return; // a newer query took over while this one was scanning + cs.contentSearching = false; + const map = new Map(); + for (const h of (Array.isArray(res) ? res : [])) if (h && h.file) map.set(h.file, h); + cs.contentHits = map; + renderList(); + }, 220); +} + +export function renderList() { + const el = $('convList'); + if (!el) return; + const list = filteredProjects(); + const total = list.reduce((n, p) => n + p.sessions.length, 0); + const fbar = cs.tagFilter + ? `
🏷 ${esc(cs.tagFilter)}
` + : ''; + if (!total) { + const emptyMsg = (cs.search || cs.tagFilter) + ? esc(cs.search && cs.contentSearching ? L('conv.searching') : L('conv.noMatch')) + : (cs.activeDir === '__trash__' + ? esc(L('conv.trashEmpty')) + : esc(L('conv.noLocal')) + '
~/.claude/projects'); + el.innerHTML = fbar + `
${emptyMsg}
`; + return; + } + el.innerHTML = fbar + list.map((p) => { + const isCol = cs.collapsed.has(p.cwd || p.name) && !cs.search; + const items = isCol ? '' : `
${orderSessionRows(p.sessions).map(sessionItem).join('')}
`; + return `
+
+ ${isCol ? '▸' : '▾'} + ${esc(p.name || L('conv.unknownProject'))} + ${p.sessions.length} +
${items} +
`; + }).join(''); +} diff --git a/src/renderer/js/views/conversations/live.js b/src/renderer/js/views/conversations/live.js new file mode 100644 index 0000000..c10f1af --- /dev/null +++ b/src/renderer/js/views/conversations/live.js @@ -0,0 +1,79 @@ +/* + * Live follow + drag-drop import. ~/.claude/projects changes → refresh the list and re-render + * the open session if it was touched. rerenderDetail rebuilds the whole thread, so during an + * active session (the file is rewritten on every streamed turn) it is debounced — bursts of + * writes coalesce into one rebuild instead of one-per-write (the main "under load" jank). + */ +import { api } from '../../core/bridge.js'; +import { icons } from '../../core/icons.js'; +import { L } from './format.js'; +import { cs, openSessionLive } from './state.js'; +import { refreshList } from './list.js'; +import { rerenderDetail } from './detail.js'; +import { toast, applyImportResult } from './actions.js'; + +// True when a changed file belongs to the OPEN session — its own .jsonl, or one of its +// subagent files (/subagents/agent-*.jsonl) — so nested subagents live-follow too. +function touchesOpenSession(files) { + if (!cs.openFile || !files) return false; + const base = cs.openFile.replace(/\.jsonl$/i, ''); + return files.some((f) => f === cs.openFile || f.indexOf(base + '/subagents/') === 0 || f.indexOf(base + '\\subagents\\') === 0); +} + +export function bindLiveFollow() { + let detailTimer; + if (api.onHistoryChanged) api.onHistoryChanged((p) => { + clearTimeout(cs.listTimer); + cs.listTimer = setTimeout(refreshList, 200); + if (p && p.files && touchesOpenSession(p.files)) { + clearTimeout(detailTimer); + detailTimer = setTimeout(() => rerenderDetail(false), 300); + } + }); + + // Unified safety-net: live sessions still refresh when a file-watch event is missed, while a + // failed read retries on its own schedule (steady probe for permission errors, backoff for + // the rest). rerenderDetail coalesces ticks while a helper-backed read is already in flight. + setInterval(() => { + if (!cs.openFile) return; + const retryDue = cs.detailRetry && cs.detailRetry.file === cs.openFile && Date.now() >= cs.detailRetry.nextAt; + if (retryDue || openSessionLive()) rerenderDetail(false); + }, 4000); +} + +/* + * Drag a .jsonl transcript or a .zip conversation bundle (main session + subagents) anywhere onto + * the window → import it directly, same pipeline as the import button. preventDefault on + * dragover/drop is REQUIRED — otherwise the webview navigates to the dropped file:// URL. Other + * files are ignored (import validates each is a real transcript/bundle before copying it in). + */ +export function bindDropImport() { + const dragHasFiles = (e) => { try { return Array.from((e.dataTransfer && e.dataTransfer.types) || []).indexOf('Files') >= 0; } catch (_) { return false; } }; + let dropOverlay = null, dropDepth = 0; + const showDropOverlay = () => { + if (!dropOverlay) { + dropOverlay = document.createElement('div'); + dropOverlay.className = 'conv-drop-overlay'; + dropOverlay.innerHTML = '
' + (icons.download || '') + '
'; + document.body.appendChild(dropOverlay); + } + dropOverlay.querySelector('span').textContent = L('conv.dropHint'); + dropOverlay.classList.add('show'); + }; + const hideDropOverlay = () => { dropDepth = 0; if (dropOverlay) dropOverlay.classList.remove('show'); }; + document.addEventListener('dragenter', (e) => { if (!dragHasFiles(e)) return; e.preventDefault(); dropDepth++; showDropOverlay(); }); + document.addEventListener('dragover', (e) => { if (!dragHasFiles(e)) return; e.preventDefault(); try { e.dataTransfer.dropEffect = 'copy'; } catch (_) {} }); + document.addEventListener('dragleave', (e) => { if (!dragHasFiles(e)) return; dropDepth = Math.max(0, dropDepth - 1); if (!dropDepth) hideDropOverlay(); }); + document.addEventListener('drop', async (e) => { + if (!dragHasFiles(e)) return; + e.preventDefault(); + hideDropOverlay(); + const files = Array.prototype.slice.call(e.dataTransfer.files || []); + const paths = files.map((f) => { try { return api.pathForFile ? api.pathForFile(f) : (f.path || ''); } catch (_) { return ''; } }).filter(Boolean); + const importable = paths.filter((p) => /\.(jsonl|zip)$/i.test(p)); + if (!importable.length) { toast(L('conv.dropNotJsonl'), false); return; } + if (!api.historyImportPaths) return; + let r; try { r = await api.historyImportPaths(importable); } catch (_) { r = null; } + await applyImportResult(r, refreshList); + }); +} diff --git a/src/renderer/js/views/conversations/message.js b/src/renderer/js/views/conversations/message.js new file mode 100644 index 0000000..9e0791b --- /dev/null +++ b/src/renderer/js/views/conversations/message.js @@ -0,0 +1,107 @@ +/* One message → HTML: user bubbles, assistant blocks, thinking, skill loads, turn meta. */ +import { esc, md, normContent, fmtTok, fmtCredits, shortPath, resultSummary, L } from './format.js'; +import { stripInjected } from './codex-format.js'; +import { codeBlock } from './code.js'; +import { renderToolCard } from './tools.js'; +import { cs } from './state.js'; + +/** Assistant display name for the open session — "Codex" for codex rollouts, else Claude. */ +export function assistantName() { + return (cs.currentDetail && cs.currentDetail.meta && cs.currentDetail.meta.assistant) || 'Claude'; +} + +export function buildResults(messages) { + const results = {}; + messages.forEach((m) => normContent(m.content).forEach((b) => { if (b.type === 'tool_result') results[b.tool_use_id] = b; })); + return results; +} + +export function renderUserBlock(b) { + if (b.type === 'image') { + const s = b.source || {}; + if (s.data) return ``; + return `
🖼 ${esc(L('conv.image'))}
`; + } + return `
${md(b.text)}
`; +} + +function renderThinking(b) { + const t = b.thinking || ''; + // Some turns carry a thinking block with only a signature and no visible text (the model/upstream + // returned encrypted/empty reasoning). Skip it rather than draw an empty collapsible. + if (!t.trim()) return ''; + const first = t.split('\n').find((x) => x.trim()) || L('conv.thinking'); + return `
💭 ${esc(L('conv.thinking'))} · ${esc(first.slice(0, 60))}
${md(t)}
`; +} + +// A Skill envelope is an automatic Codex context-load event. Its recorded body is the exact +// snapshot used for that turn, so keep it collapsed by default but make the full source available +// for later workflow/debug reviews. It deliberately carries no user/assistant role label. +function renderSkillLoad(b) { + const name = String(b.name || '').trim() || 'Skill'; + const path = String(b.path || '').trim(); + const snapshot = String(b.snapshot || ''); + const target = path ? shortPath(path) : ''; + const size = resultSummary(snapshot); + const source = path + ? `
${esc(L('conv.skillSource'))}${esc(path)}
` + : ''; + const disclosure = snapshot + ? `
${esc(L('conv.skillSnapshot'))}${size ? `${esc(size)}` : ''}
${source}${codeBlock(snapshot, 'markdown')}
` + : `
${esc(L('conv.skillNoSnapshot'))}
`; + return `
🧩${esc(L('conv.skillLoaded'))}${esc(name)}${target ? `${esc(target)}` : ''}
${disclosure}
`; +} + +function turnMeta(m) { + const bits = []; + if (m.modelActual) bits.push(esc(m.modelActual)); + if (m.usage) { + const tokenTotal = (m.usage.inputTokens || 0) + (m.usage.outputTokens || 0) + + (m.usage.cacheRead || 0) + (m.usage.cacheCreation || 0); + // A credit-bearing, all-zero Qoder usage object means token accounting was not recorded. + // Do not turn that absence into a misleading "0↑ 0↓" badge. + if (m.usage.credits == null || tokenTotal > 0) { + bits.push(`${fmtTok(m.usage.inputTokens)}↑ ${fmtTok(m.usage.outputTokens)}↓`); + } + if (m.usage.credits != null) bits.push(`${fmtCredits(m.usage.credits)} ${esc(L('conv.credits'))}`); + } + if (m.usage && m.usage.cacheRead) bits.push(`${fmtTok(m.usage.cacheRead)} ${esc(L('conv.cache'))}`); + if (m.stopReason && m.stopReason !== 'end_turn' && m.stopReason !== 'tool_use') bits.push(esc(m.stopReason)); + return bits.length ? `
${bits.map((b) => `${b}`).join('')}
` : ''; +} + +// Returns the HTML for one message, or '' for a pure tool_result / hidden meta user turn. +// Structured metadata such as a loaded Skill is rendered before the role branches so it reads +// as an event in the timeline, rather than being mislabeled as either the user or the assistant. +// inSub: rendered inside a nested subagent block — suppress the per-turn "subagent" badge +// (the surrounding block already labels it) so the nested thread stays clean. +export function renderMessage(m, results, idx, inSub) { + const mid = idx == null ? '' : ` id="m${idx}" data-mi="${idx}"`; + const blocks = normContent(m.content); + const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); + if (skillLoads.length) { + return `
${skillLoads.map(renderSkillLoad).join('')}
`; + } + if (m.role === 'user') { + const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); + if (!vis.length) return ''; + // Strip harness-injected noise but keep the human prose — the first user turn carries an + // appended , and the old "contains a tag → drop the whole turn" rule made + // that turn (the one that also seeds the title) disappear from the panel. + const clean = vis + .map((b) => (b.type === 'text' ? { type: 'text', text: stripInjected(b.text) } : b)) + .filter((b) => b.type === 'image' || b.text); + if (!clean.length) return ''; + return `
👤 ${esc(L('conv.you'))}
${clean.map(renderUserBlock).join('')}
`; + } + let body = ''; + blocks.forEach((b) => { + if (b.type === 'text') body += `
${md(b.text)}
`; + else if (b.type === 'thinking') body += renderThinking(b); + else if (b.type === 'tool_use') body += renderToolCard(b, results[b.id]); + else if (b.type === 'image') body += renderUserBlock(b); + else body += `
${esc(JSON.stringify(b))}
`; + }); + if (!body) return ''; + return `
✦ ${esc(assistantName())}${m.isSidechain && !inSub ? ` ${esc(L('conv.subagent'))}` : ''}
${body}${turnMeta(m)}
`; +} diff --git a/src/renderer/js/views/conversations/meta-edit.js b/src/renderer/js/views/conversations/meta-edit.js new file mode 100644 index 0000000..31649a8 --- /dev/null +++ b/src/renderer/js/views/conversations/meta-edit.js @@ -0,0 +1,154 @@ +/* Per-session customization: inline rename, tags, soft delete / restore / delete-forever. */ +import { api } from '../../core/bridge.js'; +import { confirmDialog } from '../../core/toast.js'; +import { cssAttr, isForeignSource, L } from './format.js'; +import { icons } from '../../core/icons.js'; +import { cs, findSession } from './state.js'; +import { esc } from './format.js'; + +// Persist a title/tags patch for one conversation, then refresh. The backend also broadcasts +// history:changed (which refreshes too) — the explicit refresh just makes it feel instant. +async function applyMeta(file, patch, refresh) { + if (!file || !api.historySetMeta) return; + try { await api.historySetMeta(file, patch); } catch (_) {} + await refresh(); +} + +function itemEl(id, file) { + return document.querySelector(`.conv-item[data-id="${cssAttr(id)}"]`) + || (file ? document.querySelector(`.conv-item[data-file="${cssAttr(file)}"]`) : null); +} + +// Swap a node for a single-line text input; commit on Enter/blur, cancel on Escape. The `done` +// guard makes Enter-then-blur (or Esc-then-blur) run the callback exactly once. onCommit(value|null): +// null = cancelled, '' = emptied (callers treat empty as clear/no-op), else the trimmed value. +function inlineEdit(node, opts) { + const inp = document.createElement('input'); + inp.className = opts.cls; + inp.value = opts.value || ''; + if (opts.placeholder) inp.setAttribute('placeholder', opts.placeholder); + node.replaceWith(inp); + inp.focus(); inp.select(); + let done = false; + const finish = (save) => { if (done) return; done = true; opts.onCommit(save ? inp.value.trim() : null); }; + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + inp.addEventListener('blur', () => finish(true)); +} + +export function startRename(file, id, deps) { + const item = itemEl(id, file); if (!item) return; + const titleEl = item.querySelector('.conv-title'); if (!titleEl) return; + const s = findSession(id, file); + inlineEdit(titleEl, { + cls: 'conv-title-edit', value: (s && s.title) || '', placeholder: L('conv.renamePlaceholder'), + onCommit: (v) => { if (v == null) { deps.renderList(); return; } applyMeta(file, { title: v }, deps.refresh); }, // '' clears → auto title + }); +} + +export function startAddTag(file, id, deps) { + const item = itemEl(id, file); if (!item) return; + let row = item.querySelector('.conv-item-tags'); + if (!row) { + row = document.createElement('div'); + row.className = 'conv-item-tags'; + row.dataset.file = file; + const sub = item.querySelector('.conv-item-sub'); + if (sub) sub.after(row); else item.appendChild(row); + } + const holder = document.createElement('span'); + row.appendChild(holder); + inlineEdit(holder, { + cls: 'conv-tag-edit', value: '', placeholder: L('conv.tagPlaceholder'), + onCommit: (v) => { + if (!v) { deps.renderList(); return; } + const s = findSession(id, file); + applyMeta(file, { tags: ((s && s.tags) || []).concat([v]) }, deps.refresh); + }, + }); +} + +export function startEditTag(file, oldTag, chip, deps) { + if (!chip) return; + inlineEdit(chip, { + cls: 'conv-tag-edit', value: oldTag, placeholder: L('conv.tagPlaceholder'), + onCommit: (v) => { + if (v == null) { deps.renderList(); return; } + const s = findSession(null, file); + const cur = (s && s.tags) || []; + const nextTags = v ? cur.map((t) => (t === oldTag ? v : t)) : cur.filter((t) => t !== oldTag); // empty = delete + if (cs.tagFilter === oldTag) cs.tagFilter = v || null; + applyMeta(file, { tags: nextTags }, deps.refresh); + }, + }); +} + +export async function deleteTag(file, tag, deps) { + const s = findSession(null, file); + await applyMeta(file, { tags: ((s && s.tags) || []).filter((t) => t !== tag) }, deps.refresh); +} + +// Soft delete: confirm, flag __ccbud__.delete=true, then refresh (the session drops out of every +// normal view and reappears only in the recycle bin). +export async function softDelete(file, deps) { + if (!file) return; + const ok = await confirmDialog({ title: L('conv.deleteTitle'), message: L('conv.deleteConfirm'), confirmText: L('conv.ctxDelete'), cancelText: L('modal.cancel'), danger: true }); + if (!ok) return; + if (file === cs.openFile) { cs.openId = null; cs.openFile = null; deps.syncConvNav(); } + await applyMeta(file, { delete: true }, deps.refresh); +} + +export async function restoreSession(file, deps) { + if (!file) return; + await applyMeta(file, { delete: false }, deps.refresh); // drop the flag → back to its working dir +} + +export async function deleteForever(file, deps) { + if (!file || !api.historyDeleteForever) return; + const ok = await confirmDialog({ title: L('conv.deleteForeverTitle'), message: L('conv.deleteForeverConfirm'), confirmText: L('conv.deleteForever'), cancelText: L('modal.cancel'), danger: true }); + if (!ok) return; + let res; try { res = await api.historyDeleteForever(file); } catch (_) { res = null; } + if (!res || !res.ok) return; + if (file === cs.openFile) { cs.openId = null; cs.openFile = null; deps.syncConvNav(); } + await deps.refresh(); +} + +// Right-click context menu on a conversation row: rename / add tag / delete (or restore / +// delete-forever in the recycle bin). A single body-level element, re-targeted per open (the +// list re-renders, so a list-child menu would be wiped out). +let ctxMenuEl = null; +export function hideCtxMenu() { if (ctxMenuEl) ctxMenuEl.classList.add('hidden'); } + +export function showCtxMenu(x, y, file, id, deps) { + if (!ctxMenuEl) { + ctxMenuEl = document.createElement('div'); + ctxMenuEl.className = 'conv-ctx-menu hidden'; + document.body.appendChild(ctxMenuEl); + ctxMenuEl.addEventListener('click', (e) => { + const it = e.target.closest('[data-ctx]'); if (!it) return; + const act = it.dataset.ctx, f = ctxMenuEl._file, i = ctxMenuEl._id; + hideCtxMenu(); + if (act === 'rename') startRename(f, i, deps); + else if (act === 'addtag') startAddTag(f, i, deps); + else if (act === 'delete') softDelete(f, deps); + else if (act === 'restore') restoreSession(f, deps); + else if (act === 'deleteforever') deleteForever(f, deps); + }); + } + ctxMenuEl._file = file; ctxMenuEl._id = id; + // A live session of another CLI can be restored but never permanently deleted (the file + // belongs to that tool); imported copies (which live in our store) keep delete-forever. + const s = findSession(id, file); + const foreign = s && isForeignSource(s.source) && !s.imported; + ctxMenuEl.innerHTML = (cs.activeDir === '__trash__') + ? `` + + (foreign ? '' : ``) + : `` + + `` + + ``; + ctxMenuEl.classList.remove('hidden'); + ctxMenuEl.style.left = Math.min(x, window.innerWidth - 180) + 'px'; + ctxMenuEl.style.top = Math.min(y, window.innerHeight - 80) + 'px'; +} diff --git a/src/renderer/js/views/conversations/panels.js b/src/renderer/js/views/conversations/panels.js new file mode 100644 index 0000000..8d9ff65 --- /dev/null +++ b/src/renderer/js/views/conversations/panels.js @@ -0,0 +1,102 @@ +/* Right rail: session overview stats + the user-turn table of contents. */ +import { $ } from '../../core/dom.js'; +import { esc, fmtTok, fmtCredits, normContent, projName, L } from './format.js'; +import { stripInjected } from './codex-format.js'; +import { cs, activeMessages } from './state.js'; +import { subName, subUsageSummary } from './subagents.js'; + +// The right rail (overview + navigation) only means something for an open conversation — hide +// it (and its resizer) entirely when nothing is selected. +export function syncConvNav() { + const nav = document.querySelector('.conv-nav'); + const rs = document.querySelector('.conv-resizer-right'); + if (nav) nav.classList.toggle('hidden', !cs.openFile); + if (rs) rs.classList.toggle('hidden', !cs.openFile); + if (!cs.openFile) cs.detailRetry = null; +} + +/** Overview rows for the session in the panel (the active subagent's when one is selected). */ +function statRows(detail) { + const m = detail.meta || {}; + // Invoking skill of the session in the panel: the active subagent's when one is selected, + // else the session's own (a standalone subagent transcript). Absent → row filtered out. + const panelSub = cs.activeAgent !== 'main' && detail.subagents ? detail.subagents[cs.activeAgent] : null; + const t = (panelSub && panelSub.totals) || m.totals || {}; + const messageCount = panelSub + ? (panelSub.count != null ? panelSub.count : (panelSub.messages || []).length) + : m.messages; + const skill = panelSub ? panelSub.skill : m.skill; + return [ + [L('conv.stat.title'), m.title], + [L('conv.stat.model'), m.model], + [L('conv.stat.skill'), skill || null], + ...(m.isSubagent ? [[L('conv.stat.type'), L('conv.subagentSession')]] : []), + ...(m.imported ? [[L('conv.imported'), m.importedFrom || '✓']] : []), + [L('conv.stat.project'), m.cwd ? projName(m.cwd) : m.project], + [L('conv.stat.branch'), m.gitBranch], + [L('conv.stat.session'), m.sessionId ? String(m.sessionId).slice(0, 8) : null], + [L('conv.stat.rootSession'), m.rootSessionId && m.rootSessionId !== m.sessionId ? String(m.rootSessionId).slice(0, 8) : null], + [L('conv.stat.parentThread'), m.parentThreadId ? String(m.parentThreadId).slice(0, 8) : null], + [L('conv.stat.agent'), m.agentNickname], + [L('conv.stat.agentPath'), m.agentPath], + [L('conv.stat.messages'), messageCount], + [L('conv.stat.turns'), t.turns], + [L('conv.stat.input'), t.tokenUsageAvailable === false ? '—' : (t.in != null ? fmtTok(t.in) : null)], + [L('conv.stat.output'), t.tokenUsageAvailable === false ? '—' : (t.out != null ? fmtTok(t.out) : null)], + [L('conv.stat.credits'), t.credits != null ? fmtCredits(t.credits) : null], + [L('conv.stat.cacheRead'), t.cacheRead ? fmtTok(t.cacheRead) : null], + [L('conv.stat.tool'), m.assistant || 'Claude Code'], + [L('conv.stat.version'), m.version], + ].filter((r) => r[1] != null && r[1] !== ''); +} + +export function renderSidePanels(detail) { + $('convStats').innerHTML = statRows(detail).map((r) => `
${esc(r[0])}${esc(r[1])}
`).join(''); + + // TOC is built from the message DATA (global indices) so it spans the WHOLE thread even though only + // a window is rendered; clicking jumps the window to that message. Keyed on user turns — the natural + // navigation points — which also keeps the sidebar light on huge threads. + const messages = activeMessages(); // TOC follows the session shown in the main panel + const toc = []; + messages.forEach((m, i) => { + if (m.role !== 'user' || m._meta || m.meta) return; + const vis = normContent(m.content).filter((b) => b.type === 'text'); + const tv = vis.map((b) => stripInjected(b.text)).filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (!tv) return; + toc.push(`
👤 ${esc(tv.slice(0, 32) || '…')}
`); + }); + $('convToc').innerHTML = toc.join(''); +} + +/* + * Session tabs (top of the main panel). When a conversation spawned subagents, the panel header + * shows peer tabs: [主会话] [子代理 (N) ▾]. 主会话 and each subagent are equals — picking one + * moves the WHOLE panel to that session. + */ +export function renderAgentTabs(detail) { + const host = $('convAgentTabs'); + if (!host) return; + const subs = (detail && detail.subagents) || {}; + const keys = Object.keys(subs); + if (!keys.length) { host.innerHTML = ''; host.classList.add('hidden'); host.classList.remove('flex'); cs.agentMenuOpen = false; return; } + host.classList.remove('hidden'); host.classList.add('flex'); + const mainActive = cs.activeAgent === 'main'; + const activeSub = !mainActive && subs[cs.activeAgent] ? subs[cs.activeAgent] : null; + const seg = (active) => `inline-flex items-center gap-1.5 h-[28px] px-3 rounded-[8px] text-[12px] font-semibold cursor-pointer border transition-colors whitespace-nowrap ${active ? 'bg-brand-soft text-brand border-brand/25' : 'bg-bg-elev text-muted border-border-custom hover:text-fg hover:bg-chip-bg'}`; + const mainTab = ``; + const ddLabel = activeSub ? `🤖 ${esc(subName(activeSub))}` : `🤖 ${esc(L('conv.stat.subagents'))} (${keys.length})`; + const items = keys.map((k) => { + const s = subs[k] || {}; + const cnt = s.count != null ? s.count : ((s.messages || []).length); + const active = cs.activeAgent === k; + const desc = s.description ? `
${esc(s.description)}
` : ''; + return ``; + }).join(''); + const menu = `
${items}
`; + const dd = `
${menu}
`; + host.innerHTML = mainTab + dd; +} diff --git a/src/renderer/js/views/conversations/search.js b/src/renderer/js/views/conversations/search.js new file mode 100644 index 0000000..a89e1a9 --- /dev/null +++ b/src/renderer/js/views/conversations/search.js @@ -0,0 +1,181 @@ +/* + * In-conversation search — DATA-driven: matches are found in the parsed message text (fast, no + * DOM) across EVERY thread of the open session, so search never has to render the whole thread. + * Navigation steps through matching messages and switches the panel across thread boundaries. + */ +import { $ } from '../../core/dom.js'; +import { esc, escapeRegExp, normContent, contentToText } from './format.js'; +import { stripInjected } from './codex-format.js'; +import { cs, threadMessages } from './state.js'; +import { buildResults } from './message.js'; +import { buildSubIndex } from './subagents.js'; +import { jumpToMessage } from './window.js'; + +const hasHighlightAPI = () => !!(window.CSS && CSS.highlights && typeof Highlight !== 'undefined'); + +export function clearDetailSearchHighlights() { + if (hasHighlightAPI()) { CSS.highlights.delete('cd-search'); CSS.highlights.delete('cd-current'); } + cs.searchOcc = []; cs.searchIndex = -1; cs.searchQuery = ''; cs.searchTotalOcc = 0; + const countEl = $('convDetailSearchCount'); + if (countEl) countEl.textContent = ''; +} + +// Mirror renderMessage's logic so a thread's texts[i] is non-empty iff message i actually renders, +// and holds the SAME searchable text (incl. tool results, which render inside the assistant's tool +// card — not the user turn that carries them). Keeps search matches aligned with rendered messages. +function messagePlainText(m, results) { + const blocks = normContent(m.content); + const skillLoads = blocks.filter((b) => b && b.type === 'skill_load'); + if (skillLoads.length) { + return skillLoads.map((b) => [b.name, b.path, b.snapshot].filter(Boolean).join('\n')).join('\n'); + } + if (m.role === 'user') { + const vis = blocks.filter((b) => b.type === 'text' || b.type === 'image'); + if (!vis.length) return ''; + // Same stripping renderMessage applies: injected reminders/commands are unsearchable, but the + // human prose beside them (e.g. the first turn, which carries a reminder) IS. + return vis.map((b) => (b.type === 'text' ? stripInjected(b.text) : '')).filter(Boolean).join('\n'); + } + let s = ''; + for (const b of blocks) { + if (b.type === 'text') s += (b.text || '') + '\n'; + else if (b.type === 'thinking') s += (b.thinking || '') + '\n'; + else if (b.type === 'tool_use') { s += (b.name || '') + ' ' + (b.input ? JSON.stringify(b.input) : '') + '\n'; const r = results && results[b.id]; if (r) s += contentToText(r.content) + '\n'; } + } + return s; +} + +// Thread keys in reading order — main first, then subagents by where they were spawned in the +// main thread (unresolved call sites sort last). Cross-agent search steps through this order. +export function searchAgentOrder() { + const subs = (cs.currentDetail && cs.currentDetail.subagents) || {}; + const keys = Object.keys(subs); + if (!keys.length) return ['main']; + if (!cs.subIndex) buildSubIndex(); + const pos = (k) => { const site = cs.subIndex.callSite.get(k); return site && site.thread === 'main' ? site.mi : Infinity; }; + keys.sort((a, b) => pos(a) - pos(b)); + return ['main'].concat(keys); +} + +function buildSearchDocs() { + cs.searchDocs = new Map(); + for (const agent of searchAgentOrder()) { + const msgs = threadMessages(agent); + const results = buildResults(msgs); + cs.searchDocs.set(agent, msgs.map((m) => messagePlainText(m, results))); + } +} + +// Highlight every match inside the CURRENT window via the CSS Custom Highlight API (Range-based, zero +// DOM mutation). The window is bounded, so this is tiny + fast. Re-run after each window paint. +export function refreshWindowHighlights() { + if (!hasHighlightAPI()) return; + const host = $('convDetail'); + if (!host || !cs.searchQuery) { CSS.highlights.delete('cd-search'); return; } + let re; try { re = new RegExp(escapeRegExp(cs.searchQuery), 'gi'); } catch (_) { return; } + const h = new Highlight(); + const w = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, null); let node; + while ((node = w.nextNode())) { + const text = node.nodeValue; if (!text || !text.trim()) continue; re.lastIndex = 0; let m; + while ((m = re.exec(text)) !== null) { + try { const r = document.createRange(); r.setStart(node, m.index); r.setEnd(node, m.index + m[0].length); h.add(r); } catch (_) {} + if (m[0].length === 0) re.lastIndex++; + } + } + CSS.highlights.set('cd-search', h); +} + +export function updateSearchCount() { + const c = $('convDetailSearchCount'); if (!c) return; + if (!cs.searchOcc.length) { c.textContent = cs.searchQuery ? '0/0' : ''; return; } + const pos = cs.searchIndex >= 0 ? String(cs.searchIndex + 1) : '–'; + c.textContent = `${pos}/${cs.searchOcc.length}` + (cs.searchTotalOcc > cs.searchOcc.length ? ` · ${cs.searchTotalOcc}` : ''); +} + +// Run the message search across EVERY thread (main + subagents). Landing rules: +// - opts.agent (big-search auto-locate): jump to that thread's first match, switching the panel; +// - opts.first (Enter confirm): jump to the first match overall, switching if needed; +// - opts.silent (live refresh): recompute counts/highlights only, never move the view; +// - default (typing): jump only within the CURRENT thread — matches elsewhere just show in the +// count until the user navigates (Enter / ↑↓), so the panel never switches under the cursor. +export function performDetailSearch(query, opts) { + opts = opts || {}; + cs.searchQuery = query || ''; + if (hasHighlightAPI()) { CSS.highlights.delete('cd-search'); CSS.highlights.delete('cd-current'); } + cs.searchOcc = []; cs.searchIndex = -1; cs.searchTotalOcc = 0; + const c = $('convDetailSearchCount'); + if (!query) { if (c) c.textContent = ''; return; } + if (!cs.searchDocs) buildSearchDocs(); + let re; try { re = new RegExp(escapeRegExp(query), 'gi'); } catch (_) { return; } + // Scan the parsed message texts (NOT the DOM) — each matching message lists once in searchOcc, + // and every match inside it is highlighted on arrival. Map iteration = insertion order = + // reading order (main first, then subagents by call site), so navigation is deterministic. + for (const [agent, texts] of cs.searchDocs) { + for (let i = 0; i < texts.length; i++) { + const t = texts[i]; if (!t) continue; re.lastIndex = 0; let m, has = false; + while ((m = re.exec(t)) !== null) { cs.searchTotalOcc++; has = true; if (m[0].length === 0) re.lastIndex++; } + if (has) cs.searchOcc.push({ agent, mi: i }); + } + } + if (!cs.searchOcc.length) { if (c) c.textContent = '0/0'; return; } + if (opts.silent) { updateSearchCount(); refreshWindowHighlights(); return; } + let target = -1; + if (opts.agent) { target = cs.searchOcc.findIndex((o) => o.agent === opts.agent); if (target < 0) target = 0; } + else if (opts.first) target = 0; + else target = cs.searchOcc.findIndex((o) => o.agent === cs.activeAgent); + if (target >= 0) gotoDetailSearchMatch(target); + else { updateSearchCount(); refreshWindowHighlights(); } // matches exist, none here — count only +} + +// Navigate to match #newIndex (wraps): switch the panel to the match's thread when it lives in a +// different agent, bring the message into the window, highlight every match in the window, and +// mark + centre the first match in the target message. Bounded — never renders a whole thread. +export function gotoDetailSearchMatch(newIndex, setPanelAgent) { + const len = cs.searchOcc.length; if (!len) return; + cs.searchIndex = ((newIndex % len) + len) % len; + const occ = cs.searchOcc[cs.searchIndex]; + if (occ.agent !== cs.activeAgent && setPanelAgent) setPanelAgent(occ.agent); // cross-agent step + const mi = occ.mi; + jumpToMessage(mi, 'center'); + refreshWindowHighlights(); + const host = $('convDetail'); + const el = host && host.querySelector(`[data-mi="${mi}"]`); + const skillSnapshot = el && el.querySelector('.skill-snapshot'); + const skillBody = skillSnapshot && skillSnapshot.querySelector('.skill-snapshot-body'); + if (skillSnapshot && skillBody && cs.searchQuery + && String(skillBody.textContent || '').toLocaleLowerCase().includes(cs.searchQuery.toLocaleLowerCase())) { + skillSnapshot.open = true; + refreshWindowHighlights(); + } + if (el && hasHighlightAPI() && cs.searchQuery) { + let re; try { re = new RegExp(escapeRegExp(cs.searchQuery), 'gi'); } catch (_) { re = null; } + let curRange = null; + if (re) { + const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); let node; + while ((node = w.nextNode()) && !curRange) { + const text = node.nodeValue; if (!text) continue; re.lastIndex = 0; const m = re.exec(text); + if (m) { curRange = document.createRange(); curRange.setStart(node, m.index); curRange.setEnd(node, m.index + m[0].length); } + } + } + if (curRange) { + const cur = new Highlight(); cur.add(curRange); CSS.highlights.set('cd-current', cur); + const rect = curRange.getBoundingClientRect(); const hr = host.getBoundingClientRect(); + if (rect && hr && rect.height) host.scrollTop += (rect.top - hr.top) - host.clientHeight / 2; + } + } + updateSearchCount(); +} + +/** Escape a content snippet and wrap query matches in for the session row. */ +export function markSnippet(text, q) { + const s = String(text || ''); + if (!q) return esc(s); + let re; try { re = new RegExp(escapeRegExp(q), 'gi'); } catch (_) { return esc(s); } + let out = '', last = 0, m; + while ((m = re.exec(s)) !== null) { + out += esc(s.slice(last, m.index)) + '' + esc(m[0]) + ''; + last = m.index + m[0].length; + if (m[0].length === 0) re.lastIndex++; + } + return out + esc(s.slice(last)); +} diff --git a/src/renderer/js/views/conversations/state.js b/src/renderer/js/views/conversations/state.js new file mode 100644 index 0000000..fd1eb9b --- /dev/null +++ b/src/renderer/js/views/conversations/state.js @@ -0,0 +1,89 @@ +/* + * 对话 view shared state — one mutable `cs` object owned here so the split modules + * (list / detail / search / subagents / …) share a single source of truth without + * export-binding gymnastics. + */ + +// Render only the most recent N messages of a thread; a "load earlier" control reveals more. +// Huge threads (1000s of turns) otherwise put 1000s of nodes in the DOM, so every window +// resize / live re-render walks the whole tree (~1s) — the measured root cause of the jank. +// Windowed (virtualized) rendering: only a window [vStart, vEnd) of the thread is ever in the DOM. +export const DETAIL_WIN = 160; // window size when opening / jumping (~115 rendered after skips) +export const LOAD_MORE = 120; // messages revealed per load-earlier / load-later click +export const MAX_WIN = 240; // hard cap on rendered messages — load-more trims the far end past this. + +export const cs = { + projects: [], // [{ cwd, name, sessions:[...], lastActivity }] + openId: null, + openFile: null, + search: '', + // Big-search content matching (backend scan of session bodies — main, subagents, codex): + contentHits: null, // Map for the current query; null = no content results yet + contentSearching: false, // a backend content scan is in flight (list shows a "searching" hint) + contentSeq: 0, // staleness guard: results from a superseded query are dropped + contentTimer: null, + pendingLocate: null, // { query, agent } — auto-locate target consumed after opening a content hit + activeDir: 'all', // active history bucket; '__trash__' = recycle bin (deleted sessions) + tagFilter: null, // when set, the list shows only conversations carrying this exact tag + tagClickTimer: null, // debounces a tag's single-click (filter) so a double-click (edit) can cancel it + listTimer: null, + collapsed: new Set(), // collapsed project cwds + lastRender: { file: null, count: -1 }, + currentDetail: null, // last-loaded session detail (for export) + detailRequestSeq: 0, // drops a late historyGet result after another session/request took over + detailRequest: null, // latest in-flight { seq, file }; also prevents timer requests piling up + // Failed detail reads retry via the safety-net timer: { file, attempts, nextAt }. + // permissionDenied probes steadily (granting macOS access emits no event we could watch); + // other read/IPC failures back off exponentially so a permanently broken transcript isn't + // re-read — and on macOS re-spawned through the helper — every 4 seconds forever. + detailRetry: null, + // Which session occupies the main panel: 'main' (the root thread) or a subagent key (its tool_use + // id in detail.subagents). Each subagent is an independent session, so it gets the WHOLE panel — + // switched via the agent list in the right nav, not nested inline. Reset to 'main' on open. + activeAgent: 'main', + vStart: 0, vEnd: 0, // rendered window into the active thread's messages + // Per-message plain text for data-driven search: Map in reading order + // ('main' first, then subagents by call site). Built lazily on first search and invalidated + // on change. A Map so transcript-supplied keys can't collide with Object.prototype. + searchDocs: null, + // Detail search state (data-driven, spans every thread of the open session). + searchOcc: [], // [{ agent, mi }] — messages with ≥1 match, in reading order + searchIndex: -1, // position in searchOcc; -1 = matches known but not navigated yet + searchQuery: '', + searchTotalOcc: 0, // total match occurrences across all threads (shown after the count) + subIndex: null, // callSite map: subKey -> { thread, mi }; built lazily per open session + agentMenuOpen: false, +}; + +try { cs.collapsed = new Set(JSON.parse(localStorage.getItem('ccbud-collapsed-projects') || '[]')); } catch (_) {} +export function persistCollapsed() { + try { localStorage.setItem('ccbud-collapsed-projects', JSON.stringify([...cs.collapsed])); } catch (_) {} +} + +/** Message list of one thread of the open session ('main' or a subagent key). */ +export function threadMessages(agent) { + if (!cs.currentDetail) return []; + if (agent !== 'main' && cs.currentDetail.subagents && cs.currentDetail.subagents[agent]) { + return cs.currentDetail.subagents[agent].messages || []; + } + return agent === 'main' ? (cs.currentDetail.messages || []) : []; +} +/** The message list currently shown in the main panel (main thread or the active subagent's). */ +export function activeMessages() { return threadMessages(cs.activeAgent); } + +export function isLive(ts) { return ts && (Date.now() - ts) < 90000; } + +// Is the currently-open session still active (recent on-disk activity)? Used to drive the +// safety-net auto-refresh so in-progress conversations live-update even if a watch event is missed. +export function openSessionLive() { + if (!cs.openId) return false; + for (const p of cs.projects) for (const s of p.sessions) if (s.id === cs.openId) return isLive(s.lastActivity); + return false; +} + +// Locate a loaded session by file (unique) or id — used by the rename/tag handlers to read its +// current title/tags before writing an updated set back. +export function findSession(id, file) { + for (const p of cs.projects) for (const s of p.sessions) if ((file && s.file === file) || (id && s.id === id)) return s; + return null; +} diff --git a/src/renderer/js/views/conversations/subagents.js b/src/renderer/js/views/conversations/subagents.js new file mode 100644 index 0000000..4d832bb --- /dev/null +++ b/src/renderer/js/views/conversations/subagents.js @@ -0,0 +1,97 @@ +/* + * Inline subagents — a subagent dialogue is keyed by the tool_use id that spawned it and rendered + * as a lazily-filled disclosure directly under that call, at any nesting depth (a subagent's own + * tool cards run through this same path). Bodies stay empty until opened, to bound the DOM. + */ +import { $ } from '../../core/dom.js'; +import { esc, fmtTok, fmtCredits, normContent, cssAttr, L } from './format.js'; +import { cs } from './state.js'; +import { highlight } from './code.js'; + +/** Display name: agent type, suffixed with the invoking skill (`type:skill`) when attributed. */ +export function subName(s) { return (s.type || 'agent') + (s.skill ? ':' + s.skill : ''); } + +export function subUsageSummary(s) { + const totals = (s && s.totals) || {}; + const bits = []; + if (totals.tokenUsageAvailable !== false) bits.push(`${fmtTok(totals.out || 0)}↓`); + if (totals.credits != null) bits.push(`${fmtCredits(totals.credits)} ${L('conv.credits')}`); + return bits.join(' · ') || '—'; +} + +export function inlineSubagentBlock(id) { + const subs = (cs.currentDetail && cs.currentDetail.subagents) || {}; + const s = id && subs[id]; + if (!s) return ''; + const cnt = s.count != null ? s.count : ((s.messages || []).length); + const meta = `${esc(L('conv.subagentMsgs', { n: cnt }))} · ${esc(subUsageSummary(s))}`; + const desc = s.description ? ` · ${esc(s.description)}` : ''; + return `
🤖 ${esc(L('conv.subagent'))} · ${esc(subName(s))}${desc}${meta}
`; +} + +// Render one subagent's whole thread (recursively wiring its own inline subagents via renderMessage → +// renderToolCard). idx=null so nested turns carry no data-mi (they're outside main-window navigation). +async function renderSubThread(key) { + const { renderMessage, buildResults } = await import('./message.js'); + const s = cs.currentDetail && cs.currentDetail.subagents && cs.currentDetail.subagents[key]; + if (!s) return ''; + const msgs = s.messages || []; + if (!msgs.length) return `
${esc(L('conv.emptyConv'))}
`; + const results = buildResults(msgs); + return msgs.map((m) => renderMessage(m, results, null, true)).join('') || `
${esc(L('conv.emptyConv'))}
`; +} + +/** Fill a subagent disclosure's body on first open (no-op afterwards). Returns the body element. */ +export function fillSubBody(det) { + const body = det && det.querySelector(':scope > [data-sub-body]'); + if (!body) return null; + if (!body.dataset.filled) { + body.dataset.filled = '1'; + renderSubThread(body.getAttribute('data-sub-body')).then((html) => { + body.innerHTML = html; + highlight(body); + }); + } + return body; +} + +// Map every subagent to where it was spawned: callSite.get(subKey) = { thread, mi } where thread is +// 'main' or another subagent's key (nested spawns), and mi is the message index in that thread. Built +// lazily per open session and reset when the session changes. +export function buildSubIndex() { + const subs = (cs.currentDetail && cs.currentDetail.subagents) || {}; + const keys = new Set(Object.keys(subs)); + const callSite = new Map(); + const scan = (msgs, threadKey) => (msgs || []).forEach((m, i) => normContent(m.content).forEach((b) => { + if (b.type === 'tool_use' && keys.has(b.id) && !callSite.has(b.id)) callSite.set(b.id, { thread: threadKey, mi: i }); + })); + scan((cs.currentDetail && cs.currentDetail.messages) || [], 'main'); + for (const k of keys) scan(subs[k].messages, k); + cs.subIndex = { callSite }; +} + +// Ancestor chain from the outermost (spawned in main) down to `key`, e.g. [topSub, …, key]. Empty if +// the call site can't be resolved (e.g. a subagent whose meta recorded no toolUseId). +export function subChain(key) { + if (!cs.subIndex) buildSubIndex(); + const chain = []; const seen = new Set(); let cur = key; + while (cur && cur !== 'main' && !seen.has(cur)) { + seen.add(cur); chain.unshift(cur); + const cSite = cs.subIndex.callSite.get(cur); + if (!cSite) return []; // broken link — can't place it in context + cur = cSite.thread; + } + return chain; +} + +/** Expand the disclosure chain down to `key` inside the currently-painted window. */ +export function expandChain(chain) { + const host = $('convDetail'); + let det = null; + if (host) for (const k of chain) { + det = host.querySelector(`.subagent-inline[data-sub="${cssAttr(k)}"]`); + if (!det) break; + fillSubBody(det); det.open = true; // child level now exists in the DOM for the next iteration + } + return det; +} diff --git a/src/renderer/js/views/conversations/template.js b/src/renderer/js/views/conversations/template.js new file mode 100644 index 0000000..7a978f3 --- /dev/null +++ b/src/renderer/js/views/conversations/template.js @@ -0,0 +1,75 @@ +/* 会话 view markup — injected on first switch (kept off the cold-start path). */ +export const CONVERSATIONS_HTML = ` + `; diff --git a/src/renderer/js/views/conversations/tools.js b/src/renderer/js/views/conversations/tools.js new file mode 100644 index 0000000..eb6d1b7 --- /dev/null +++ b/src/renderer/js/views/conversations/tools.js @@ -0,0 +1,99 @@ +/* Tool-call cards: per-tool icon/label/target/body + the result disclosure. */ +import { esc, truncate, shortPath, resultSummary, L } from './format.js'; +import { codeBlock, langFromPath, stripCatN, mdDoc, isMdPath } from './code.js'; +import { inlineSubagentBlock } from './subagents.js'; + +const PRE = 'pre bg-[#0c0e12] border border-white/7 rounded-[7px] p-2.5 overflow-x-auto font-mono text-[11px] leading-[1.48] text-[#e8edf4] whitespace-pre-wrap break-all'; +const TOOL_CLS = { Bash: 'exec', Script: 'exec', Read: 'read', Edit: 'write', MultiEdit: 'write', Write: 'write', ApplyPatch: 'write', Grep: 'search', Glob: 'search', TodoWrite: 'todo', Task: 'task', WebSearch: 'net', WebFetch: 'net' }; + +export function toolResultText(b) { + const c = b && b.content; + if (typeof c === 'string') return c; + // image blocks render separately (renderToolCard) — stringifying them would dump base64 + if (Array.isArray(c)) return c.filter((x) => !(x && x.type === 'image')).map((x) => (x && x.type === 'text' ? x.text : (x && x.text) || JSON.stringify(x))).join('\n'); + return c == null ? '' : JSON.stringify(c); +} + +function diff(oldS, newS) { + const o = String(oldS || '').split('\n'); + const n = String(newS || '').split('\n'); + return '
' + o.map((l) => `
- ${esc(l)}
`).join('') + n.map((l) => `
+ ${esc(l)}
`).join('') + '
'; +} + +function todos(list) { + return '
' + (list || []).map((t) => { + const m = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; + return `
${m}${esc(t.content || t.activeForm || '')}
`; + }).join('') + '
'; +} + +// Codex apply_patch envelope: "*** Update File: x" headers → the card's target (file, or "N files"). +function patchTarget(patch) { + const files = []; + String(patch || '').split('\n').forEach((l) => { + const m = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)$/.exec(l.trim()); + if (m) files.push(m[1].trim()); + }); + if (!files.length) return ''; + return files.length === 1 ? shortPath(files[0]) : L('conv.patchFiles', { n: files.length }); +} + +/** Per-tool head/body shaping. Returns { icon, label, target, bodyInput, cls }. */ +function shapeTool(name, input) { + const cls = /^mcp__/.test(name) ? 'mcp' : (TOOL_CLS[name] || 'default'); + let icon = '🔧', label = name, target = '', bodyInput = ''; + if (name === 'Bash') { icon = '⌘'; label = 'Bash'; target = input.description || ''; bodyInput = codeBlock(input.command || '', 'bash'); } + // Codex code-mode orchestration scripts (multi-call / write_stdin / custom JS) — the plain + // shell-run shape is already mapped to Bash by the backend (codex map_exec_script). + else if (name === 'Script') { icon = '📜'; label = 'Script'; bodyInput = codeBlock(truncate(input.code || '', 12000), 'javascript'); } + else if (name === 'Read') { icon = '📖'; label = 'Read'; target = shortPath(input.file_path); } + else if (name === 'Edit') { icon = '✏️'; label = 'Edit'; target = shortPath(input.file_path); bodyInput = diff(input.old_string, input.new_string); } + else if (name === 'MultiEdit') { icon = '✏️'; label = 'MultiEdit'; target = shortPath(input.file_path); bodyInput = Array.isArray(input.edits) && input.edits.length ? input.edits.map((e) => diff(e.old_string, e.new_string)).join('') : `
${esc(L('conv.noEdits'))}
`; } + else if (name === 'Write') { icon = '📝'; label = 'Write'; target = shortPath(input.file_path); const c = truncate(input.content || '', 12000); bodyInput = isMdPath(input.file_path) ? mdDoc(c) : codeBlock(c, langFromPath(input.file_path)); } + else if (name === 'ApplyPatch') { icon = '✏️'; label = 'ApplyPatch'; target = patchTarget(input.patch); bodyInput = codeBlock(truncate(input.patch || '', 12000), 'diff'); } + else if (name === 'Grep') { icon = '🔎'; label = 'Grep'; target = input.pattern || ''; if (input.path) bodyInput = `
in ${esc(input.path)}
`; } + else if (name === 'Glob') { icon = '🔎'; label = 'Glob'; target = input.pattern || ''; } + else if (name === 'TodoWrite') { icon = '✅'; label = 'Todos'; bodyInput = todos(input.todos); } + else if (name === 'Task') { icon = '🤖'; label = 'Task'; target = '→ ' + (input.subagent_type || 'agent'); bodyInput = (input.description ? `
${esc(input.description)}
` : '') + (input.prompt ? `
${esc(truncate(input.prompt, 4000))}
` : ''); } + else if (name === 'WebSearch') { icon = '🌐'; label = 'WebSearch'; target = input.query || ''; } + else if (name === 'WebFetch') { icon = '🌐'; label = 'WebFetch'; target = input.url || ''; } + else if (/^mcp__/.test(name)) { icon = '🧩'; label = 'MCP · ' + name.replace(/^mcp__/, ''); bodyInput = Object.keys(input).length ? codeBlock(JSON.stringify(input, null, 2), 'json') : ''; } + else { bodyInput = Object.keys(input).length ? codeBlock(JSON.stringify(input, null, 2), 'json') : ''; } + return { icon, label, target, bodyInput, cls }; +} + +/** The result disclosure under a tool call (or the "no result yet" pending line). */ +function resultBlock(name, input, resBlock) { + if (!resBlock) return `
— ${esc(L('conv.noResult'))}
`; + const isErr = !!resBlock.is_error; + const txt = toolResultText(resBlock); + const size = resultSummary(txt); + // Read shows the file's content → highlight by extension (+ our own gutter, stripping cat -n); + // other results stay plain text. An empty text renders nothing (no bare empty code box). + const resBody = !txt ? '' : name === 'Read' + ? (isMdPath(input.file_path) + ? mdDoc(stripCatN(truncate(txt, 8000))) + : codeBlock(stripCatN(truncate(txt, 8000)), langFromPath(input.file_path))) + : name === 'Bash' + ? codeBlock(truncate(txt, 8000), 'bash') + : codeBlock(truncate(txt, 8000), ''); + // Screenshot-carrying results (codex code-mode / grok): image blocks render as images. + const resImgs = Array.isArray(resBlock.content) + ? resBlock.content + .filter((x) => x && x.type === 'image' && x.source && x.source.data) + .map((x) => ``) + .join('') + : ''; + return `
${isErr ? '✗ ' + esc(L('conv.errResult')) : '✓ ' + esc(L('conv.result'))}${size ? `${esc(size)}` : ''}
${resBody}${resImgs}
`; +} + +export function renderToolCard(tu, resBlock) { + const name = tu.name || 'tool'; + const input = (tu.input && typeof tu.input === 'object') ? tu.input : {}; + const { icon, label, target, bodyInput, cls } = shapeTool(name, input); + const resHtml = resultBlock(name, input, resBlock); + // If this call spawned a subagent (Task / Agent / Workflow / …, matched by tool_use id), nest its + // transcript right under the call so it's read in the context that produced it. + const subHtml = inlineSubagentBlock(tu.id); + return `
${icon}${esc(label)}${target ? `${esc(target)}` : ''}
${bodyInput ? `
${bodyInput}
` : ''}${resHtml}${subHtml}
`; +} diff --git a/src/renderer/js/views/conversations/tooltip.js b/src/renderer/js/views/conversations/tooltip.js new file mode 100644 index 0000000..e70839c --- /dev/null +++ b/src/renderer/js/views/conversations/tooltip.js @@ -0,0 +1,50 @@ +/* + * Lightweight hover tooltip for truncated fields (overview stats, session titles, project names). + * Shows the FULL value instantly in an app-styled bubble — replaces the slow, system-default native + * `title` tooltip on these. Any element carrying a [data-tip] attribute gets it; event-delegated on + * document so it keeps working across the list's frequent re-renders. + */ +let tipEl = null, cur = null; + +function place(el) { + const txt = el.getAttribute('data-tip'); + if (!txt) return; + // body-level, so outside the Clarity-masked conversations section — mask it + // explicitly: it renders session titles and project paths. + if (!tipEl) { + tipEl = document.createElement('div'); + tipEl.className = 'cc-tip'; + tipEl.setAttribute('data-clarity-mask', 'true'); + document.body.appendChild(tipEl); + } + tipEl.textContent = txt; + tipEl.classList.add('show'); + const r = el.getBoundingClientRect(); + const tw = tipEl.offsetWidth, th = tipEl.offsetHeight; + const left = Math.max(6, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 6)); + let top = r.top - th - 7; // prefer above the field + if (top < 6) top = r.bottom + 7; // flip below when there's no room + tipEl.style.left = left + 'px'; + tipEl.style.top = top + 'px'; +} + +function hide() { cur = null; if (tipEl) tipEl.classList.remove('show'); } + +// Only show the tooltip when the field is actually clipped (has the ellipsis); a fully-visible +// value like "glm-5.2" or "HEAD" needs no bubble. +const clipped = (el) => el.scrollWidth > el.clientWidth + 1; + +export function initTooltips() { + document.addEventListener('mouseover', (e) => { + const el = e.target.closest && e.target.closest('[data-tip]'); + if (el === cur) return; + cur = el || null; + if (el && el.getAttribute('data-tip') && clipped(el)) place(el); else hide(); + }); + document.addEventListener('mouseout', (e) => { + const el = e.target.closest && e.target.closest('[data-tip]'); + if (el && !el.contains(e.relatedTarget)) hide(); + }); + document.addEventListener('scroll', hide, true); + window.addEventListener('blur', hide); +} diff --git a/src/renderer/js/views/conversations/window.js b/src/renderer/js/views/conversations/window.js new file mode 100644 index 0000000..0773fcb --- /dev/null +++ b/src/renderer/js/views/conversations/window.js @@ -0,0 +1,91 @@ +/* + * Windowed (virtualized) detail rendering — only [vStart, vEnd) of the active thread is ever + * in the DOM. Browsing extends it via load-earlier/later; search/TOC jump renders a fresh + * window around the target. Keeps the DOM bounded so collapse/resize/scroll stay cheap. + */ +import { $ } from '../../core/dom.js'; +import { esc, L } from './format.js'; +import { cs, activeMessages, DETAIL_WIN, LOAD_MORE, MAX_WIN } from './state.js'; +import { renderMessage, buildResults } from './message.js'; +import { highlight } from './code.js'; +import { refreshWindowHighlights } from './search.js'; + +function winBtn(dir, n) { + const lbl = esc(L('conv.loadEarlier', { n })); + return ``; +} + +/** HTML for the current window, plus load-earlier/later buttons. */ +function renderWindow() { + const messages = activeMessages(); + const total = messages.length; + if (!total) return `
${esc(L('conv.emptyConv'))}
`; + const results = buildResults(messages); // scan ALL so tool_use cards resolve their result even if out of window + const inSub = cs.activeAgent !== 'main'; // in a subagent view the whole panel is that agent — drop per-turn badge + let html = cs.vStart > 0 ? winBtn('earlier', cs.vStart) : ''; + for (let i = cs.vStart; i < cs.vEnd; i++) html += renderMessage(messages[i], results, i, inSub); + if (cs.vEnd < total) html += winBtn('later', total - cs.vEnd); + return html || `
${esc(L('conv.emptyConv'))}
`; +} + +export function paintWindow() { + const host = $('convDetail'); if (!host) return; + host.innerHTML = renderWindow(); + highlight(host); + refreshWindowHighlights(); // re-paint search highlights for the new window (no-op if not searching) +} + +export function isNearBottom(el) { return el.scrollHeight - el.scrollTop - el.clientHeight < 120; } + +// The first message whose bottom is below the viewport top, with its offset within the viewport — +// used to keep the view fixed across a repaint even when content is both added AND trimmed. +function visibleAnchor() { + const host = $('convDetail'); if (!host) return null; + const hr = host.getBoundingClientRect(); + const els = host.querySelectorAll('[data-mi]'); + for (const el of els) { const r = el.getBoundingClientRect(); if (r.bottom > hr.top + 2) return { mi: +el.dataset.mi, off: r.top - hr.top }; } + return null; +} + +function anchoredPaint(a) { + paintWindow(); + const host = $('convDetail'); + const el = a && host && host.querySelector(`[data-mi="${a.mi}"]`); + if (el) host.scrollTop += (el.getBoundingClientRect().top - host.getBoundingClientRect().top) - a.off; +} + +// Extend the window upward / downward; trim the far end past MAX_WIN so the DOM stays bounded. +// Anchored on a currently-visible message so the viewport doesn't jump despite add+trim. +export function loadEarlier() { + const host = $('convDetail'); if (!host || cs.vStart <= 0) return; + const a = visibleAnchor(); + cs.vStart = Math.max(0, cs.vStart - LOAD_MORE); + if (cs.vEnd - cs.vStart > MAX_WIN) cs.vEnd = cs.vStart + MAX_WIN; // trim the (off-screen) bottom + anchoredPaint(a); +} + +export function loadLater() { + const host = $('convDetail'); if (!host) return; + const total = activeMessages().length; + if (cs.vEnd >= total) return; + const a = visibleAnchor(); + cs.vEnd = Math.min(total, cs.vEnd + LOAD_MORE); + if (cs.vEnd - cs.vStart > MAX_WIN) cs.vStart = cs.vEnd - MAX_WIN; // trim the (off-screen) top + anchoredPaint(a); +} + +/** Render a fresh window centred on message `mi` and bring it into view. */ +export function jumpToMessage(mi, block) { + const total = activeMessages().length; + if (!total) return null; + mi = Math.max(0, Math.min(total - 1, mi)); + if (mi < cs.vStart || mi >= cs.vEnd || cs.vEnd - cs.vStart > DETAIL_WIN * 2) { + cs.vStart = Math.max(0, mi - Math.floor(DETAIL_WIN / 2)); + cs.vEnd = Math.min(total, cs.vStart + DETAIL_WIN); + paintWindow(); + } + const host = $('convDetail'); + const el = host && host.querySelector(`[data-mi="${mi}"]`); + if (el) el.scrollIntoView({ block: block || 'center' }); + return el; +} diff --git a/src/renderer/js/views/monitor/drawer-search.js b/src/renderer/js/views/monitor/drawer-search.js new file mode 100644 index 0000000..d96914d --- /dev/null +++ b/src/renderer/js/views/monitor/drawer-search.js @@ -0,0 +1,75 @@ +/* In-body find bar for the request inspector (request/response bodies can be 100KB+). */ +import { $, escapeHtml } from '../../core/dom.js'; + +const DR_MARK_CAP = 800; +let drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1; + +export function drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; } + +/** Reset the search model for a freshly-rendered body. */ +export function setDrBody(text, html) { + drBodyText = text; drBodyHTML = html; drMatches = []; drMatchIdx = -1; +} +export function getDrBodyText() { return drBodyText; } + +function updateDrCount() { + const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count'); + if (!c) return; + const shown = Math.min(drMatches.length, DR_MARK_CAP); + c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0'; +} + +export function applyDrSearch(q) { + const code = drCodeEl(); + if (!code) return; + q = q || ''; + if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; } + const hay = drBodyText.toLowerCase(), needle = q.toLowerCase(); + drMatches = []; + for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i); + if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; } + const n = Math.min(drMatches.length, DR_MARK_CAP); + let html = '', last = 0; + for (let k = 0; k < n; k++) { + const pos = drMatches[k]; + html += escapeHtml(drBodyText.slice(last, pos)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + ''; + last = pos + q.length; + } + html += escapeHtml(drBodyText.slice(last)); + code.innerHTML = html; + drMatchIdx = 0; drHighlightCurrent(); updateDrCount(); +} + +function drHighlightCurrent() { + const code = drCodeEl(); + if (!code) return; + const marks = code.querySelectorAll('.dr-mark'); + marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx)); + if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' }); +} + +export function drNavSearch(dir) { + const n = Math.min(drMatches.length, DR_MARK_CAP); + if (!n) return; + drMatchIdx = (drMatchIdx + dir + n) % n; + drHighlightCurrent(); updateDrCount(); +} + +/** Wire the find-bar events on the (stable) drawer body container. */ +export function bindDrawerSearch(reqDrawerBody) { + let _drSearchT = null; + reqDrawerBody.addEventListener('input', (e) => { + if (!e.target.classList || !e.target.classList.contains('dr-search')) return; + const v = e.target.value; + clearTimeout(_drSearchT); + _drSearchT = setTimeout(() => applyDrSearch(v), 110); + }); + reqDrawerBody.addEventListener('keydown', (e) => { + if (!e.target.classList || !e.target.classList.contains('dr-search')) return; + if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); } + else if (e.key === 'Escape') { + if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); } + else e.target.blur(); + } + }); +} diff --git a/src/renderer/js/views/monitor/drawer.js b/src/renderer/js/views/monitor/drawer.js new file mode 100644 index 0000000..e946313 --- /dev/null +++ b/src/renderer/js/views/monitor/drawer.js @@ -0,0 +1,178 @@ +/* Request inspector — full headers + body of one forwarded exchange, per-tab. */ +import { $, escapeHtml, fmtTime, fmtBytes, injectIcons } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { ensureVendor } from '../../core/loader.js'; +import { DRAWER_HTML } from './template.js'; +import { setDrBody, drCodeEl, drNavSearch, bindDrawerSearch } from './drawer-search.js'; + +let reqDrawerTab = 'req'; +let reqDrawerData = null; + +function prettyText(cap) { + if (!cap || !cap.text) return { text: '', lang: 'plaintext' }; + let text = cap.text, lang = 'plaintext'; + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {} + } + return { text, lang }; +} +function prettyBody(cap) { + if (!cap || !cap.text) return `
${escapeHtml(I18n.t('drawer.empty'))}
`; + const { text, lang } = prettyText(cap); + const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : ''; + return note + `
${escapeHtml(text)}
`; +} +function kvTable(h) { + const keys = Object.keys(h || {}); + if (!keys.length) return `
${escapeHtml(I18n.t('drawer.none'))}
`; + return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
'; +} + +// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps +// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer: +// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body) +// req — what the gateway SENT upstream (real upstream URL/headers/translated body) +// ures — what the upstream RETURNED (raw, pre-translation) +// res — what the gateway RETURNED to the client (translated) +function drawerTabView(d, tab) { + const creq = d.clientReq || {}; + const ures = d.upstreamRes || {}; + switch (tab) { + case 'creq': + return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` }; + case 'ures': + return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` }; + case 'res': + return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` }; + default: // 'req' + return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` }; + } +} +function drawerTabs(d) { + return d && d.translated + ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')], + ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]] + : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]]; +} +const DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand'; +function renderDrawerTabs() { + const wrap = $('drTabs'); + if (!wrap) return; + wrap.innerHTML = drawerTabs(reqDrawerData) + .map(([k, label]) => ``) + .join(''); +} + +function renderReqDrawerBody() { + const d = reqDrawerData; + if (!d) return; + const body = $('reqDrawerBody'); + const view = drawerTabView(d, reqDrawerTab); + const { isReq, headers, cap } = view; + const which = reqDrawerTab; + const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy'); + const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} ${escapeHtml(view.sub)}`; + const bodyText = prettyText(cap).text; + const searchBar = bodyText ? `
+ + 0/0 + + +
` : ''; + const copyCls = bodyText ? '' : 'ml-auto '; + body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${prettyBody(cap)}`; + // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI. + body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} }); + const codeEl = drCodeEl(); + setDrBody(bodyText, codeEl ? codeEl.innerHTML : ''); +} + +export async function openReqDetail(id) { + ensureDrawer(); + // Highlighting is optional but nice — load the vendor bundle lazily, tolerate failure. + try { await ensureVendor(); } catch (_) {} + let d = null; + try { d = await api.monitorGet(id); } catch (_) {} + if (!d) { + // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer. + reqDrawerData = null; + $('drMethod').textContent = '—'; + const drStatus = $('drStatus'); + if (drStatus) { + drStatus.textContent = ''; + drStatus.classList.remove('ok', 'err'); + } + $('drModel').textContent = ''; + $('reqMeta').innerHTML = ''; + $('reqDrawerBody').innerHTML = `
${escapeHtml(I18n.t('drawer.expired'))}
`; + $('reqDrawer').classList.remove('hidden'); + return; + } + // Translated exchanges open on the client request (what the gateway received) so the + // before/after of the translation reads left-to-right across the tabs. + reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req'; + const ok = d.status >= 200 && d.status < 400; + $('drMethod').textContent = d.method || 'POST'; + const drStatus = $('drStatus'); + if (drStatus) { + drStatus.textContent = d.status != null ? d.status : '—'; + drStatus.classList.toggle('ok', ok); + drStatus.classList.toggle('err', !ok); + } + $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ` : ''}`; + const meta = [ + [I18n.t('drawer.service'), d.provider], + d.translated ? [I18n.t('drawer.translated'), d.translated] : null, + d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null, + [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''], + [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''], + d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null, + [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''], + d.error ? [I18n.t('drawer.error'), d.error] : null, + ].filter((r) => r && r[1]); + $('reqMeta').innerHTML = meta.map((r) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).join(''); + renderDrawerTabs(); + renderReqDrawerBody(); + $('reqDrawer').classList.remove('hidden'); +} + +export function closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; } + +let drawerBound = false; +function ensureDrawer() { + if ($('reqDrawer')) return; + document.body.insertAdjacentHTML('beforeend', DRAWER_HTML); + I18n.apply($('reqDrawer')); + injectIcons($('reqDrawer')); + if (drawerBound) return; + drawerBound = true; + $('reqDrawerClose').addEventListener('click', closeReqDrawer); + const reqDrawer = $('reqDrawer'); + reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); }); + // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container. + $('drTabs').addEventListener('click', (e) => { + const t = e.target.closest('.dr-tab'); + if (!t) return; + reqDrawerTab = t.dataset.tab; + $('drTabs').querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t)); + renderReqDrawerBody(); + }); + const reqDrawerBody = $('reqDrawerBody'); + reqDrawerBody.addEventListener('click', (e) => { + if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; } + if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; } + const cb = e.target.closest('[data-copy-body]'); + if (cb && reqDrawerData) { + const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap; + api.copy((cap && cap.text) || ''); + cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200); + } + }); + bindDrawerSearch(reqDrawerBody); + document.addEventListener('keydown', (e) => { + const dr = $('reqDrawer'); + if (e.key === 'Escape' && dr && !dr.classList.contains('hidden')) closeReqDrawer(); + }); +} diff --git a/src/renderer/js/views/monitor/feed.js b/src/renderer/js/views/monitor/feed.js new file mode 100644 index 0000000..7311778 --- /dev/null +++ b/src/renderer/js/views/monitor/feed.js @@ -0,0 +1,50 @@ +/* + * Monitor feed — subscribed at BOOT (not at view mount) so requests forwarded before the + * 监控 view is first opened still count. No DOM work here: stats accumulate into shared + * state and rows buffer (bounded) until the view mounts and drains them. + */ +import { api } from '../../core/bridge.js'; +import { fmtTime } from '../../core/dom.js'; +import { state } from '../../core/state.js'; + +const pendingRows = []; +const pendingLogs = []; +let rowSink = null; +let logSink = null; + +function onRequestRow(r) { + state.stats.total++; + if (r.status >= 200 && r.status < 400) state.stats.ok++; + state.stats.sumMs += r.ms || 0; + state.stats.last = fmtTime(); + if (rowSink) rowSink(r); + else { + pendingRows.push(r); + while (pendingRows.length > 100) pendingRows.shift(); // match the backend's capture buffer + } +} + +function onLogLine(l) { + if (logSink) logSink(l); + else { + pendingLogs.push(l); + while (pendingLogs.length > 100) pendingLogs.shift(); + } +} + +let inited = false; +/** Called once from boot. Safe to call again (no-op). */ +export function initMonitorFeed() { + if (inited) return; + inited = true; + api.onRequest(onRequestRow); + api.onLog(onLogLine); +} + +/** The mounted monitor view takes over: replay what buffered, then stream live. */ +export function attachMonitorSinks(onRow, onLog) { + pendingRows.splice(0).forEach((r) => onRow(r, true)); + pendingLogs.splice(0).forEach((l) => onLog(l)); + rowSink = onRow; + logSink = onLog; +} diff --git a/src/renderer/js/views/monitor/index.js b/src/renderer/js/views/monitor/index.js new file mode 100644 index 0000000..0b08dda --- /dev/null +++ b/src/renderer/js/views/monitor/index.js @@ -0,0 +1,94 @@ +/* 监控 view — metrics, request stream, gateway log. Mounted lazily on first switch. */ +import { $, escapeHtml, fmtTime, injectIcons } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { state, activeProvider, onRender, onLocalLog } from '../../core/state.js'; +import { scheduleHeroUsage } from '../providers/hero.js'; +import { MONITOR_HTML } from './template.js'; +import { attachMonitorSinks } from './feed.js'; +import { pushRawLog, renderGwLogStatus, renderGatewayLog, refreshGatewayLog, gwLog } from './log.js'; +import { openReqDetail, closeReqDrawer } from './drawer.js'; + +function renderMonitor() { + $('mStatusText').textContent = state.status.connected ? I18n.t('status.connected') : state.status.running ? I18n.t('status.running') : I18n.t('status.disconnected'); + const dot = $('mStatus').querySelector('.pulse-dot, .live-dot'); + if (dot) { + const isLive = !!(state.status.connected || state.status.running); + dot.classList.toggle('on', isLive); + dot.classList.toggle('off', !isLive); + } + $('mEndpoint').textContent = `localhost:${(state.status.running && state.status.port) || state.config.port}`; + const ap = activeProvider(); + $('mActive').textContent = ap ? ap.name : '—'; + $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService'); + $('mTotal').textContent = state.stats.total; + $('mSuccess').textContent = state.stats.total ? I18n.t('monitor.successRate', { pct: Math.round((state.stats.ok / state.stats.total) * 100) }) : I18n.t('monitor.successRateNone'); + $('mAvg').innerHTML = state.stats.total ? `${Math.round(state.stats.sumMs / state.stats.total)} ms` : `— ms`; + $('mLast').textContent = state.stats.last ? I18n.t('monitor.recent', { time: state.stats.last }) : I18n.t('monitor.recentNone'); + renderGwLogStatus(); +} + +// Stats already accumulated by the boot-time feed (see feed.js) — this only paints the row. +function pushStreamRow(r) { + renderMonitor(); + $('streamHint').textContent = I18n.t('monitor.forwarded', { n: state.stats.total }); + const list = $('streamList'); + const empty = list.querySelector('.state-inline, .empty'); + if (empty) empty.remove(); + const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err'; + const row = document.createElement('div'); + row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer'; + if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); } + const agentTag = r.agentId ? `sub` : ''; + row.innerHTML = ` + + ${escapeHtml(r.method || '')} + ${agentTag} + + ${escapeHtml(r.requestedModel || '-')} + + ${escapeHtml(r.outgoingModel || '-')} + ${r.rewritten ? `` : ''} + + ${escapeHtml(r.provider || '')} + ${r.status} + ${r.ms}ms + ${fmtTime()}`; + list.insertBefore(row, list.firstChild); + // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer). + while (list.children.length > 100) list.removeChild(list.lastChild); + scheduleHeroUsage(); +} + +function clearLog() { + $('streamList').innerHTML = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`; + gwLog.items.length = 0; gwLog.seen.clear(); + renderGatewayLog(); + state.stats.total = state.stats.ok = state.stats.sumMs = 0; state.stats.last = null; + $('streamHint').textContent = I18n.t('monitor.waitingDots'); + renderMonitor(); + if (api.monitorClear) api.monitorClear(); + if (api.logsClear) api.logsClear(); + closeReqDrawer(); +} + +export default { + id: 'monitor', + mount(host) { + host.insertAdjacentHTML('beforeend', MONITOR_HTML); + const section = $('view-monitor'); + I18n.apply(section); + injectIcons(section); + $('btnClearLog').addEventListener('click', clearLog); + // Request inspector: click a stream row to open its full captured exchange. + $('streamList').addEventListener('click', (e) => { + const row = e.target.closest('.stream-row'); + if (row && row.dataset.id) openReqDetail(row.dataset.id); + }); + attachMonitorSinks((r) => pushStreamRow(r), (l) => pushRawLog(l)); + onLocalLog((l) => pushRawLog(l)); + onRender(renderMonitor); + renderMonitor(); + }, + onShow() { refreshGatewayLog(); }, +}; diff --git a/src/renderer/js/views/monitor/log.js b/src/renderer/js/views/monitor/log.js new file mode 100644 index 0000000..5786a3b --- /dev/null +++ b/src/renderer/js/views/monitor/log.js @@ -0,0 +1,56 @@ +/* Gateway log panel — lifecycle + error events, backfilled from the backend's ring buffer. */ +import { $, escapeHtml, fmtTime } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { state } from '../../core/state.js'; + +export const gwLog = { seen: new Set(), items: [] }; + +// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer +// notices (provider test, save error, …) have none and are always appended. +export function addGatewayLog(l) { + if (!l) return false; + if (l.seq != null) { + if (gwLog.seen.has(l.seq)) return false; + gwLog.seen.add(l.seq); + } + if (l.ts == null) l.ts = Date.now(); + gwLog.items.push(l); + while (gwLog.items.length > 100) gwLog.items.shift(); + return true; +} + +export function renderGwLogStatus() { + const el = $('gwLogStatus'); + if (!el) return; + const running = !!(state.status.connected || state.status.running); + const port = (state.status.running && state.status.port) || state.config.port; + el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off'); + el.innerHTML = `${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`; +} + +export function renderGatewayLog() { + const el = $('rawLog'); + if (!el) return; + if (!gwLog.items.length) { + el.innerHTML = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`; + return; + } + const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse(); + el.innerHTML = rows.map((l) => { + const lv = String(l.level || 'info'); + const t = fmtTime(l.ts); + return `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`; + }).join(''); +} + +export function pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); } + +// Backfill from the backend ring buffer (events fire once and aren't otherwise replayed) + refresh banner. +export async function refreshGatewayLog() { + if (api.logsGet) { + try { ((await api.logsGet()) || []).forEach(addGatewayLog); } catch (_) {} + } + renderGwLogStatus(); + renderGatewayLog(); +} diff --git a/src/renderer/js/views/monitor/template.js b/src/renderer/js/views/monitor/template.js new file mode 100644 index 0000000..6e7c5fb --- /dev/null +++ b/src/renderer/js/views/monitor/template.js @@ -0,0 +1,61 @@ +/* 监控 view + request inspector drawer markup — injected on first switch/open. */ +export const MONITOR_HTML = ` + `; + +export const DRAWER_HTML = ` + `; diff --git a/src/renderer/js/views/plugins/actions.js b/src/renderer/js/views/plugins/actions.js new file mode 100644 index 0000000..da488ac --- /dev/null +++ b/src/renderer/js/views/plugins/actions.js @@ -0,0 +1,85 @@ +/* 插件 — list-row actions: enable/disable, uninstall, update, plugin-declared actions. */ +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { state } from '../../core/state.js'; +import { showToast, confirmDialog } from '../../core/toast.js'; +import { setPluginBtnBusy, markPluginCardBusy } from './git.js'; +import { openPluginActionForm } from './forms.js'; + +export const pluginActionsById = {}; // pluginId -> declared actions (for the form modal) + +// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ---- +async function runPluginDeclaredAction(btn, reload) { + if (btn.disabled) return; + const pid = btn.dataset.pluginActionbtn; + const actionId = btn.dataset.actionId; + const kind = btn.dataset.actionKind || 'call'; + if (kind === 'link') { + const url = btn.dataset.actionUrl; + if (url) { try { api.openExternal(url); } catch (_) {} } + return; + } + const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId }; + if (kind === 'form') { await openPluginActionForm(pid, action, reload); return; } + // kind === 'call': fire-and-report, with an optional confirm gate + // (confirmDialog, not window.confirm — the Tauri webview never shows the latter) + if (action.confirm) { + const ok = await confirmDialog({ + title: action.label || action.id, + message: action.confirm, + confirmText: action.label || action.id, + }); + if (!ok) return; + } + btn.disabled = true; + try { + const r = await api.pluginAction(pid, actionId, {}); + showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok'); + } catch (err) { + showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err'); + } + btn.disabled = false; + await reload(); +} + +/** Delegated click handler for the plugin list; `deps` supplies reload + provider re-render. */ +export async function onPluginAction(e, deps) { + const { reload, renderProviders } = deps; + const actionBtn = e.target.closest('[data-plugin-actionbtn]'); + if (actionBtn) { await runPluginDeclaredAction(actionBtn, reload); return; } + const toggle = e.target.closest('[data-plugin-toggle]'); + const uninstall = e.target.closest('[data-plugin-uninstall]'); + const update = e.target.closest('[data-plugin-update]'); + const btn = toggle || uninstall || update; + if (!btn) return; + btn.disabled = true; + try { + if (toggle) { + const enabling = toggle.dataset.enabled !== '1'; + // Starting a sidecar spawns a process and health-gates it (can take a + // beat), so give the button an immediate spinner + "Starting…". + if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting')); + markPluginCardBusy(toggle); + await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling); + state.config = await api.getConfig(); // enabling adds a provider, disabling removes it + renderProviders(); + } else if (uninstall) { + const ok = await confirmDialog({ + title: I18n.t('plugins.deleteTitle'), + message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }), + confirmText: I18n.t('plugins.confirmDelete'), + danger: true, + }); + if (!ok) { uninstall.disabled = false; return; } + const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall); + if (!(r && r.canceled)) { state.config = await api.getConfig(); renderProviders(); } + } else if (update) { + update.disabled = true; + await api.pluginUpdate(update.dataset.pluginUpdate); + state.config = await api.getConfig(); renderProviders(); + } + } catch (err) { + showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err'); + } + await reload(); +} diff --git a/src/renderer/js/views/plugins/forms.js b/src/renderer/js/views/plugins/forms.js new file mode 100644 index 0000000..72107b4 --- /dev/null +++ b/src/renderer/js/views/plugins/forms.js @@ -0,0 +1,112 @@ +/* 插件 — declared-action form modal, built entirely from the plugin's field specs. */ +import { escapeHtml } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { showToast } from '../../core/toast.js'; + +// Build one form control from a field spec. Recognized types: text (default), +// number, password, textarea, select, checkbox. +function pluginFieldControl(f, val) { + const key = escapeHtml(f.key); + const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary'; + const v = (val != null ? val : (f.default != null ? f.default : '')); + if (f.type === 'select') { + const opts = (Array.isArray(f.options) ? f.options : []).map((o) => { + const ov = (o && typeof o === 'object') ? o.value : o; + const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o; + const sel = String(ov) === String(v) ? ' selected' : ''; + return ``; + }).join(''); + return ``; + } + if (f.type === 'checkbox') { + const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true'; + return ``; + } + if (f.type === 'textarea') { + return ``; + } + const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text'; + const mono = type === 'text' || type === 'number' ? ' font-mono' : ''; + const minmax = f.type === 'number' + ? `${f.min != null ? ` min="${escapeHtml(String(f.min))}"` : ''}${f.max != null ? ` max="${escapeHtml(String(f.max))}"` : ''}` + : ''; + return ``; +} + +// Read the form back into a values object, coercing/validating by field type. +// Returns null (and focuses the offending control) if a required/number check fails. +function collectPluginFormValues(root, fields) { + const out = {}; + for (const f of fields) { + const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key; + const el = root.querySelector(`[data-field-key="${sel}"]`); + if (!el) continue; + let v = f.type === 'checkbox' ? !!el.checked : el.value; + if (f.type === 'number') { + if (v === '' || v == null) { + v = null; + } else { + const n = Number(v); + if (Number.isNaN(n)) { el.focus(); return null; } + v = n; + } + } + if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; } + out[f.key] = v; + } + return out; +} + +// Open a modal built from a plugin action's field specs; prefill from the plugin, +// then POST the collected values back through the host. +export async function openPluginActionForm(pid, action, reload) { + const fields = Array.isArray(action.fields) ? action.fields : []; + let values = {}; + if (action.loadOnOpen !== false) { + try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; } + catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); } + } + const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove(); + const ov = document.createElement('div'); + ov.id = 'pluginActionModal'; + ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md'; + const rows = fields.map((f) => ` + `).join(''); + ov.innerHTML = ` +
+
+

${escapeHtml(action.label || action.id)}

+
+
+ ${rows} +
+ + +
+
+
`; + document.body.appendChild(ov); + const close = () => ov.remove(); + ov.addEventListener('click', (e) => { if (e.target === ov) close(); }); + ov.querySelector('[data-act="cancel"]').addEventListener('click', close); + ov.querySelector('[data-act="submit"]').addEventListener('click', async () => { + const vals = collectPluginFormValues(ov, fields); + if (vals == null) return; + const submitBtn = ov.querySelector('[data-act="submit"]'); + submitBtn.disabled = true; + try { + const r = await api.pluginAction(pid, action.id, vals); + showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok'); + close(); + await reload(); + } catch (err) { + submitBtn.disabled = false; + showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err'); + } + }); +} diff --git a/src/renderer/js/views/plugins/git.js b/src/renderer/js/views/plugins/git.js new file mode 100644 index 0000000..cb55566 --- /dev/null +++ b/src/renderer/js/views/plugins/git.js @@ -0,0 +1,68 @@ +/* 插件 — “从 Git 添加” modal + full-screen busy overlay for clone/build runs. */ +import { $, escapeHtml } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { showToast } from '../../core/toast.js'; + +export function openPluginGitModal() { + const m = $('pluginGitModal'); if (!m) return; + m.classList.remove('hidden'); + const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); } +} +export function closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); } + +// Full-screen blocking overlay shown while a git clone/build runs — the user +// cannot interact with the rest of the app until it finishes. +export function showPluginBusy(text) { + let ov = document.getElementById('pluginBusy'); + if (!ov) { + ov = document.createElement('div'); + ov.id = 'pluginBusy'; + ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md'; + ov.style.background = 'rgba(0,0,0,0.45)'; + ov.innerHTML = '

'; + document.body.appendChild(ov); + } + const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || ''; + ov.style.display = 'flex'; +} +export function hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; } + +/** Import a plugin from the URL typed into the git modal; refreshes via `reload()`. */ +export async function importFromGit(reload) { + const u = $('pluginGitUrl'); + const url = ((u && u.value) || '').trim(); + if (!url) return; + closePluginGitModal(); + showPluginBusy(I18n.t('plugins.importing')); + try { + const r = await api.pluginInstallGit(url); + hidePluginBusy(); + if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok'); + } catch (e) { + hidePluginBusy(); + showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err'); + } + await reload(); +} + +// Small inline spinner used for per-button loading (enable/start). +export function pluginSpinner() { + return ''; +} +// Turn a button into a busy state: spinner + text, disabled. Reset happens on the +// next renderPlugins() (loadPlugins re-renders the whole list from fresh status). +export function setPluginBtnBusy(btn, text) { + if (!btn) return; + btn.disabled = true; + btn.innerHTML = `${pluginSpinner()}${escapeHtml(text || '')}`; +} +// Dim the card and swap its status line to a "starting" spinner while the sidecar +// spins up, so the whole row reads as in-progress (not just the button). +export function markPluginCardBusy(btn) { + const card = btn && btn.closest('.plugin'); + if (!card) return; + card.classList.add('opacity-60', 'pointer-events-none'); + const line = card.querySelector('[data-plugin-status]'); + if (line) line.innerHTML = `${pluginSpinner()}${escapeHtml(I18n.t('plugins.starting'))}`; +} diff --git a/src/renderer/js/views/plugins/index.js b/src/renderer/js/views/plugins/index.js new file mode 100644 index 0000000..fca4178 --- /dev/null +++ b/src/renderer/js/views/plugins/index.js @@ -0,0 +1,149 @@ +/* 插件 view — sidecar coding-agent backends. Mounted lazily on first switch. */ +import { $, escapeHtml, injectIcons } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { icons } from '../../core/icons.js'; +import { renderAll } from '../../core/state.js'; +import { showToast } from '../../core/toast.js'; +import { renderProviderIcon } from '../providers/icon.js'; +import { pluginActionsById, onPluginAction } from './actions.js'; +import { openPluginGitModal, closePluginGitModal, importFromGit } from './git.js'; + +const PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md'; + +const SECTION_HTML = ` + `; + +export async function loadPlugins() { + let plugins = []; + try { plugins = await api.pluginList(); } + catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); } + const arr = Array.isArray(plugins) ? plugins : []; + renderPlugins(arr); + for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); } +} + +async function checkPluginUpdate(id) { + let r; + try { r = await api.pluginCheckUpdate(id); } catch (_) { return; } + if (!r || !r.updateAvailable) return; + const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id; + const slot = document.querySelector('[data-update-slot="' + sel + '"]'); + if (slot) slot.innerHTML = ''; +} + +function renderPlugins(plugins) { + const list = $('pluginList'); + if (!list) return; + const empty = $('emptyPlugins'); + if (empty) empty.classList.toggle('hidden', plugins.length > 0); + list.innerHTML = ''; + for (const p of plugins) { + const running = !!p.running; + const auth = p.auth || {}; + const st = auth.state || ''; + const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : '')) + : st === 'expired' ? I18n.t('plugins.authExpired') + : st === 'logged_out' ? I18n.t('plugins.authLoggedOut') + : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning'); + const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption'); + const iconData = renderProviderIcon(p.name, p.icon); + const dot = ``; + const toggleBtn = ``; + const delBtn = ``; + // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest. + const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => { + if (!a || !a.id) return ''; + // Links open in a browser and never touch the plugin, so they don't need it + // running; form/call actions default to requiring a running plugin. + const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false); + const disabled = needsRun && !running; + const label = escapeHtml(a.label || a.id); + return ``; + }).join(''); + pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : []; + const el = document.createElement('div'); + el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong'; + el.dataset.id = p.id; + el.innerHTML = ` +
${iconData.html}
+
+
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
+
${escapeHtml(p.description || '')}
+
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
+
+
${actionBtns}${toggleBtn}${delBtn}
`; + list.appendChild(el); + } +} + +export default { + id: 'plugins', + mount(host) { + host.insertAdjacentHTML('beforeend', SECTION_HTML); + const section = $('view-plugins'); + I18n.apply(section); + injectIcons(section); + const deps = { reload: loadPlugins, renderProviders: renderAll }; + $('pluginList').addEventListener('click', (e) => onPluginAction(e, deps)); + $('linkPluginDocs').addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} }); + $('btnPluginInstall').addEventListener('click', async () => { + try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); } + catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); } + await loadPlugins(); + }); + $('btnPluginOpenDir').addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} }); + $('btnPluginGit').addEventListener('click', openPluginGitModal); + $('btnGitCancel').addEventListener('click', closePluginGitModal); + $('pluginGitModal').addEventListener('click', (e) => { if (e.target === $('pluginGitModal')) closePluginGitModal(); }); + $('btnPluginGitGo').addEventListener('click', () => importFromGit(loadPlugins)); + $('pluginGitUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(loadPlugins); }); + }, + onShow() { loadPlugins(); }, +}; diff --git a/src/renderer/js/views/providers/drag.js b/src/renderer/js/views/providers/drag.js new file mode 100644 index 0000000..021358a --- /dev/null +++ b/src/renderer/js/views/providers/drag.js @@ -0,0 +1,35 @@ +/* Drag-to-reorder for the provider list. */ +import { $ } from '../../core/dom.js'; +import { state, persist } from '../../core/state.js'; + +let dragId = null; + +export function wireDrag() { + const list = $('providerList'); + list.addEventListener('dragstart', (e) => { + const card = e.target.closest('.provider'); if (!card) return; + dragId = card.dataset.id; card.classList.add('dragging'); + }); + list.addEventListener('dragend', (e) => { + const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging'); + document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over')); + }); + list.addEventListener('dragover', (e) => { + e.preventDefault(); + const card = e.target.closest('.provider'); + document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over')); + if (card && card.dataset.id !== dragId) card.classList.add('drag-over'); + }); + list.addEventListener('drop', async (e) => { + e.preventDefault(); + const card = e.target.closest('.provider'); + if (!card || !dragId || card.dataset.id === dragId) return; + const ids = state.config.providers.map((p) => p.id); + const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id); + if (from < 0 || to < 0) return; + const reordered = state.config.providers.slice(); + const [moved] = reordered.splice(from, 1); + reordered.splice(to, 0, moved); + await persist({ providers: reordered }); + }); +} diff --git a/src/renderer/js/views/providers/hero.js b/src/renderer/js/views/providers/hero.js new file mode 100644 index 0000000..757866b --- /dev/null +++ b/src/renderer/js/views/providers/hero.js @@ -0,0 +1,117 @@ +/* Hero card (gateway service state + usage summary) and the sidebar status pill. */ +import { $, fmtNum, sparkSVG, escapeHtml } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { icons } from '../../core/icons.js'; +import { state, activeProvider, refresh } from '../../core/state.js'; +import { renderProviderIcon } from './icon.js'; + +export function showHeroNote(text, warn) { + const n = $('heroNote'); + if (n) { + n.textContent = text; + n.classList.remove('hidden'); + n.classList.toggle('warn', !!warn); + } +} +export function hideHeroNote() { + const n = $('heroNote'); + if (n) n.classList.add('hidden'); +} + +let heroRange = '30d'; +export async function renderHeroUsage() { + const wrap = $('heroUsage'); + if (!wrap || !api.usageGet) return; + let u; try { u = await api.usageGet(heroRange); } catch (_) { return; } + if (!u) return; + const port = (state.status.running && state.status.port) || state.config.port; + const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`; + // a11y: the button's accessible name must contain its visible text (localhost:port). + const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`); + const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0); + const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() }); + const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : ''; + const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90; + const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0); + const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series); +} + +let _heroUsageT = null; +/** Debounced refresh after monitor traffic — keeps the spark current while requests stream. */ +export function scheduleHeroUsage() { + clearTimeout(_heroUsageT); + _heroUsageT = setTimeout(() => { + const w = $('heroUsage'); + if (state.status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); + }, 2500); +} + +// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action +// ("一键接入"/"断开" writes or restores the CLIs' configs) — independent of the service switch. +export function renderHero() { + const hero = $('hero'); + const ap = activeProvider(); + $('btnConnect').textContent = I18n.t(state.status.running ? 'hero.stopSvc' : 'hero.startSvc'); + if (state.status.running) { + hero.classList.add('connected'); + const icon = $('heroIcon'); + if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; } + else { icon.removeAttribute('style'); icon.innerHTML = icons.connected || ''; } + $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running'); + $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running'); + hideHeroNote(); + $('heroUsage').classList.remove('hidden'); + renderHeroUsage(); + } else { + hero.classList.remove('connected'); + const icon = $('heroIcon'); + icon.removeAttribute('style'); + icon.innerHTML = icons.connect || ''; + $('heroTitle').textContent = I18n.t('hero.titleIdle'); + $('heroSub').textContent = I18n.t('hero.subIdle'); + hideHeroNote(); + $('heroUsage').classList.add('hidden'); + } +} + +/** Sidebar status pill + brand-title tint. */ +export function renderStatus() { + const chip = $('statusPill'); + if (chip) { + chip.classList.toggle('on', !!state.status.running); + const txt = chip.querySelector('.status-text'); + if (txt) txt.textContent = I18n.t(state.status.running ? 'status.gwRunning' : 'status.gwStopped'); + const bt = $('brandTitle'); + if (bt) bt.classList.toggle('running', !!state.status.running); + } +} + +/** Hero button + endpoint/ranges wiring. Called once from the providers view mount. */ +export function bindHero() { + $('btnConnect').addEventListener('click', async () => { + const btn = $('btnConnect'); + const on = !state.status.running; + btn.disabled = true; + let res; + try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; } + btn.disabled = false; + await refresh(); + if (res && res.ok === false) showHeroNote(res.message || I18n.t('err.opFailed'), true); + }); + const heroRanges = $('heroRanges'); + if (heroRanges) heroRanges.addEventListener('click', (e) => { + const b = e.target.closest('[data-hrange]'); + if (!b) return; + heroRange = b.dataset.hrange; + heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b)); + renderHeroUsage(); + }); + const heroEndpoint = $('heroEndpoint'); + if (heroEndpoint) heroEndpoint.addEventListener('click', () => { + const port = (state.status.running && state.status.port) || state.config.port; + if (api.copy) api.copy(`http://localhost:${port}`); + const t = $('heroEndpointText'); + if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); } + }); +} diff --git a/src/renderer/js/views/providers/icon-picker.js b/src/renderer/js/views/providers/icon-picker.js new file mode 100644 index 0000000..f5699ea --- /dev/null +++ b/src/renderer/js/views/providers/icon-picker.js @@ -0,0 +1,62 @@ +/* Emoji / image icon picker popover for the provider modal. */ +import { escapeHtml } from '../../core/dom.js'; +import { I18n } from '../../core/i18n.js'; +import { ICON_EMOJIS } from './icon.js'; + +function resizeImage(file, size) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + try { + const c = document.createElement('canvas'); c.width = size; c.height = size; + const ctx = c.getContext('2d'); + const s = Math.min(img.width, img.height); + ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size); + resolve(c.toDataURL('image/png')); + } catch (e) { reject(e); } + }; + img.onerror = reject; + img.src = reader.result; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** Open the picker anchored to `anchor`; `setIcon(value|null)` receives the choice. */ +export function openIconPicker(anchor, setIcon) { + const existing = document.querySelector('.icon-picker'); + if (existing) { existing.remove(); return; } + const pop = document.createElement('div'); + pop.className = 'icon-picker'; + pop.innerHTML = + `
${ICON_EMOJIS.map((e) => ``).join('')}
` + + `
` + + ``; + document.body.appendChild(pop); + const r = anchor.getBoundingClientRect(); + let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10)); + let y = Math.round(r.bottom + 8); + if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8)); + pop.style.left = x + 'px'; pop.style.top = y + 'px'; + const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; + const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); }; + const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; + setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0); + pop.addEventListener('click', (e) => { + const em = e.target.closest('.ip-emoji'); + if (em) { setIcon(em.dataset.emoji); close(); return; } + const act = e.target.closest('.ip-act'); + if (!act) return; + if (act.dataset.act === 'random') { setIcon(ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]); close(); } + else if (act.dataset.act === 'reset') { setIcon(null); close(); } + else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); } + }); + pop.querySelector('.ip-file').addEventListener('change', (e) => { + const f = e.target.files && e.target.files[0]; + if (!f) return; + resizeImage(f, 72).then((d) => { setIcon(d); close(); }).catch(() => close()); + }); +} diff --git a/src/renderer/js/views/providers/icon.js b/src/renderer/js/views/providers/icon.js new file mode 100644 index 0000000..f7a768c --- /dev/null +++ b/src/renderer/js/views/providers/icon.js @@ -0,0 +1,35 @@ +/* Provider icon rendering: user image/emoji → brand logo → deterministic emoji fallback. */ +import { escapeHtml, hashHue } from '../../core/dom.js'; +import { I18n } from '../../core/i18n.js'; + +// Deterministic "random" emoji set — a custom provider with no brand logo gets a stable one. +export const ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊']; + +export function emojiIcon(emoji, name) { + const h = hashHue(name || '?'); + return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `${escapeHtml(emoji)}` }; +} + +// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji. +export function renderProviderIcon(name, icon) { + if (icon && typeof icon === 'string') { + if (/^(data:|https?:|assets\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `` }; + return emojiIcon(icon, name); // a chosen emoji + } + const n = (name || '').trim().toLowerCase(); + const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] }; + for (const file in brand) { + // object-fit:contain keeps non-square logos from being stretched into the square icon slot. + const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`; + if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `` }; + } + if (n.includes('claude') || n.includes('anthropic')) { + const h = hashHue(name || '?'); + return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `` }; + } + return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji +} + +export function mask(t) { + return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); +} diff --git a/src/renderer/js/views/providers/index.js b/src/renderer/js/views/providers/index.js new file mode 100644 index 0000000..eabdbc1 --- /dev/null +++ b/src/renderer/js/views/providers/index.js @@ -0,0 +1,151 @@ +/* 服务 view — provider list + hero. The section itself ships inline in index.html + (first-paint content); this module binds it and renders from shared state. */ +import { $, escapeHtml } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { icons } from '../../core/icons.js'; +import { state, onRender, renderAll } from '../../core/state.js'; +import { showToast, confirmDialog } from '../../core/toast.js'; +import { pushLocalLog } from '../../core/state.js'; +import { renderProviderIcon, mask } from './icon.js'; +import { renderHero, renderStatus, bindHero } from './hero.js'; +import { openModal, closeModal, collectProvider, setModalHandlers } from './modal.js'; +import { wireDrag } from './drag.js'; + +function renderProviders() { + const list = $('providerList'); + list.innerHTML = ''; + $('emptyProviders').classList.toggle('hidden', state.config.providers.length > 0); + for (const p of state.config.providers) { + const isActive = p.id === state.config.activeProviderId; + const el = document.createElement('div'); + el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : ''); + el.draggable = true; + el.dataset.id = p.id; + + const tags = []; + if (p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`); + if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`); + for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`); + + const iconData = renderProviderIcon(p.name, p.icon); + // Protocol badge so the wire protocol (and whether requests are translated) is visible at a + // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones + // stand out. + const proto = p.protocol || 'anthropic'; + const protoMeta = proto === 'openai-chat' + ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' } + : proto === 'openai-responses' + ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' } + : { label: 'Anthropic', cls: 'proto-badge-direct' }; + const protoBadge = `${escapeHtml(protoMeta.label)}`; + el.innerHTML = ` + +
${iconData.html}
+
+
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
+
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\/\//, ''))}
+
+
${tags.join('') || ''}
+
+ + + +
`; + list.appendChild(el); + } +} + +async function onListClick(e) { + // Resolve the actual button (the click may land on the inner SVG icon). + const btn = e.target.closest('button'); + if (btn && btn.dataset.edit) { openModal(state.config.providers.find((p) => p.id === btn.dataset.edit)); return; } + if (btn && btn.dataset.del) { + // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS). + const ok = await confirmDialog({ + title: I18n.t('providers.delete'), + message: I18n.t('providers.confirmDelete'), + confirmText: I18n.t('providers.delete'), + danger: true, + }); + if (ok) { state.config = await api.deleteProvider(btn.dataset.del); renderAll(); } + return; + } + if (btn && btn.dataset.test) { + const p = state.config.providers.find((pp) => pp.id === btn.dataset.test); + const orig = btn.innerHTML; // preserve the SVG icon, restore it after + btn.innerHTML = '…'; btn.disabled = true; + const res = await api.testProvider(p); + btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗'; + pushLocalLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) }); + setTimeout(() => { btn.innerHTML = orig; }, 1800); + return; + } + if (btn) return; // some other button — ignore + // click anywhere else on the card → set it as the active service + const card = e.target.closest('.provider'); + if (!card || !card.dataset.id) return; + if (card.dataset.id === state.config.activeProviderId) return; // already active — nothing to switch + // While the gateway is running, switching is easy to mis-trigger and would re-point every new + // Claude Code session — so confirm first. + if (state.status.running) { + const p = state.config.providers.find((pp) => pp.id === card.dataset.id); + const ok = await confirmDialog({ + title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }), + message: I18n.t('switch.confirmMsg'), + confirmText: I18n.t('switch.confirmOk'), + }); + if (!ok) return; + } + try { + state.config = await api.setActive(card.dataset.id); + } catch (err) { + const msg = (err && err.message) || String(err); + showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err'); + return; + } + renderAll(); +} + +async function saveFromModal() { + const p = collectProvider(); + if (!p.baseUrl) { + showToast(I18n.t('modal.fillUrl'), 'err'); + return; + } + state.config = await api.upsertProvider(p); + closeModal(); renderAll(); +} + +async function testFromModal() { + // Surface the result as a floating toast — the in-modal alert sits at the bottom of a + // scrollable sheet and was easily hidden, leaving users unsure whether the test ran. + const pending = showToast(I18n.t('modal.testing'), 'pending'); + const testedProvider = collectProvider(); + const res = await api.testProvider(testedProvider); + if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) { + $('fBaseUrl').value = res.baseUrl; + } + let msg; + if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty'); + else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid'); + else if (res.reason === 'timeout') msg = I18n.t('err.timeout'); + else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' }); + else msg = res.message || ('HTTP ' + (res.status || '')); + if (pending) pending.dismiss(); + showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err'); +} + +export default { + id: 'providers', + mount() { + bindHero(); + $('btnAdd').addEventListener('click', () => openModal(null)); + const btnAddEmpty = $('btnAddEmpty'); + if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null)); + $('providerList').addEventListener('click', onListClick); + wireDrag(); + setModalHandlers({ onSave: saveFromModal, onTest: testFromModal }); + onRender(() => { renderStatus(); renderHero(); renderProviders(); }); + }, +}; diff --git a/src/renderer/js/views/providers/modal-template.js b/src/renderer/js/views/providers/modal-template.js new file mode 100644 index 0000000..ff15945 --- /dev/null +++ b/src/renderer/js/views/providers/modal-template.js @@ -0,0 +1,82 @@ +/* 服务编辑 modal markup — injected on first open (kept out of the startup HTML). */ +export const MODAL_HTML = ` + `; diff --git a/src/renderer/js/views/providers/modal.js b/src/renderer/js/views/providers/modal.js new file mode 100644 index 0000000..d51e0b5 --- /dev/null +++ b/src/renderer/js/views/providers/modal.js @@ -0,0 +1,180 @@ +/* Provider add/edit modal — template, open/close, form collection, preset + protocol controls. */ +import { $, escapeHtml, injectIcons } from '../../core/dom.js'; +import { I18n } from '../../core/i18n.js'; +import { onConfigReplaced } from '../../core/state.js'; +import { PRESETS, PRESET_LABELS } from './presets.js'; +import { renderProviderIcon } from './icon.js'; +import { openIconPicker } from './icon-picker.js'; +import { MODAL_HTML } from './modal-template.js'; + +let editingId = null; +let modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL) +export const getModalIcon = () => modalIcon; +export const setModalIcon = (v) => { modalIcon = v; updateIconPreview(); }; + +/** Inject the modal DOM on first use (it's not part of the startup HTML). */ +function ensureModal() { + if ($('modal')) return; + document.body.insertAdjacentHTML('beforeend', MODAL_HTML); + I18n.apply($('modal')); + injectIcons($('modal')); + renderPresetGrid(); + bindModal(); +} + +function renderPresetGrid() { + const grid = $('presetGrid'); + grid.innerHTML = ''; + Object.keys(PRESET_LABELS).forEach((key) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; + b.dataset.preset = key; + b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key]; + grid.appendChild(b); + }); +} + +function selectPreset(key) { + document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key)); + const p = PRESETS[key] || PRESETS.custom; + $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel; + setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front + modalIcon = null; // a preset uses its brand logo + updateIconPreview(); + if (key !== 'custom') $('fToken').focus(); +} + +// Segmented protocol control: get/set the selected wire protocol. +function getProtocol() { + const g = $('fProtocol'); if (!g) return 'anthropic'; + const b = g.querySelector('.proto-seg-btn.selected'); + return (b && b.dataset.proto) || 'anthropic'; +} +function setProtocol(v) { + const g = $('fProtocol'); if (!g) return; + v = v || 'anthropic'; + g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v)); + syncProtocolHint(); +} +// Reflect the chosen protocol as a prominent status line so the user always knows whether their +// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses). +function syncProtocolHint() { + const badge = $('protoBadge'); + if (!badge) return; + const v = getProtocol(); + const map = { + 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' }, + 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' }, + 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' }, + }; + const m = map[v] || map['anthropic']; + badge.className = 'proto-badge ' + m.cls; + badge.textContent = I18n.t(m.k); +} + +function updateIconPreview() { + const el = $('fIconPreview'); + const iconData = renderProviderIcon($('fName').value || '?', modalIcon); + el.setAttribute('style', iconData.style); + el.innerHTML = iconData.html; +} + +function addMapRow(alias = '', upstream = '') { + const row = document.createElement('div'); + row.className = 'map-row flex items-center gap-1.75'; + const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary'; + row.innerHTML = ` + + + + `; + row.querySelector('.m-alias').value = alias; + row.querySelector('.m-upstream').value = upstream; + row.querySelector('.m-del').addEventListener('click', () => row.remove()); + $('mapRows').appendChild(row); +} + +export function openModal(provider) { + ensureModal(); + editingId = provider ? provider.id : null; + modalIcon = provider ? (provider.icon || null) : null; + $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle'); + document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected')); + $('fName').value = provider ? provider.name : ''; + $('fBaseUrl').value = provider ? provider.baseUrl : ''; + $('fToken').value = provider ? provider.authToken : ''; + $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show'); + $('fDefaultModel').value = provider ? provider.defaultModel : ''; + $('fSmallModel').value = provider ? provider.smallFastModel : ''; + $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true; + setProtocol((provider && provider.protocol) || 'anthropic'); + $('mapRows').innerHTML = ''; + if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream)); + if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into + const mapDetails = $('mapRows').closest('details'); + if (mapDetails) mapDetails.open = true; + updateIconPreview(); + $('modal').classList.remove('hidden'); + $('fName').focus(); +} +export function closeModal() { if ($('modal')) $('modal').classList.add('hidden'); editingId = null; } + +export function collectProvider() { + const models = []; + $('mapRows').querySelectorAll('.map-row').forEach((row) => { + const alias = row.querySelector('.m-alias').value.trim(); + const upstream = row.querySelector('.m-upstream').value.trim(); + if (alias || upstream) models.push({ alias, upstream }); + }); + const p = { + name: $('fName').value.trim() || I18n.t('providers.unnamed'), + baseUrl: $('fBaseUrl').value.trim(), + authToken: $('fToken').value.trim(), + defaultModel: $('fDefaultModel').value.trim(), + smallFastModel: $('fSmallModel').value.trim(), + mapDefaultModels: $('fMapDefault').checked, + protocol: getProtocol(), + models, + }; + if (modalIcon) p.icon = modalIcon; + if (editingId) p.id = editingId; + return p; +} + +let onSave = null, onTest = null; +/** The providers view injects save/test behavior so the modal stays UI-only. */ +export function setModalHandlers(handlers) { onSave = handlers.onSave; onTest = handlers.onTest; } + +function bindModal() { + $('modalClose').addEventListener('click', closeModal); + $('btnCancel').addEventListener('click', closeModal); + $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); }); + const fp = $('fProtocol'); + if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); + $('fName').addEventListener('input', updateIconPreview); + const fIconPreview = $('fIconPreview'); + if (fIconPreview && fIconPreview.parentElement) { + fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview, setModalIcon)); + } + $('btnAddMap').addEventListener('click', () => addMapRow()); + $('fTokenToggle').addEventListener('click', () => { + const f = $('fToken'); const show = f.type === 'password'; + f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show'); + }); + $('btnSave').addEventListener('click', () => { if (onSave) onSave(); }); + $('btnTest').addEventListener('click', () => { if (onTest) onTest(); }); +} + +// A backend config push while the modal edits a provider: if the baseUrl input still shows the +// pre-push value (e.g. the /v1 auto-migration fired during a connection test), sync it in place. +onConfigReplaced((prev, next) => { + if (!editingId) return; + const previousProvider = prev.providers && prev.providers.find((p) => p.id === editingId); + const baseUrlInput = $('fBaseUrl'); + if (!previousProvider || !baseUrlInput) return; + if (baseUrlInput.value === (previousProvider.baseUrl || '')) { + const updatedProvider = next.providers.find((p) => p.id === editingId); + if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || ''; + } +}); diff --git a/src/renderer/js/views/providers/presets.js b/src/renderer/js/views/providers/presets.js new file mode 100644 index 0000000..a52c95a --- /dev/null +++ b/src/renderer/js/views/providers/presets.js @@ -0,0 +1,20 @@ +/* + * Provider presets. Each preset declares its wire `protocol` up front — Anthropic-native + * endpoints (the `/anthropic` gateways) pass through directly; OpenAI-compatible endpoints + * are auto-translated. Picking a preset sets the protocol so the user knows immediately how + * their requests will be handled. + */ +export const PRESETS = { + glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic/v1', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' }, + deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' }, + mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' }, + kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' }, + minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' }, + nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' }, + google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' }, + openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' }, + openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' }, + custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' }, +}; + +export const PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' }; diff --git a/src/renderer/js/views/registry.js b/src/renderer/js/views/registry.js new file mode 100644 index 0000000..cbe44f7 --- /dev/null +++ b/src/renderer/js/views/registry.js @@ -0,0 +1,104 @@ +/* + * View registry — lazy view mounting + switching. + * + * The 服务 view ships inline in index.html (first paint); every other view is an ES module + * that mounts its template into #mainScroll on FIRST switch. That keeps cold start down to + * the shell + one view, and gives each view a single owner module (high cohesion): + * a view module exposes { id, mount(host), onShow?() } and registers its own renderer + * via core/state.onRender once mounted. + */ +import { $ } from '../core/dom.js'; +import { api } from '../core/bridge.js'; + +const LOADERS = { + plugins: () => import('./plugins/index.js'), + monitor: () => import('./monitor/index.js'), + settings: () => import('./settings/index.js'), + conversations: () => import('./conversations/index.js'), +}; +const mounted = new Map(); // view -> module (providers pre-registered by main.js) + +export function registerView(name, mod) { mounted.set(name, mod); } +export function isMounted(name) { return mounted.has(name); } + +async function ensureView(name) { + if (mounted.has(name)) return mounted.get(name); + const mod = (await LOADERS[name]()).default; + // Re-check: a fast double-click can race two imports of the same view. + if (!mounted.has(name)) { + mod.mount($('mainScroll')); + mounted.set(name, mod); + } + return mounted.get(name); +} + +/** Idle-time warm-up so the first click on a heavy view doesn't pay its mount. */ +export function prefetchView(name) { ensureView(name).catch(() => {}); } + +const viewIds = { + providers: 'view-providers', + plugins: 'view-plugins', + monitor: 'view-monitor', + conversations: 'view-conversations', + settings: 'view-settings', +}; +let current = 'providers'; +export function currentView() { return current; } + +export async function switchView(view) { + if (!(view in LOADERS) && view !== 'providers') return; + const mod = await ensureView(view).catch((e) => { console.error('[ccbud] view failed to load', view, e); return null; }); + if (!mod) return; // leave the current view in place rather than blanking the panel + document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); + current = view; + + // Smooth fade between views + const views = Object.values(viewIds).map((id) => $(id)).filter(Boolean); + const currentEl = views.find((el) => !el.classList.contains('hidden')); + const target = $(viewIds[view] || 'view-providers'); + + const doSwitch = () => { + views.forEach((el) => { + const isTarget = el === target; + el.classList.toggle('hidden', !isTarget); + if (!isTarget) { + el.style.transition = ''; + el.style.opacity = ''; + } + }); + $('btnAdd').classList.toggle('hidden', view !== 'providers'); + const emptyAdd = $('btnAddEmpty'); + if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers'); + + if (target) { + target.style.transition = 'none'; + target.style.opacity = '0'; + // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a + // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced). + requestAnimationFrame(() => { + target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)'; + target.style.opacity = '1'; + setTimeout(() => { if (target) target.style.transition = ''; }, 280); + }); + } + + if (mod && mod.onShow) mod.onShow(); + // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere. + if (api.setSettingsMode) api.setSettingsMode(view === 'settings'); + // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide + // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300. + if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900); + }; + + if (currentEl && currentEl !== target) { + currentEl.style.transition = 'opacity 0.12s ease'; + currentEl.style.opacity = '0'; + setTimeout(() => { + currentEl.style.transition = ''; + currentEl.style.opacity = ''; + doSwitch(); + }, 110); + } else { + doSwitch(); + } +} diff --git a/src/renderer/js/views/settings/about-pane.js b/src/renderer/js/views/settings/about-pane.js new file mode 100644 index 0000000..60053c9 --- /dev/null +++ b/src/renderer/js/views/settings/about-pane.js @@ -0,0 +1,147 @@ +/* 设置 → 关于与更新 pane: in-app update state machine + links + auto toggles. */ +import { $, show, copyFeedback } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { state } from '../../core/state.js'; +import { confirmDialog } from '../../core/toast.js'; +import { SWITCH_TRACK } from './gateway-template.js'; + +export const aboutPaneTemplate = () => ` + + + `; + +let updateState = null; +let updateBusy = false; + +export function renderUpdate() { + const s = updateState; + const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip'); + const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'), + bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes'); + if (!verEl) return; + if (s) { + verEl.textContent = s.runningVersion || s.shellVersion || '—'; + latEl.textContent = s.latestVersion || '—'; + } + // reset + [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false)); + show(actions, false); show(notes, false); show(chip, false); + if (chip) chip.classList.remove('text-green', 'text-amber'); + + if (!s) { stEl.textContent = I18n.t('about.idle'); return; } + const staged = s.pending && s.pending.staged; + if (staged) { + stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version }); + chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true); + show(actions, true); show(bApply, true); + return; + } + if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; } + if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; } + if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; } + + // an update is available + chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true); + show(actions, true); + if (s.notes) { notes.textContent = s.notes; show(notes, true); } + if (s.mode === 'hot') { + stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion }); + show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall'); + } else { // full + stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion }); + show(bOpen, true); + if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); } + } +} + +export async function loadUpdateState() { + try { updateState = await api.updateState(); } catch (_) {} + syncAutoToggles(); + renderUpdate(); +} + +export async function checkUpdate() { + const btn = $('btnUpdateCheck'); + if (btn) btn.disabled = true; + $('updStatus').textContent = I18n.t('about.checking'); + try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; } + if (btn) btn.disabled = false; + renderUpdate(); +} + +async function downloadUpdate() { + updateBusy = true; renderUpdate(); + let res; + try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; } + updateBusy = false; + try { updateState = await api.updateState(); } catch (_) {} + if (res && !res.ok && updateState) updateState.error = res.error; + renderUpdate(); +} + +function syncAutoToggles() { + const au = (state.config && state.config.autoUpdate) || {}; + const c = $('fAutoCheck'), d = $('fAutoDownload'); + if (c) c.checked = au.check !== false; + if (d) d.checked = au.autoDownload !== false; +} + +export function bindAboutPane() { + $('btnUpdateCheck').addEventListener('click', checkUpdate); + $('btnUpdateDownload').addEventListener('click', downloadUpdate); + $('btnUpdateApply').addEventListener('click', async () => { + const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') }); + if (ok) api.updateApply(); + }); + $('btnUpdateOpen').addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest')); + $('btnUpdateBrew').addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud', I18n.t('copy.copiedCheck'), api.copy)); + $('btnRepo').addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud')); + $('btnReleases').addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases')); + $('fAutoCheck').addEventListener('change', async (e) => { state.config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); }); + $('fAutoDownload').addEventListener('change', async (e) => { state.config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); }); + if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); }); + // The staged-update LOG line is emitted from boot (main.js); here we only refresh this pane. + if (api.onUpdateStaged) api.onUpdateStaged(() => loadUpdateState()); +} diff --git a/src/renderer/js/views/settings/conv-font.js b/src/renderer/js/views/settings/conv-font.js new file mode 100644 index 0000000..07f6003 --- /dev/null +++ b/src/renderer/js/views/settings/conv-font.js @@ -0,0 +1,25 @@ +/* + * 会话正文字号 (Sessions message text size). The 对话 message timeline scales through one + * root CSS var (--conv-fs, a factor of the 13px base — see input.css). Persisted as + * config.convFontPx: absent/13 = default, 15 = 大, 17 = 特大, anything else = custom. + * Applied at boot (main.js) so the sessions view is right even before settings mounts. + */ +import { state } from '../../core/state.js'; + +export const CONV_FONT_BASE = 13; +export const CONV_FONT_PRESETS = { large: 15, xlarge: 17 }; +export const CONV_FONT_MIN = 10; +export const CONV_FONT_MAX = 24; + +export function convFontPx() { + const v = state.config.convFontPx; + const n = Number(v); + if (v == null || v === '' || !Number.isFinite(n) || n <= 0) return CONV_FONT_BASE; + return Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, Math.round(n))); +} + +export function applyConvFont() { + const px = convFontPx(); + if (px === CONV_FONT_BASE) document.documentElement.style.removeProperty('--conv-fs'); + else document.documentElement.style.setProperty('--conv-fs', String(px / CONV_FONT_BASE)); +} diff --git a/src/renderer/js/views/settings/data-pane.js b/src/renderer/js/views/settings/data-pane.js new file mode 100644 index 0000000..e9ebde6 --- /dev/null +++ b/src/renderer/js/views/settings/data-pane.js @@ -0,0 +1,65 @@ +/* 设置 → 数据 pane: history work directories. */ +import { $, escapeHtml } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { icons } from '../../core/icons.js'; +import { state, persist } from '../../core/state.js'; + +export const dataPaneTemplate = () => ` + `; + +export async function renderDataPane() { + const host = $('histDirList'); + if (!host) return; + let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; } + // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep + // them out of this list. + const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash); + host.innerHTML = dirs.map((d) => { + const status = d.exists === false + ? `${escapeHtml(I18n.t('settings.dirMissing'))}` + : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`; + return `
+ ${escapeHtml(d.label)} + ${status} + +
`; + }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`; +} + +async function addHistDirPath(v) { + v = (v || '').trim(); + if (!v) return; + const dirs = (state.config.historyDirs || []).slice(); + if (!dirs.includes(v)) dirs.push(v); + await persist({ historyDirs: dirs }); +} + +async function pickHistDir() { + if (!api.historyPickDir) return; + let res; try { res = await api.historyPickDir(); } catch (_) { return; } + if (!res || res.canceled || !res.path) return; + await addHistDirPath(res.path); +} + +export function bindDataPane() { + // History directories — primary action opens a native picker (hidden dirs shown) + $('btnPickHistDir').addEventListener('click', pickHistDir); + $('histDirList').addEventListener('click', async (e) => { + const btn = e.target.closest('[data-del-dir]'); + if (!btn || btn.disabled) return; + const id = btn.dataset.delDir; + if (id === '~/.claude') return; + const dirs = (state.config.historyDirs || []).filter((d) => d !== id); + await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] }); + }); +} diff --git a/src/renderer/js/views/settings/gateway-pane.js b/src/renderer/js/views/settings/gateway-pane.js new file mode 100644 index 0000000..a15121e --- /dev/null +++ b/src/renderer/js/views/settings/gateway-pane.js @@ -0,0 +1,93 @@ +/* 设置 → 网关 pane: gateway service, endpoint/port/export, connect targets, advanced. */ +import { $, copyFeedback } from '../../core/dom.js'; +import { api } from '../../core/bridge.js'; +import { I18n } from '../../core/i18n.js'; +import { state, persist, refresh, pushLocalLog } from '../../core/state.js'; +import { showHeroNote } from '../providers/hero.js'; +import { GATEWAY_PANE_HTML } from './gateway-template.js'; + +export const gatewayPaneTemplate = () => GATEWAY_PANE_HTML; + +// Multi-select for which coding CLIs "一键接入" wires to the gateway. Each toggle reflects +// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled +// (with a note) until it's installed. +function renderConnectTargets() { + // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection. + const cc = $('fTargetClaude'), cx = $('fTargetCodex'); + if (cc) cc.checked = !!state.status.connectedClaude; + if (cx) cx.checked = !!state.status.connectedCodex; + const codexOk = state.status.codexAvailable !== false; + const row = $('targetCodexRow'), note = $('targetCodexNote'); + if (cx) cx.disabled = !codexOk; + if (row) row.style.opacity = codexOk ? '1' : '0.55'; + if (note) note.style.display = codexOk ? 'none' : ''; + const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); }; + chip($('tgtClaudeChip'), !!state.status.connectedClaude); + chip($('tgtCodexChip'), !!state.status.connectedCodex); +} + +// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or +// stops the gateway as needed), so unchecking Claude Code actually turns it off. +async function toggleTarget(target, on) { + if (!api.setConnectTarget) return; + let res; + try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; } + if (res && res.ok === false) { + // couldn't turn on (no provider / port) → revert the switch + surface the reason + const msg = res.reason === 'noProvider' ? I18n.t('err.noProvider') : (res.message || I18n.t('err.opFailed')); + try { showHeroNote(msg, true); } catch (_) {} + const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude'); + if (el) el.checked = !on; + return; + } + await refresh(); +} + +export function renderGatewayPane() { + const port = (state.status.running && state.status.port) || state.config.port; + $('endpoint').textContent = `http://localhost:${port}`; + $('portInput').value = state.config.port; + const token = state.config.requireToken && state.config.gatewayToken ? state.config.gatewayToken : 'ccbud-local'; + $('exportBlock').textContent = [ + `export ANTHROPIC_BASE_URL=http://localhost:${port}`, + `export ANTHROPIC_AUTH_TOKEN=${token}`, + '', + I18n.t('settings.exportHint'), + ].join('\n'); + $('claudePath').textContent = state.status.claudePath ? I18n.t('settings.claudePath') + state.status.claudePath : ''; + if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = state.status.gatewayEnabled !== false; + if ($('fRetry429')) $('fRetry429').checked = !(state.config.retry429 && state.config.retry429.enabled === false); + if ($('fInsecureTls')) $('fInsecureTls').checked = !!state.config.insecureSkipVerify; + renderConnectTargets(); + const se = $('startError'); + if (state.status.lastStartError) { se.textContent = state.status.lastStartError; se.classList.remove('hidden'); } + else se.classList.add('hidden'); +} + +export function bindGatewayPane() { + $('portInput').addEventListener('change', async (e) => { + const port = Number(e.target.value); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + e.target.value = state.config.port; + pushLocalLog({ level: 'error', msg: I18n.t('err.portInvalid') }); + return; + } + await persist({ port }); + }); + $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent, I18n.t('copy.copiedCheck'), api.copy)); + document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent, I18n.t('copy.copiedCheck'), api.copy))); + if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked)); + if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked)); + if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => { + const on = e.target.checked; + let res; + try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; } + if (res && res.ok === false) { + e.target.checked = !on; // couldn't bind the port → revert + surface + try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {} + } + await refresh(); + }); + if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, state.config.retry429, { enabled: e.target.checked }) })); + if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked })); +} diff --git a/src/renderer/js/views/settings/gateway-template.js b/src/renderer/js/views/settings/gateway-template.js new file mode 100644 index 0000000..6556c17 --- /dev/null +++ b/src/renderer/js/views/settings/gateway-template.js @@ -0,0 +1,50 @@ +/* 设置 → 网关 pane markup (verbatim from the former inline HTML). */ +const SWITCH_TRACK = ``; +export { SWITCH_TRACK }; + +export const GATEWAY_PANE_HTML = ` +
+
+

网关

+

本机网关服务:接入的 CLI 都通过它转发请求。

+
+
网关服务
独立于接入配置的本机服务开关;停止后所有转发暂停,接入配置保持不变。
+ +
+
+ http://localhost:8788 + + +
+

+              
+ + +
+

若曾在终端 export ANTHROPIC_BASE_URL,请删除以免覆盖。

+
+
+

接入目标

+

选择「一键接入」要接管的编程 CLI;接入时自动写入各自的配置文件。

+
+
Claude Code
+ +
+
+
Codex
+ +
+ +
+
+

高级

+
+
429 自动重试
供应商限流(429)时自动重试几次,再如实返回,减少偶发失败。
+ +
+
+
忽略上游 TLS 证书校验
会降低安全性。仅在自签名 / 企业代理证书导致连接失败时临时开启,平时请保持关闭。
+ +
+
+
`; diff --git a/src/renderer/js/views/settings/general-pane.js b/src/renderer/js/views/settings/general-pane.js new file mode 100644 index 0000000..94e6706 --- /dev/null +++ b/src/renderer/js/views/settings/general-pane.js @@ -0,0 +1,170 @@ +/* 设置 → 常规 pane: app switches, tray usage, language, sessions font size. */ +import { $ } from '../../core/dom.js'; +import { I18n } from '../../core/i18n.js'; +import { state, persist, renderAll } from '../../core/state.js'; +import { SWITCH_TRACK } from './gateway-template.js'; +import { CONV_FONT_BASE, CONV_FONT_PRESETS, CONV_FONT_MIN, CONV_FONT_MAX, convFontPx, applyConvFont } from './conv-font.js'; + +export const generalPaneTemplate = () => ` + `; + +let convFontCustomOpen = false; // 自定义 clicked but value not (yet) diverging from a preset + +function convFontMode(px) { + if (convFontCustomOpen) return 'custom'; + if (px === CONV_FONT_PRESETS.large) return 'large'; + if (px === CONV_FONT_PRESETS.xlarge) return 'xlarge'; + return px === CONV_FONT_BASE ? 'default' : 'custom'; +} + +function renderConvFontControl() { + const seg = $('fConvFontSeg'); + if (!seg) return; + const px = convFontPx(); + const mode = convFontMode(px); + seg.querySelectorAll('.seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.cfs === mode)); + const row = $('convFontCustomRow'); + if (row) row.classList.toggle('hidden', mode !== 'custom'); + const input = $('fConvFontPx'); + if (input && document.activeElement !== input) input.value = px; + const chip = $('convFontPreviewPx'); + if (chip) chip.textContent = px + 'px'; +} + +function genToken() { + const a = new Uint8Array(18); + crypto.getRandomValues(a); + return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export function renderGeneralPane() { + $('fOpenAtLogin').checked = !!state.config.openAtLogin; + $('fRequireToken').checked = !!state.config.requireToken; + $('fGatewayToken').value = state.config.gatewayToken || ''; + $('tokenRow').classList.toggle('hidden', !state.config.requireToken); + const tu = state.config.trayUsage || { enabled: false, range: '7d' }; + $('fTrayUsage').checked = !!tu.enabled; + $('fTrayRange').value = tu.range || '7d'; + $('trayRangeRow').classList.toggle('hidden', !tu.enabled); + if ($('fLang')) $('fLang').value = state.config.language || I18n.lang; + applyConvFont(); + renderConvFontControl(); +} + +export function bindGeneralPane() { + $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked })); + $('fRequireToken').addEventListener('change', (e) => { + const requireToken = e.target.checked; + const patch = { requireToken }; + if (requireToken && !state.config.gatewayToken) patch.gatewayToken = genToken(); + persist(patch); + }); + $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() })); + $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true })); + $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } })); + $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } })); + if ($('fLang')) $('fLang').addEventListener('change', async (e) => { + const language = e.target.value; + await I18n.setLang(language); // updates + localStorage['ccbud-lang'] + I18n.apply(document); // static data-i18n nodes + renderAll(); // dynamic strings (hero/status/monitor/providers/settings) + if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang(); + await persist({ language }); // → config:save → main rebuilds tray on next open + }); + // 会话正文字号: preset segments persist directly; 自定义 opens the px input (which persists + // on change). The preview + open Sessions view update live through the --conv-fs root var. + $('fConvFontSeg').addEventListener('click', (e) => { + const b = e.target.closest('.seg-btn'); + if (!b) return; + const mode = b.dataset.cfs; + if (mode === 'custom') { + convFontCustomOpen = true; + renderConvFontControl(); + const input = $('fConvFontPx'); + if (input) { input.focus(); input.select(); } + return; + } + convFontCustomOpen = false; + const px = mode === 'large' ? CONV_FONT_PRESETS.large : mode === 'xlarge' ? CONV_FONT_PRESETS.xlarge : CONV_FONT_BASE; + persist({ convFontPx: px === CONV_FONT_BASE ? null : px }); + }); + const fConvFontPx = $('fConvFontPx'); + if (fConvFontPx) { + const commit = () => { + const n = Math.round(Number(fConvFontPx.value)); + if (!Number.isFinite(n)) { fConvFontPx.value = convFontPx(); return; } + const px = Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, n)); + fConvFontPx.value = px; + persist({ convFontPx: px === CONV_FONT_BASE ? null : px }); + }; + fConvFontPx.addEventListener('change', commit); + fConvFontPx.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); + } +} diff --git a/src/renderer/js/views/settings/index.js b/src/renderer/js/views/settings/index.js new file mode 100644 index 0000000..81556e0 --- /dev/null +++ b/src/renderer/js/views/settings/index.js @@ -0,0 +1,91 @@ +/* 设置 view — subnav shell + four panes (gateway/general/data/about), each its own module. */ +import { $, injectIcons } from '../../core/dom.js'; +import { icons } from '../../core/icons.js'; +import { I18n } from '../../core/i18n.js'; +import { onRender } from '../../core/state.js'; +import { gatewayPaneTemplate, renderGatewayPane, bindGatewayPane } from './gateway-pane.js'; +import { generalPaneTemplate, renderGeneralPane, bindGeneralPane } from './general-pane.js'; +import { dataPaneTemplate, renderDataPane, bindDataPane } from './data-pane.js'; +import { aboutPaneTemplate, bindAboutPane, loadUpdateState, checkUpdate } from './about-pane.js'; + +const SECTION_HTML = () => ` + `; + +// Settings sub-nav: keep the main panel focused on one section at a time. +export function switchSettings(pane) { + const nav = $('settingsNav'); + if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane)); + const panes = $('settingsPanes'); + if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane)); + // Refresh the live cards the moment their section is revealed. + if (pane === 'about') loadUpdateState(); +} + +function bindSubnav() { + const settingsNav = $('settingsNav'); + settingsNav.addEventListener('click', (e) => { + const b = e.target.closest('.settings-subnav-item'); + if (b && b.dataset.settings) switchSettings(b.dataset.settings); + }); + // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar. + const subnavBtn = $('btnSubnavCollapse'); + try { + if (localStorage.getItem('ccbud-subnav-collapsed') === '1') { + settingsNav.classList.add('collapsed'); + const ic = subnavBtn.querySelector('[data-icon]'); + if (ic && icons.chevronRight) ic.innerHTML = icons.chevronRight; + } + } catch (_) {} + subnavBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const collapsed = settingsNav.classList.toggle('collapsed'); + const ic = subnavBtn.querySelector('[data-icon]'); + if (ic) ic.innerHTML = collapsed ? (icons.chevronRight || '›') : (icons.chevronLeft || '‹'); + try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {} + }); +} + +export default { + id: 'settings', + mount(host) { + host.insertAdjacentHTML('beforeend', SECTION_HTML()); + const section = $('view-settings'); + I18n.apply(section); + injectIcons(section); + bindSubnav(); + bindGatewayPane(); + bindGeneralPane(); + bindDataPane(); + bindAboutPane(); + onRender(() => { renderGatewayPane(); renderGeneralPane(); renderDataPane(); }); + renderGatewayPane(); renderGeneralPane(); renderDataPane(); + }, + onShow() {}, + /** Jump straight to the About pane and run a check (tray “检查更新” entry point). */ + openAboutAndCheck() { switchSettings('about'); checkUpdate(); }, +}; diff --git a/src/renderer/popover.html b/src/renderer/popover.html index 475a85d..7ac4a71 100644 --- a/src/renderer/popover.html +++ b/src/renderer/popover.html @@ -5,6 +5,7 @@ CC Buddy +
@@ -51,10 +52,8 @@
- - - - - + + + diff --git a/src/renderer/popover.js b/src/renderer/popover.js deleted file mode 100644 index 528e279..0000000 --- a/src/renderer/popover.js +++ /dev/null @@ -1,200 +0,0 @@ -'use strict'; - -const api = window.ccbud; -let range = '7d'; -let tab = 'overview'; -let heatmapReady = false; - -const $ = (id) => document.getElementById(id); -const L = (k, p) => (window.I18n ? window.I18n.t(k, p) : k); - -function fmt(n) { - n = n || 0; - if (n < 1000) return String(n); - if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; - if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '') + 'M'; - return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; -} -function hourLabel(h) { - if (h == null) return '—'; - const tag = window.I18n ? window.I18n.localeTag : 'en-US'; - try { return new Date(2000, 0, 1, h).toLocaleTimeString(tag, { hour: 'numeric' }); } - catch (_) { const ap = h < 12 ? 'AM' : 'PM'; const hh = h % 12 === 0 ? 12 : h % 12; return `${hh} ${ap}`; } -} -function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } -function fmtDate(d) { - try { - const dt = new Date(`${d}T00:00:00`); - if (isNaN(dt)) return d; - return dt.toLocaleDateString(window.I18n ? window.I18n.localeTag : 'en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } catch (_) { return d; } -} - -// Instant, styled heatmap tooltip (replaces the slow/ugly native title). -let _tip = null; -function showHeatTip(cell) { - if (!_tip) { _tip = document.createElement('div'); _tip.className = 'hm-tip'; document.body.appendChild(_tip); } - _tip.innerHTML = `
${esc(cell.dataset.date || '')}
${esc(cell.dataset.val || '')}
`; - _tip.classList.add('show'); - const r = cell.getBoundingClientRect(); - const tw = _tip.offsetWidth, th = _tip.offsetHeight; - let x = Math.max(6, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 6)); - let y = r.top - th - 7; - if (y < 4) y = r.bottom + 7; - _tip.style.left = `${Math.round(x)}px`; - _tip.style.top = `${Math.round(y)}px`; -} -function hideHeatTip() { if (_tip) _tip.classList.remove('show'); } - -async function renderHeatmap() { - let u; - try { - u = await api.usageGet('all'); - } catch (e) { - console.error('usageGet(all) failed', e); - u = { heatmap: [] }; - } - const hm = $('heatmap'); - if (!hm) return; - hm.innerHTML = ''; - const levelBgs = { - 0: 'bg-[#c6ccd8] dark:bg-white/14', - 1: 'bg-[#5856d6]/34 dark:bg-[#7d7aff]/32', - 2: 'bg-[#5856d6]/55 dark:bg-[#7d7aff]/54', - 3: 'bg-[#5856d6]/76 dark:bg-[#7d7aff]/76', - 4: 'bg-brand dark:bg-[#7d7aff]' - }; - if (u && u.heatmap) { - for (const c of u.heatmap) { - const cell = document.createElement('div'); - cell.className = `hm-cell lv${c.level} rounded-[3px] transition-colors duration-200 ${levelBgs[c.level] || levelBgs[0]}`; - cell.dataset.date = fmtDate(c.date); - cell.dataset.val = `${fmt(c.tokens)} ${L('pop.tokensUnit')}`; - hm.appendChild(cell); - } - } - heatmapReady = true; -} - -async function renderStats() { - let u = null; - try { u = await api.usageGet(range); } catch (e) { console.error('usageGet failed', e); } - if (!u) { - // a failed scan must LOOK failed — zeros would read as "no usage" - $('sTokens').textContent = '—'; - $('sReq').textContent = '—'; - return; - } - $('sTokens').textContent = fmt(u.tokens); - $('sReq').textContent = (u.requests || 0).toLocaleString(); - $('sDays').textContent = u.activeDays || 0; - const elProv = $('sProv'); - const fullProv = u.favoriteProvider && u.favoriteProvider !== '—' ? u.favoriteProvider : ''; - elProv.textContent = u.favoriteProvider || '—'; - if (elProv.parentElement) { - if (fullProv) elProv.parentElement.setAttribute('data-tip', fullProv); - else elProv.parentElement.removeAttribute('data-tip'); - } - $('sCur').innerHTML = `${u.currentStreak || 0}${esc(L('time.unitDay'))}`; - $('sLong').innerHTML = `${u.longestStreak || 0}${esc(L('time.unitDay'))}`; - $('sPeak').textContent = u.peakHour == null ? '—' : hourLabel(u.peakHour); - const elModel = $('sModel'); - const fullModel = u.favoriteModel && u.favoriteModel !== '—' ? u.favoriteModel : ''; - elModel.textContent = u.favoriteModel || '—'; - if (elModel.parentElement) { - if (fullModel) elModel.parentElement.setAttribute('data-tip', fullModel); - else elModel.parentElement.removeAttribute('data-tip'); - } - - const ml = $('modelList'); - if (ml) { - ml.innerHTML = ''; - const byModel = u.byModel || []; - if (!byModel.length) ml.innerHTML = `
${esc(L('pop.noData'))}
`; - for (const m of byModel.slice(0, 12)) { - const row = document.createElement('div'); - row.className = 'model-row flex items-center gap-2'; - row.innerHTML = ` -
${esc(m.model)}
-
-
${fmt(m.tokens)}
`; - ml.appendChild(row); - } - } -} - -async function render() { - if (!heatmapReady) await renderHeatmap(); - await renderStats(); -} - -async function renderStatus() { - const s = await api.serverStatus(); - const dot = $('popStatus').querySelector('.pulse-dot, .live-dot'); - dot.className = 'pulse-dot w-1.75 h-1.75 rounded-full shrink-0 ' + (s.running ? 'on bg-green animate-[pulse_2s_infinite]' : 'off bg-muted'); - $('popStatusText').textContent = s.running ? L('status.gwRunning') : L('status.gwStopped'); - $('popConnect').textContent = s.running ? L('pop.svcStop') : L('pop.svcStart'); - $('popConnect').dataset.running = s.running ? '1' : ''; -} - -function setTab(t) { - tab = t; - document.querySelectorAll('#popTabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.tab === t)); - $('tab-overview').classList.toggle('hidden', t !== 'overview'); - $('tab-models').classList.toggle('hidden', t !== 'models'); -} -function setRange(r) { - range = r; - document.querySelectorAll('#popRanges .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.range === r)); - renderStats(); -} - -function bind() { - $('popTabs').addEventListener('click', (e) => { if (e.target.dataset.tab) setTab(e.target.dataset.tab); }); - $('popRanges').addEventListener('click', (e) => { if (e.target.dataset.range) setRange(e.target.dataset.range); }); - $('popConnect').addEventListener('click', async (e) => { - e.target.disabled = true; - try { await api.gatewaySetEnabled(!e.target.dataset.running); } catch (_) {} - e.target.disabled = false; - renderStatus(); - }); - $('popOpen').addEventListener('click', () => api.openMain()); - $('popQuit').addEventListener('click', () => api.quitApp()); - const hm = $('heatmap'); - if (hm) { - hm.addEventListener('mouseover', (e) => { const c = e.target.closest('.hm-cell'); if (c) showHeatTip(c); }); - hm.addEventListener('mouseleave', hideHeatTip); - } - if (api.onPopoverShow) { - api.onPopoverShow(async () => { - applyTheme(); - applyLang(); - heatmapReady = false; - await render(); - renderStatus(); - }); - } -} - -function applyTheme() { - try { document.documentElement.setAttribute('data-theme', localStorage.getItem('ccbud-theme') || 'light'); } catch (_) {} -} -// The popover is a separate window; it reads the language from shared localStorage (set by the -// main window) on load and on every show, so a language change propagates the next time it opens. -function applyLang() { - try { - let l = localStorage.getItem('ccbud-lang') || ''; - if (!l) { - const nav = (navigator.language || 'en').toLowerCase(); - l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh') - : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en'; - } - if (window.I18n) { window.I18n.setLang(l); window.I18n.apply(document); } - } catch (_) {} -} - -applyTheme(); -applyLang(); -bind(); -render(); -renderStatus(); \ No newline at end of file diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js deleted file mode 100644 index 8c1b9d8..0000000 --- a/src/renderer/renderer.js +++ /dev/null @@ -1,1837 +0,0 @@ -'use strict'; - -const api = window.ccbud; -let config = { port: 8788, activeProviderId: null, providers: [] }; -let status = { running: false, port: null, connected: false, lastStartError: null, claudePath: '' }; -let editingId = null; -let modalIcon = null; // the icon being edited in the add/edit modal (emoji or image data-URL) -let dragId = null; -const stats = { total: 0, ok: 0, sumMs: 0, last: null }; - -const $ = (id) => document.getElementById(id); -const I = window.ccbudIcons || {}; - -function injectIcons(root) { - (root || document).querySelectorAll('[data-icon]').forEach((el) => { - const name = el.dataset.icon; - if (I[name]) el.innerHTML = I[name]; - }); -} - -// Each preset declares its wire `protocol` up front — Anthropic-native endpoints (the `/anthropic` -// gateways) pass through directly; OpenAI-compatible endpoints are auto-translated. Picking a preset -// sets the protocol so the user knows immediately how their requests will be handled. -const PRESETS = { - glm: { name: 'GLM', baseUrl: 'https://open.bigmodel.cn/api/anthropic/v1', defaultModel: 'glm-5.2', smallFastModel: 'glm-5.2', protocol: 'anthropic' }, - deepseek: { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/anthropic', defaultModel: 'deepseek-v4-pro', smallFastModel: 'deepseek-v4-flash', protocol: 'anthropic' }, - mimo: { name: 'MiMo', baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic', defaultModel: 'mimo-v2.5-pro', smallFastModel: 'mimo-v2.5', protocol: 'anthropic' }, - kimi: { name: 'Kimi', baseUrl: 'https://api.kimi.com/coding', defaultModel: 'kimi-for-coding', smallFastModel: 'kimi-for-coding', protocol: 'anthropic' }, - minimax: { name: 'MiniMax', baseUrl: 'https://api.minimax.io/anthropic', defaultModel: 'MiniMax-M3', smallFastModel: 'MiniMax-M3', protocol: 'anthropic' }, - nvidia: { name: 'NVIDIA', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'z-ai/glm-5.2', smallFastModel: 'z-ai/glm-5.2', protocol: 'openai-chat' }, - google: { name: 'Google AI Studio', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-3.5-flash', smallFastModel: 'gemini-3.1-flash-lite', protocol: 'openai-chat' }, - openai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.2', smallFastModel: 'gpt-5.2-mini', protocol: 'openai-responses' }, - openrouter: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: '', smallFastModel: '', protocol: 'openai-chat' }, - custom: { name: '', baseUrl: '', defaultModel: '', smallFastModel: '', protocol: 'anthropic' }, -}; -const PRESET_LABELS = { glm: 'GLM', deepseek: 'DeepSeek', mimo: 'MiMo', kimi: 'Kimi', minimax: 'MiniMax', nvidia: 'NVIDIA', google: 'Google AI Studio', openai: 'OpenAI', openrouter: 'OpenRouter', custom: '自定义' }; - -/* ---------- helpers ---------- */ -function escapeHtml(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); -} -// Unified 24-hour clock (HH:MM:SS) everywhere — language-independent, so the monitor stays -// consistent across language switches and the timestamp never wraps with a 12-hour "PM" suffix. -function fmtTime(d) { - const t = d == null ? new Date() : (d instanceof Date ? d : new Date(d)); - const p = (n) => String(n).padStart(2, '0'); - return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`; -} -function fmtNum(n) { - n = n || 0; - if (n < 1000) return String(n); - if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0).replace(/\.0$/, '') + 'K'; - if (n < 1e9) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '') + 'M'; - return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; -} -// Smooth brand-tinted area sparkline; stretches to its container via preserveAspectRatio="none". -function sparkSVG(vals) { - const W = 300, H = 46, pad = 4; - const data = (vals && vals.length) ? vals : [0, 0]; - const n = data.length; - const max = Math.max(1, ...data); - const xs = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad)); - const ys = (v) => H - pad - (v / max) * (H - 2 * pad - 2); - const pts = data.map((v, i) => [xs(i), ys(v)]); - let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; - for (let i = 0; i < pts.length - 1; i++) { - const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2; - const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; - const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; - line += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`; - } - const area = `${line} L ${pts[n - 1][0].toFixed(1)} ${H - pad} L ${pts[0][0].toFixed(1)} ${H - pad} Z`; - return ``; -} -const activeProvider = () => config.providers.find((p) => p.id === config.activeProviderId) || null; -function hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; } -// Deterministic "random" emoji set — a custom provider with no brand logo gets a stable one. -const ICON_EMOJIS = ['🤖', '🧠', '⚡', '🚀', '🦊', '🐳', '🌟', '💎', '🔮', '🎯', '🛰️', '🧩', '🔆', '🌀', '🦁', '🐲', '🦄', '🍀', '🔥', '❄️', '🌈', '🎨', '🧪', '📡', '🛡️', '🎲', '🌶️', '🦉', '🐙', '🪐', '✨', '🌊']; -function emojiIcon(emoji, name) { - const h = hashHue(name || '?'); - return { style: `background: linear-gradient(135deg, hsl(${h},62%,56%), hsl(${(h + 45) % 360},68%,46%))`, html: `${escapeHtml(emoji)}` }; -} -// icon (optional): a user-set image (data:/http) or emoji; otherwise brand logo, else a default emoji. -function renderProviderIcon(name, icon) { - if (icon && typeof icon === 'string') { - if (/^(data:|https?:|assets\/)/.test(icon)) return { style: 'background: transparent; box-shadow: none;', html: `` }; - return emojiIcon(icon, name); // a chosen emoji - } - const n = (name || '').trim().toLowerCase(); - const brand = { google: ['google ai studio', 'gemini', 'generativelanguage'], kimi: ['kimi', 'moonshot', '月之'], deepseek: ['deepseek'], zhipu: ['glm', '智谱', 'bigmodel'], xiaomi: ['mimo', '小米', 'xiaomi'], zenmux: ['zenmux'], minimax: ['minimax', 'mini max', '海螺'], nvidia: ['nvidia'] }; - for (const file in brand) { - // object-fit:contain keeps non-square logos from being stretched into the square icon slot. - const asset = file === 'google' ? 'google-ai-studio.png' : `${file}.svg`; - if (brand[file].some((k) => n.includes(k))) return { style: 'background: transparent; box-shadow: none;', html: `` }; - } - if (n.includes('claude') || n.includes('anthropic')) { - const h = hashHue(name || '?'); - return { style: `background: linear-gradient(135deg, hsl(28,70%,48%), hsl(${(h + 40) % 360},75%,45%))`, html: `` }; - } - return emojiIcon(ICON_EMOJIS[hashHue(name || '?') % ICON_EMOJIS.length], name); // default: deterministic emoji -} -function mask(t) { return !t ? I18n.t('providers.noKey') : t.length <= 10 ? '••••' : t.slice(0, 4) + '••••' + t.slice(-4); } - -/* ---------- hero / status ---------- */ -function showHeroNote(text, warn) { - const n = $('heroNote'); - if (n) { - n.textContent = text; - n.classList.remove('hidden'); - n.classList.toggle('warn', !!warn); - } -} -function hideHeroNote() { - const n = $('heroNote'); - if (n) { - n.classList.add('hidden'); - } -} - -// Floating toast — sits above modals/drawers (z above everything) so a result is never -// hidden by a scrolled-out container. type: 'ok' | 'err' | 'pending'. Click to dismiss. -// Uses inline styles (not Tailwind utilities) so it renders correctly regardless of the -// compiled CSS state. -function ensureToastHost() { - let host = document.getElementById('toastHost'); - if (!host) { - host = document.createElement('div'); - host.id = 'toastHost'; - // Toast text can carry backend error strings (paths, upstream URLs) — keep it out of Clarity replays. - host.setAttribute('data-clarity-mask', 'true'); - host.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none;'; - document.body.appendChild(host); - } - return host; -} -function showToast(text, type, opts) { - opts = opts || {}; - const host = ensureToastHost(); - const bg = type === 'ok' ? 'var(--green)' : type === 'err' ? 'var(--red)' : 'var(--primary)'; - const el = document.createElement('div'); - el.style.cssText = `pointer-events:auto;max-width:min(520px,90vw);padding:10px 16px;border-radius:10px;background:${bg};color:#fff;font-size:13px;font-weight:600;line-height:1.5;word-break:break-word;cursor:pointer;box-shadow:0 8px 28px rgba(17,24,39,0.22);animation:panelIn 0.18s cubic-bezier(0.23,1,0.32,1);`; - el.textContent = text; - const dismiss = () => { - if (el._gone) return; - el._gone = true; - clearTimeout(el._t); - el.style.transition = 'opacity 0.18s ease, transform 0.18s ease'; - el.style.opacity = '0'; - el.style.transform = 'translateY(-6px)'; - setTimeout(() => el.remove(), 180); - }; - el.addEventListener('click', dismiss); - el.dismiss = dismiss; - host.appendChild(el); - // pending toasts stay until explicitly replaced; results auto-dismiss (errors linger longer). - const ttl = opts.ttl != null ? opts.ttl : (type === 'pending' ? 0 : type === 'err' ? 6000 : 3500); - if (ttl) el._t = setTimeout(dismiss, ttl); - return el; -} - -let heroRange = '30d'; -async function renderHeroUsage() { - const wrap = $('heroUsage'); - if (!wrap || !api.usageGet) return; - let u; try { u = await api.usageGet(heroRange); } catch (_) { return; } - if (!u) return; - const port = (status.running && status.port) || config.port; - const ep = $('heroEndpointText'); if (ep) ep.textContent = `localhost:${port}`; - // a11y: the button's accessible name must contain its visible text (localhost:port). - const epBtn = $('heroEndpoint'); if (epBtn) epBtn.setAttribute('aria-label', `localhost:${port} · ${I18n.t('hero.copyEndpoint')}`); - const tk = $('heroTokens'); if (tk) tk.textContent = fmtNum(u.tokens || 0); - const rq = $('heroReqs'); if (rq) rq.textContent = I18n.t('hero.reqsN', { n: (u.requests || 0).toLocaleString() }); - const md = $('heroModel'); if (md) md.textContent = u.favoriteModel && u.favoriteModel !== '—' ? `· ${u.favoriteModel}` : ''; - const days = heroRange === '7d' ? 7 : heroRange === '30d' ? 30 : 90; - const series = (u.heatmap || []).slice(-days).map((c) => c.tokens || 0); - const sp = $('heroSpark'); if (sp) sp.innerHTML = sparkSVG(series); -} -let _heroUsageT = null; -function scheduleHeroUsage() { - clearTimeout(_heroUsageT); - _heroUsageT = setTimeout(() => { const w = $('heroUsage'); if (status.connected && w && !w.classList.contains('hidden')) renderHeroUsage(); }, 2500); -} - -// Hero state = the gateway SERVICE (running/stopped). The button stays the config-file action -// ("一键接入"/"断开" writes or restores the CLIs' configs) — independent of the service switch. -function renderHero() { - const hero = $('hero'); - const ap = activeProvider(); - $('btnConnect').textContent = I18n.t(status.running ? 'hero.stopSvc' : 'hero.startSvc'); - if (status.running) { - hero.classList.add('connected'); - const icon = $('heroIcon'); - if (ap) { const pi = renderProviderIcon(ap.name, ap.icon); icon.setAttribute('style', pi.style || ''); icon.innerHTML = pi.html; } - else { icon.removeAttribute('style'); icon.innerHTML = I.connected || ''; } - $('heroTitle').textContent = ap ? ap.name : I18n.t('hero.running'); - $('heroSub').innerHTML = ap ? I18n.t('hero.connectedVia', { name: escapeHtml(ap.name) }) : I18n.t('hero.running'); - hideHeroNote(); - $('heroUsage').classList.remove('hidden'); - renderHeroUsage(); - } else { - hero.classList.remove('connected'); - const icon = $('heroIcon'); - icon.removeAttribute('style'); - icon.innerHTML = I.connect || ''; - $('heroTitle').textContent = I18n.t('hero.titleIdle'); - $('heroSub').textContent = I18n.t('hero.subIdle'); - hideHeroNote(); - $('heroUsage').classList.add('hidden'); - } -} - -function renderStatus() { - const chip = $('statusPill'); - if (chip) { - chip.classList.toggle('on', !!status.running); - const txt = chip.querySelector('.status-text'); - if (txt) { - txt.textContent = I18n.t(status.running ? 'status.gwRunning' : 'status.gwStopped'); - } - const bt = $('brandTitle'); - if (bt) { - bt.classList.toggle('running', !!status.running); - } - } -} - -// Multi-select for which coding CLIs "一键接入" wires to the gateway. Each toggle reflects -// config.connectTargets; each row's chip shows that CLI's live connected state. Codex is disabled -// (with a note) until it's installed. -function renderConnectTargets() { - // The switch reflects the ACTUAL connection (a live on/off), not just the saved selection. - const cc = $('fTargetClaude'), cx = $('fTargetCodex'); - if (cc) cc.checked = !!status.connectedClaude; - if (cx) cx.checked = !!status.connectedCodex; - const codexOk = status.codexAvailable !== false; - const row = $('targetCodexRow'), note = $('targetCodexNote'); - if (cx) cx.disabled = !codexOk; - if (row) row.style.opacity = codexOk ? '1' : '0.55'; - if (note) note.style.display = codexOk ? 'none' : ''; - const chip = (el, on) => { if (!el) return; el.className = 'proto-badge ' + (on ? 'proto-badge-xlate' : 'proto-badge-direct'); el.textContent = I18n.t(on ? 'settings.targetOn' : 'settings.targetOff'); }; - chip($('tgtClaudeChip'), !!status.connectedClaude); - chip($('tgtCodexChip'), !!status.connectedCodex); -} -// Live per-CLI switch: flipping a target immediately connects/disconnects that CLI (and starts or -// stops the gateway as needed), so unchecking Claude Code actually turns it off. -async function toggleTarget(target, on) { - if (!api.setConnectTarget) return; - let res; - try { res = await api.setConnectTarget(target, on); } catch (_) { res = null; } - if (res && res.ok === false) { - // couldn't turn on (no provider / port) → revert the switch + surface the reason - const msg = res.reason === 'noProvider' ? I18n.t('err.noProvider') : (res.message || I18n.t('err.opFailed')); - try { showHeroNote(msg, true); } catch (_) {} - const el = target === 'codex' ? $('fTargetCodex') : $('fTargetClaude'); - if (el) el.checked = !on; - return; - } - config = await api.getConfig(); - status = await api.serverStatus(); - renderAll(); -} -function renderConnect() { - const port = (status.running && status.port) || config.port; - $('endpoint').textContent = `http://localhost:${port}`; - $('portInput').value = config.port; - const token = config.requireToken && config.gatewayToken ? config.gatewayToken : 'ccbud-local'; - $('exportBlock').textContent = [ - `export ANTHROPIC_BASE_URL=http://localhost:${port}`, - `export ANTHROPIC_AUTH_TOKEN=${token}`, - '', - I18n.t('settings.exportHint'), - ].join('\n'); - $('claudePath').textContent = status.claudePath ? I18n.t('settings.claudePath') + status.claudePath : ''; - $('fOpenAtLogin').checked = !!config.openAtLogin; - $('fRequireToken').checked = !!config.requireToken; - $('fGatewayToken').value = config.gatewayToken || ''; - $('tokenRow').classList.toggle('hidden', !config.requireToken); - if ($('fGatewayEnabled')) $('fGatewayEnabled').checked = status.gatewayEnabled !== false; - if ($('fRetry429')) $('fRetry429').checked = !(config.retry429 && config.retry429.enabled === false); - if ($('fInsecureTls')) $('fInsecureTls').checked = !!config.insecureSkipVerify; - renderConnectTargets(); - const tu = config.trayUsage || { enabled: false, range: '7d' }; - $('fTrayUsage').checked = !!tu.enabled; - $('fTrayRange').value = tu.range || '7d'; - $('trayRangeRow').classList.toggle('hidden', !tu.enabled); - if ($('fLang')) $('fLang').value = config.language || I18n.lang; - const se = $('startError'); - if (status.lastStartError) { se.textContent = status.lastStartError; se.classList.remove('hidden'); } - else se.classList.add('hidden'); - renderHistoryDirs(); - applyConvFont(); - renderConvFontControl(); -} - -/* ---------- 会话正文字号 (Sessions message text size) ---------- */ -// The 对话 message timeline scales through one root CSS var (--conv-fs, a factor of the 13px -// base — see input.css). Persisted as config.convFontPx: absent/13 = default, 15 = 大, -// 17 = 特大, anything else = custom. -const CONV_FONT_BASE = 13; -const CONV_FONT_PRESETS = { large: 15, xlarge: 17 }; -const CONV_FONT_MIN = 10, CONV_FONT_MAX = 24; -let convFontCustomOpen = false; // 自定义 clicked but value not (yet) diverging from a preset - -function convFontPx() { - const v = config.convFontPx; - const n = Number(v); - if (v == null || v === '' || !Number.isFinite(n) || n <= 0) return CONV_FONT_BASE; - return Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, Math.round(n))); -} -function applyConvFont() { - const px = convFontPx(); - if (px === CONV_FONT_BASE) document.documentElement.style.removeProperty('--conv-fs'); - else document.documentElement.style.setProperty('--conv-fs', String(px / CONV_FONT_BASE)); -} -function convFontMode(px) { - if (convFontCustomOpen) return 'custom'; - if (px === CONV_FONT_PRESETS.large) return 'large'; - if (px === CONV_FONT_PRESETS.xlarge) return 'xlarge'; - return px === CONV_FONT_BASE ? 'default' : 'custom'; -} -function renderConvFontControl() { - const seg = $('fConvFontSeg'); - if (!seg) return; - const px = convFontPx(); - const mode = convFontMode(px); - seg.querySelectorAll('.seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.cfs === mode)); - const row = $('convFontCustomRow'); - if (row) row.classList.toggle('hidden', mode !== 'custom'); - const input = $('fConvFontPx'); - if (input && document.activeElement !== input) input.value = px; - const chip = $('convFontPreviewPx'); - if (chip) chip.textContent = px + 'px'; -} - -async function renderHistoryDirs() { - const host = $('histDirList'); - if (!host) return; - let data; try { data = await api.historyDirs(); } catch (_) { data = { dirs: [] }; } - // The synthetic buckets (导入 store / 回收站) are app-managed, not user work dirs — keep - // them out of this list. - const dirs = (data.dirs || []).filter((d) => !d.imported && !d.trash); - host.innerHTML = dirs.map((d) => { - const status = d.exists === false - ? `${escapeHtml(I18n.t('settings.dirMissing'))}` - : `${escapeHtml(I18n.t('settings.sessions', { n: d.sessions }))}`; - return `
- ${escapeHtml(d.label)} - ${status} - -
`; - }).join('') || `
${escapeHtml(I18n.t('settings.none'))}
`; -} -async function addHistDirPath(v) { - v = (v || '').trim(); - if (!v) return; - const dirs = (config.historyDirs || []).slice(); - if (!dirs.includes(v)) dirs.push(v); - await persist({ historyDirs: dirs }); -} -async function pickHistDir() { - if (!api.historyPickDir) return; - let res; try { res = await api.historyPickDir(); } catch (_) { return; } - if (!res || res.canceled || !res.path) return; - await addHistDirPath(res.path); -} - -function renderProviders() { - const list = $('providerList'); - list.innerHTML = ''; - $('emptyProviders').classList.toggle('hidden', config.providers.length > 0); - for (const p of config.providers) { - const isActive = p.id === config.activeProviderId; - const el = document.createElement('div'); - el.className = 'provider group grid grid-cols-[14px_36px_1fr_minmax(72px,auto)_auto] items-center gap-3 p-2.5 pr-3.5 pl-2.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card cursor-pointer relative transition-all duration-150 hover:border-border-strong hover:shadow-card-hover hover:-translate-y-0.25 [&.active]:border-green/38 [&.active]:bg-[color-mix(in_srgb,var(--bg-elev)_90%,var(--green)_10%)] [&.dragging]:opacity-40 [&.dragging]:scale-99 [&.drag-over]:border-brand [&.drag-over]:bg-brand-soft ' + (isActive ? 'active' : ''); - el.draggable = true; - el.dataset.id = p.id; - - const tags = []; - if (p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagMain'))} ${escapeHtml(p.defaultModel)}`); - if (p.smallFastModel && p.smallFastModel !== p.defaultModel) tags.push(`${escapeHtml(I18n.t('providers.tagFast'))} ${escapeHtml(p.smallFastModel)}`); - for (const m of p.models || []) tags.push(`${escapeHtml(m.alias)} → ${escapeHtml(m.upstream)}`); - - const iconData = renderProviderIcon(p.name, p.icon); - // Protocol badge so the wire protocol (and whether requests are translated) is visible at a - // glance on every provider. Anthropic (passthrough) is the quiet default; the translated ones - // stand out. - const proto = p.protocol || 'anthropic'; - const protoMeta = proto === 'openai-chat' - ? { label: 'OpenAI Chat', cls: 'proto-badge-xlate' } - : proto === 'openai-responses' - ? { label: 'OpenAI Responses', cls: 'proto-badge-xlate' } - : { label: 'Anthropic', cls: 'proto-badge-direct' }; - const protoBadge = `${escapeHtml(protoMeta.label)}`; - el.innerHTML = ` - -
${iconData.html}
-
-
${escapeHtml(p.name)} ${protoBadge} ${isActive ? '' + escapeHtml(I18n.t('providers.active')) + '' : ''}
-
${escapeHtml(mask(p.authToken))} · ${escapeHtml(p.baseUrl.replace(/^https?:\/\//,''))}
-
-
${tags.join('') || ''}
-
- - - -
`; - list.appendChild(el); - } -} - -/* ---------- plugins (sidecar coding-agent backends) ---------- */ -const PLUGIN_DOCS_URL = 'https://github.com/ccbud/ccbud/blob/main/docs/plugin-system.md'; -const pluginActionsById = {}; // pluginId -> declared actions (for the form modal) -let pluginListWired = false; -async function loadPlugins() { - const list = $('pluginList'); - if (!list) return; - if (!pluginListWired) { - pluginListWired = true; - list.addEventListener('click', onPluginAction); - const bd = $('linkPluginDocs'); - if (bd) bd.addEventListener('click', (e) => { e.preventDefault(); try { api.openExternal(PLUGIN_DOCS_URL); } catch (_) {} }); - const bi = $('btnPluginInstall'); - if (bi) bi.addEventListener('click', async () => { - try { const r = await api.pluginInstall(I18n.t('plugins.pickDir')); if (r && r.ok) showToast(I18n.t('plugins.added', { id: r.id }), 'ok'); } - catch (e) { showToast(I18n.t('plugins.addFailed', { msg: (e && e.message) || e }), 'err'); } - await loadPlugins(); - }); - const bo = $('btnPluginOpenDir'); - if (bo) bo.addEventListener('click', () => { try { api.pluginOpenDir(); } catch (_) {} }); - const bg = $('btnPluginGit'); - if (bg) bg.addEventListener('click', openPluginGitModal); - const bgc = $('btnGitCancel'); - if (bgc) bgc.addEventListener('click', closePluginGitModal); - const bgm = $('pluginGitModal'); - if (bgm) bgm.addEventListener('click', (e) => { if (e.target === bgm) closePluginGitModal(); }); - const bgo = $('btnPluginGitGo'); - if (bgo) bgo.addEventListener('click', importFromGit); - const gu = $('pluginGitUrl'); - if (gu) gu.addEventListener('keydown', (e) => { if (e.key === 'Enter') importFromGit(); }); - } - let plugins = []; - try { plugins = await api.pluginList(); } - catch (e) { showToast(I18n.t('plugins.loadFailed', { msg: (e && e.message) || e }), 'err'); } - const arr = Array.isArray(plugins) ? plugins : []; - renderPlugins(arr); - for (const p of arr) { if (p.hasSource) checkPluginUpdate(p.id); } -} -async function checkPluginUpdate(id) { - let r; - try { r = await api.pluginCheckUpdate(id); } catch (_) { return; } - if (!r || !r.updateAvailable) return; - const sel = (window.CSS && CSS.escape) ? CSS.escape(id) : id; - const slot = document.querySelector('[data-update-slot="' + sel + '"]'); - if (slot) slot.innerHTML = ''; -} -async function importFromGit() { - const u = $('pluginGitUrl'); - const url = ((u && u.value) || '').trim(); - if (!url) return; - closePluginGitModal(); - showPluginBusy(I18n.t('plugins.importing')); - try { - const r = await api.pluginInstallGit(url); - hidePluginBusy(); - if (r && r.ok) showToast(I18n.t('plugins.gitImported', { id: r.id }), 'ok'); - } catch (e) { - hidePluginBusy(); - showToast(I18n.t('plugins.gitFailed', { msg: (e && e.message) || e }), 'err'); - } - await loadPlugins(); -} -function renderPlugins(plugins) { - const list = $('pluginList'); - if (!list) return; - const empty = $('emptyPlugins'); - if (empty) empty.classList.toggle('hidden', plugins.length > 0); - list.innerHTML = ''; - for (const p of plugins) { - const running = !!p.running; - const auth = p.auth || {}; - const st = auth.state || ''; - const authLabel = st === 'logged_in' ? (I18n.t('plugins.authLoggedIn') + (auth.account ? ' · ' + auth.account : '')) - : st === 'expired' ? I18n.t('plugins.authExpired') - : st === 'logged_out' ? I18n.t('plugins.authLoggedOut') - : running ? I18n.t('plugins.authUnknown') : I18n.t('plugins.authNotRunning'); - const authColor = st === 'logged_in' ? 'text-green' : (st === 'expired' ? 'text-amber' : 'text-caption'); - const iconData = renderProviderIcon(p.name, p.icon); - const dot = ``; - const toggleBtn = ``; - const delBtn = ``; - // Plugin-declared actions (buttons/forms) — display driven entirely by the manifest. - const actionBtns = (Array.isArray(p.actions) ? p.actions : []).map((a) => { - if (!a || !a.id) return ''; - // Links open in a browser and never touch the plugin, so they don't need it - // running; form/call actions default to requiring a running plugin. - const needsRun = a.kind === 'link' ? (a.requiresRunning === true) : (a.requiresRunning !== false); - const disabled = needsRun && !running; - const label = escapeHtml(a.label || a.id); - return ``; - }).join(''); - pluginActionsById[p.id] = Array.isArray(p.actions) ? p.actions : []; - const el = document.createElement('div'); - el.className = 'plugin group grid grid-cols-[36px_1fr_auto] items-center gap-3 p-2.5 pr-3.5 min-h-[60px] bg-bg-elev border border-border-custom rounded-[13px] shadow-card relative transition-all duration-150 hover:border-border-strong'; - el.dataset.id = p.id; - el.innerHTML = ` -
${iconData.html}
-
-
${escapeHtml(p.name || p.id)} v${escapeHtml(p.version || '')}${p.official ? ' ' + escapeHtml(I18n.t('plugins.trusted')) + '' : ''} ${escapeHtml(p.protocol || '')}
-
${escapeHtml(p.description || '')}
-
${dot}${running ? escapeHtml(I18n.t('plugins.running')) : escapeHtml(I18n.t('plugins.stopped'))} · ${escapeHtml(authLabel)}
-
-
${actionBtns}${toggleBtn}${delBtn}
`; - list.appendChild(el); - } -} -async function onPluginAction(e) { - const actionBtn = e.target.closest('[data-plugin-actionbtn]'); - if (actionBtn) { await runPluginDeclaredAction(actionBtn); return; } - const toggle = e.target.closest('[data-plugin-toggle]'); - const uninstall = e.target.closest('[data-plugin-uninstall]'); - const update = e.target.closest('[data-plugin-update]'); - const btn = toggle || uninstall || update; - if (!btn) return; - btn.disabled = true; - try { - if (toggle) { - const enabling = toggle.dataset.enabled !== '1'; - // Starting a sidecar spawns a process and health-gates it (can take a - // beat), so give the button an immediate spinner + "Starting…". - if (enabling) setPluginBtnBusy(toggle, I18n.t('plugins.starting')); - markPluginCardBusy(toggle); - await api.pluginSetEnabled(toggle.dataset.pluginToggle, enabling); - config = await api.getConfig(); // enabling adds a provider, disabling removes it - renderProviders(); - } else if (uninstall) { - const ok = await confirmDialog({ - title: I18n.t('plugins.deleteTitle'), - message: I18n.t('plugins.deleteConfirmMsg', { name: uninstall.dataset.pluginName || uninstall.dataset.pluginUninstall }), - confirmText: I18n.t('plugins.confirmDelete'), - danger: true, - }); - if (!ok) { uninstall.disabled = false; return; } - const r = await api.pluginUninstall(uninstall.dataset.pluginUninstall); - if (!(r && r.canceled)) { config = await api.getConfig(); renderProviders(); } - } else if (update) { - update.disabled = true; - await api.pluginUpdate(update.dataset.pluginUpdate); - config = await api.getConfig(); renderProviders(); - } - } catch (err) { - showToast(I18n.t('plugins.opFailed', { msg: (err && err.message) || err }), 'err'); - } - await loadPlugins(); -} -function openPluginGitModal() { - const m = $('pluginGitModal'); if (!m) return; - m.classList.remove('hidden'); - const u = $('pluginGitUrl'); if (u) { u.value = ''; u.focus(); } -} -function closePluginGitModal() { const m = $('pluginGitModal'); if (m) m.classList.add('hidden'); } -// Full-screen blocking overlay shown while a git clone/build runs — the user -// cannot interact with the rest of the app until it finishes. -function showPluginBusy(text) { - let ov = document.getElementById('pluginBusy'); - if (!ov) { - ov = document.createElement('div'); - ov.id = 'pluginBusy'; - ov.className = 'overlay fixed inset-0 flex flex-col items-center justify-center z-[300] backdrop-blur-md'; - ov.style.background = 'rgba(0,0,0,0.45)'; - ov.innerHTML = '

'; - document.body.appendChild(ov); - } - const t = ov.querySelector('#pluginBusyText'); if (t) t.textContent = text || ''; - ov.style.display = 'flex'; -} -function hidePluginBusy() { const ov = document.getElementById('pluginBusy'); if (ov) ov.style.display = 'none'; } - -// Small inline spinner used for per-button loading (enable/start). -function pluginSpinner() { - return ''; -} -// Turn a button into a busy state: spinner + text, disabled. Reset happens on the -// next renderPlugins() (loadPlugins re-renders the whole list from fresh status). -function setPluginBtnBusy(btn, text) { - if (!btn) return; - btn.disabled = true; - btn.innerHTML = `${pluginSpinner()}${escapeHtml(text || '')}`; -} -// Dim the card and swap its status line to a "starting" spinner while the sidecar -// spins up, so the whole row reads as in-progress (not just the button). -function markPluginCardBusy(btn) { - const card = btn && btn.closest('.plugin'); - if (!card) return; - card.classList.add('opacity-60', 'pointer-events-none'); - const line = card.querySelector('[data-plugin-status]'); - if (line) line.innerHTML = `${pluginSpinner()}${escapeHtml(I18n.t('plugins.starting'))}`; -} - -// ---- plugin-declared actions: buttons/forms whose shape comes from the manifest ---- -async function runPluginDeclaredAction(btn) { - if (btn.disabled) return; - const pid = btn.dataset.pluginActionbtn; - const actionId = btn.dataset.actionId; - const kind = btn.dataset.actionKind || 'call'; - if (kind === 'link') { - const url = btn.dataset.actionUrl; - if (url) { try { api.openExternal(url); } catch (_) {} } - return; - } - const action = (pluginActionsById[pid] || []).find((a) => a && a.id === actionId) || { id: actionId }; - if (kind === 'form') { await openPluginActionForm(pid, action); return; } - // kind === 'call': fire-and-report, with an optional confirm gate - // (confirmDialog, not window.confirm — the Tauri webview never shows the latter) - if (action.confirm) { - const ok = await confirmDialog({ - title: action.label || action.id, - message: action.confirm, - confirmText: action.label || action.id, - }); - if (!ok) return; - } - btn.disabled = true; - try { - const r = await api.pluginAction(pid, actionId, {}); - showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok'); - } catch (err) { - showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err'); - } - btn.disabled = false; - await loadPlugins(); -} - -// Build one form control from a field spec. Recognized types: text (default), -// number, password, textarea, select, checkbox. -function pluginFieldControl(f, val) { - const key = escapeHtml(f.key); - const cls = 'bg-bg-input border border-border-custom rounded-[7px] px-2.75 py-2 text-fg text-[13px] w-full outline-none transition-colors duration-120 focus:border-primary'; - const v = (val != null ? val : (f.default != null ? f.default : '')); - if (f.type === 'select') { - const opts = (Array.isArray(f.options) ? f.options : []).map((o) => { - const ov = (o && typeof o === 'object') ? o.value : o; - const ol = (o && typeof o === 'object') ? (o.label != null ? o.label : o.value) : o; - const sel = String(ov) === String(v) ? ' selected' : ''; - return ``; - }).join(''); - return ``; - } - if (f.type === 'checkbox') { - const on = v === true || v === 1 || v === '1' || String(v).toLowerCase() === 'true'; - return ``; - } - if (f.type === 'textarea') { - return ``; - } - const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text'; - const mono = type === 'text' || type === 'number' ? ' font-mono' : ''; - const minmax = f.type === 'number' - ? `${f.min != null ? ` min="${escapeHtml(String(f.min))}"` : ''}${f.max != null ? ` max="${escapeHtml(String(f.max))}"` : ''}` - : ''; - return ``; -} - -// Read the form back into a values object, coercing/validating by field type. -// Returns null (and focuses the offending control) if a required/number check fails. -function collectPluginFormValues(root, fields) { - const out = {}; - for (const f of fields) { - const sel = (window.CSS && CSS.escape) ? CSS.escape(f.key) : f.key; - const el = root.querySelector(`[data-field-key="${sel}"]`); - if (!el) continue; - let v = f.type === 'checkbox' ? !!el.checked : el.value; - if (f.type === 'number') { - if (v === '' || v == null) { - v = null; - } else { - const n = Number(v); - if (Number.isNaN(n)) { el.focus(); return null; } - v = n; - } - } - if (f.required && f.type !== 'checkbox' && (v === '' || v == null)) { el.focus(); return null; } - out[f.key] = v; - } - return out; -} - -// Open a modal built from a plugin action's field specs; prefill from the plugin, -// then POST the collected values back through the host. -async function openPluginActionForm(pid, action) { - const fields = Array.isArray(action.fields) ? action.fields : []; - let values = {}; - if (action.loadOnOpen !== false) { - try { const r = await api.pluginActionLoad(pid, action.id); if (r && r.values) values = r.values; } - catch (err) { showToast(I18n.t('plugins.actionLoadFailed', { msg: (err && err.message) || err }), 'err'); } - } - const prev = document.getElementById('pluginActionModal'); if (prev) prev.remove(); - const ov = document.createElement('div'); - ov.id = 'pluginActionModal'; - ov.className = 'overlay fixed inset-0 bg-black/28 flex items-center justify-center z-[130] backdrop-blur-md'; - const rows = fields.map((f) => ` - `).join(''); - ov.innerHTML = ` -
-
-

${escapeHtml(action.label || action.id)}

-
-
- ${rows} -
- - -
-
-
`; - document.body.appendChild(ov); - const close = () => ov.remove(); - ov.addEventListener('click', (e) => { if (e.target === ov) close(); }); - ov.querySelector('[data-act="cancel"]').addEventListener('click', close); - ov.querySelector('[data-act="submit"]').addEventListener('click', async () => { - const vals = collectPluginFormValues(ov, fields); - if (vals == null) return; - const submitBtn = ov.querySelector('[data-act="submit"]'); - submitBtn.disabled = true; - try { - const r = await api.pluginAction(pid, action.id, vals); - showToast((r && r.message) || I18n.t('plugins.actionDone'), 'ok'); - close(); - await loadPlugins(); - } catch (err) { - submitBtn.disabled = false; - showToast(I18n.t('plugins.actionFailed', { msg: (err && err.message) || err }), 'err'); - } - }); -} - -function renderMonitor() { - $('mStatusText').textContent = status.connected ? I18n.t('status.connected') : status.running ? I18n.t('status.running') : I18n.t('status.disconnected'); - const dot = $('mStatus').querySelector('.pulse-dot, .live-dot'); - if (dot) { - const isLive = !!(status.connected || status.running); - dot.classList.toggle('on', isLive); - dot.classList.toggle('off', !isLive); - } - $('mEndpoint').textContent = `localhost:${(status.running && status.port) || config.port}`; - const ap = activeProvider(); - $('mActive').textContent = ap ? ap.name : '—'; - $('mActiveUrl').textContent = ap ? ap.baseUrl : I18n.t('monitor.noService'); - $('mTotal').textContent = stats.total; - $('mSuccess').textContent = stats.total ? I18n.t('monitor.successRate', { pct: Math.round((stats.ok / stats.total) * 100) }) : I18n.t('monitor.successRateNone'); - $('mAvg').innerHTML = stats.total ? `${Math.round(stats.sumMs / stats.total)} ms` : `— ms`; - $('mLast').textContent = stats.last ? I18n.t('monitor.recent', { time: stats.last }) : I18n.t('monitor.recentNone'); - renderGwLogStatus(); -} - -function renderAll() { renderStatus(); renderHero(); renderConnect(); renderProviders(); renderMonitor(); } - -/* ---------- in-app updates ---------- */ -let updateState = null; -let updateBusy = false; -function show(el, on) { if (el) el.classList.toggle('hidden', !on); } -function renderUpdate() { - const s = updateState; - const verEl = $('updVersion'), latEl = $('updLatest'), stEl = $('updStatus'), chip = $('updateChip'); - const actions = $('updActions'), bDl = $('btnUpdateDownload'), bApply = $('btnUpdateApply'), - bOpen = $('btnUpdateOpen'), bBrew = $('btnUpdateBrew'), notes = $('updNotes'); - if (!verEl) return; - if (s) { - verEl.textContent = s.runningVersion || s.shellVersion || '—'; - latEl.textContent = s.latestVersion || '—'; - } - // reset - [bDl, bApply, bOpen, bBrew].forEach((b) => show(b, false)); - show(actions, false); show(notes, false); show(chip, false); - if (chip) chip.classList.remove('text-green', 'text-amber'); - - if (!s) { stEl.textContent = I18n.t('about.idle'); return; } - const staged = s.pending && s.pending.staged; - if (staged) { - stEl.textContent = I18n.t('about.stagedReady', { v: s.pending.version }); - chip.textContent = I18n.t('about.ready'); chip.classList.add('text-green'); show(chip, true); - show(actions, true); show(bApply, true); - return; - } - if (s.ok === false) { stEl.textContent = I18n.t('about.checkFailed', { msg: s.error || '' }); return; } - if (!s.latestVersion || s.mode === 'unknown') { stEl.textContent = I18n.t('about.idle'); return; } - if (s.mode === 'none') { stEl.textContent = I18n.t('about.upToDate'); chip.textContent = I18n.t('about.upToDateChip'); chip.classList.add('text-green'); show(chip, true); return; } - - // an update is available - chip.textContent = I18n.t('about.availableChip'); chip.classList.add('text-amber'); show(chip, true); - show(actions, true); - if (s.notes) { notes.textContent = s.notes; show(notes, true); } - if (s.mode === 'hot') { - stEl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.hotAvailable', { v: s.latestVersion }); - show(bDl, true); bDl.disabled = updateBusy; bDl.textContent = updateBusy ? I18n.t('about.downloading') : I18n.t('about.downloadInstall'); - } else { // full - stEl.textContent = I18n.t('about.fullAvailable', { v: s.latestVersion }); - show(bOpen, true); - if (s.installMethod === 'mac' || s.installMethod === 'linux') { bBrew.textContent = s.brewCommand || 'brew upgrade --cask ccbud'; show(bBrew, true); } - } -} -async function loadUpdateState() { - try { updateState = await api.updateState(); } catch (_) {} - syncAutoToggles(); - renderUpdate(); -} -async function checkUpdate() { - const btn = $('btnUpdateCheck'); - if (btn) { btn.disabled = true; } - $('updStatus').textContent = I18n.t('about.checking'); - try { updateState = await api.updateCheck(); } catch (e) { updateState = { ok: false, error: (e && e.message) || '' }; } - if (btn) btn.disabled = false; - renderUpdate(); -} -async function downloadUpdate() { - updateBusy = true; renderUpdate(); - let res; - try { res = await api.updateDownload(); } catch (e) { res = { ok: false, error: (e && e.message) || '' }; } - updateBusy = false; - try { updateState = await api.updateState(); } catch (_) {} - if (res && !res.ok && updateState) updateState.error = res.error; - renderUpdate(); -} -function syncAutoToggles() { - const au = (config && config.autoUpdate) || {}; - const c = $('fAutoCheck'), d = $('fAutoDownload'); - if (c) c.checked = au.check !== false; - if (d) d.checked = au.autoDownload !== false; -} - -/* ---------- monitor stream ---------- */ -function pushStreamRow(r) { - stats.total++; - if (r.status >= 200 && r.status < 400) stats.ok++; - stats.sumMs += r.ms || 0; - stats.last = fmtTime(); - renderMonitor(); - $('streamHint').textContent = I18n.t('monitor.forwarded', { n: stats.total }); - const list = $('streamList'); - const empty = list.querySelector('.state-inline, .empty'); - if (empty) empty.remove(); - const okCls = r.status >= 200 && r.status < 400 ? 'ok' : 'err'; - const row = document.createElement('div'); - row.className = 'stream-row flex items-center gap-2.5 py-2.25 px-3.5 border-b border-border-custom text-[11.5px] transition-colors duration-100 hover:bg-chip-bg last:border-b-0 [&.clickable]:cursor-pointer'; - if (r.id != null) { row.dataset.id = r.id; row.classList.add('clickable'); row.title = I18n.t('monitor.rowTitle'); } - const agentTag = r.agentId ? `sub` : ''; - row.innerHTML = ` - - ${escapeHtml(r.method || '')} - ${agentTag} - - ${escapeHtml(r.requestedModel || '-')} - - ${escapeHtml(r.outgoingModel || '-')} - ${r.rewritten ? `` : ''} - - ${escapeHtml(r.provider || '')} - ${r.status} - ${r.ms}ms - ${fmtTime()}`; - list.insertBefore(row, list.firstChild); - // Live window only — keep the last 100 rows (matches the backend's exchange-detail buffer). - while (list.children.length > 100) list.removeChild(list.lastChild); - scheduleHeroUsage(); -} -/* ---------- gateway log (lifecycle + error events; backfilled from main's ring buffer) ---------- */ -const gwLog = { seen: new Set(), items: [] }; - -// Add an entry to the local model. Live/replayed entries carry a `seq` (deduped); local renderer -// notices (provider test, save error, …) have none and are always appended. -function addGatewayLog(l) { - if (!l) return false; - if (l.seq != null) { - if (gwLog.seen.has(l.seq)) return false; - gwLog.seen.add(l.seq); - } - if (l.ts == null) l.ts = Date.now(); - gwLog.items.push(l); - while (gwLog.items.length > 100) gwLog.items.shift(); - return true; -} - -function renderGwLogStatus() { - const el = $('gwLogStatus'); - if (!el) return; - const running = !!(status.connected || status.running); - const port = (status.running && status.port) || config.port; - el.className = 'raw-log-badge ml-auto ' + (running ? 'on' : 'off'); - el.innerHTML = `${escapeHtml(I18n.t(running ? 'monitor.gwRunning' : 'monitor.gwStopped'))} · localhost:${escapeHtml(String(port))}`; -} - -function renderGatewayLog() { - const el = $('rawLog'); - if (!el) return; - if (!gwLog.items.length) { - el.innerHTML = `
${escapeHtml(I18n.t('monitor.logEmpty'))}
`; - return; - } - const rows = gwLog.items.slice().sort((a, b) => (a.ts || 0) - (b.ts || 0)).reverse(); - el.innerHTML = rows.map((l) => { - const lv = String(l.level || 'info'); - const t = fmtTime(l.ts); - return `
${escapeHtml(lv)}${escapeHtml(l.msg || '')}${escapeHtml(t)}
`; - }).join(''); -} - -function pushRawLog(l) { if (addGatewayLog(l)) renderGatewayLog(); } - -// Backfill from main's ring buffer (events fire once and aren't otherwise replayed) + refresh banner. -async function refreshGatewayLog() { - if (api.logsGet) { - try { (await api.logsGet() || []).forEach(addGatewayLog); } catch (_) {} - } - renderGwLogStatus(); - renderGatewayLog(); -} - -/* ---------- request inspector (full headers + body of one forwarded exchange) ---------- */ -let reqDrawerTab = 'req'; -let reqDrawerData = null; - -function fmtBytes(n) { n = n || 0; if (n < 1024) return n + ' B'; if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'; return (n / 1048576).toFixed(2) + ' MB'; } -function prettyText(cap) { - if (!cap || !cap.text) return { text: '', lang: 'plaintext' }; - let text = cap.text, lang = 'plaintext'; - const trimmed = text.trim(); - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { text = JSON.stringify(JSON.parse(trimmed), null, 2); lang = 'json'; } catch (_) {} - } - return { text, lang }; -} -function prettyBody(cap) { - if (!cap || !cap.text) return `
${escapeHtml(I18n.t('drawer.empty'))}
`; - const { text, lang } = prettyText(cap); - const note = cap.truncated ? `
${escapeHtml(I18n.t('drawer.truncated', { shown: fmtBytes(cap.bytes - cap.truncated), total: fmtBytes(cap.bytes) }))}
` : ''; - return note + `
${escapeHtml(text)}
`; -} -// In-body find bar (request/response body can be 100KB+) — highlight + navigate matches. -const DR_MARK_CAP = 800; -let drBodyText = '', drBodyHTML = '', drMatches = [], drMatchIdx = -1; -function drCodeEl() { const b = $('reqDrawerBody'); return b ? b.querySelector('.dr-pre code') : null; } -function updateDrCount() { - const c = $('reqDrawerBody') && $('reqDrawerBody').querySelector('.dr-search-count'); - if (!c) return; - const shown = Math.min(drMatches.length, DR_MARK_CAP); - c.textContent = drMatches.length ? `${drMatchIdx + 1}/${shown}${drMatches.length > DR_MARK_CAP ? '+' : ''}` : '0/0'; -} -function applyDrSearch(q) { - const code = drCodeEl(); - if (!code) return; - q = q || ''; - if (!q) { code.innerHTML = drBodyHTML; drMatches = []; drMatchIdx = -1; updateDrCount(); return; } - const hay = drBodyText.toLowerCase(), needle = q.toLowerCase(); - drMatches = []; - for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length)) drMatches.push(i); - if (!drMatches.length) { code.innerHTML = escapeHtml(drBodyText); drMatchIdx = -1; updateDrCount(); return; } - const n = Math.min(drMatches.length, DR_MARK_CAP); - let html = '', last = 0; - for (let k = 0; k < n; k++) { - const pos = drMatches[k]; - html += escapeHtml(drBodyText.slice(last, pos)) + '' + escapeHtml(drBodyText.slice(pos, pos + q.length)) + ''; - last = pos + q.length; - } - html += escapeHtml(drBodyText.slice(last)); - code.innerHTML = html; - drMatchIdx = 0; drHighlightCurrent(); updateDrCount(); -} -function drHighlightCurrent() { - const code = drCodeEl(); - if (!code) return; - const marks = code.querySelectorAll('.dr-mark'); - marks.forEach((m, i) => m.classList.toggle('cur', i === drMatchIdx)); - if (marks[drMatchIdx]) marks[drMatchIdx].scrollIntoView({ block: 'center' }); -} -function drNavSearch(dir) { - const n = Math.min(drMatches.length, DR_MARK_CAP); - if (!n) return; - drMatchIdx = (drMatchIdx + dir + n) % n; - drHighlightCurrent(); updateDrCount(); -} -function kvTable(h) { - const keys = Object.keys(h || {}); - if (!keys.length) return `
${escapeHtml(I18n.t('drawer.none'))}
`; - return '
' + keys.map((k) => `
${escapeHtml(k)}${escapeHtml(Array.isArray(h[k]) ? h[k].join(', ') : h[k])}
`).join('') + '
'; -} -// A translated exchange (client wire ≠ provider wire) exposes all four sides; passthrough keeps -// the classic two. Each tab resolves to { headers, cap, isReq, sub } for the shared body renderer: -// creq — what the gateway RECEIVED from the client (inbound URL/headers/original body) -// req — what the gateway SENT upstream (real upstream URL/headers/translated body) -// ures — what the upstream RETURNED (raw, pre-translation) -// res — what the gateway RETURNED to the client (translated) -function drawerTabView(d, tab) { - const creq = d.clientReq || {}; - const ures = d.upstreamRes || {}; - switch (tab) { - case 'creq': - return { headers: creq.headers, cap: creq.body || d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${creq.url || d.path || ''}` }; - case 'ures': - return { headers: ures.headers, cap: ures.body, isReq: false, sub: `HTTP ${ures.status != null ? ures.status : d.status || ''}` }; - case 'res': - return { headers: d.resHeaders, cap: d.resBody, isReq: false, sub: `HTTP ${d.status || ''}` }; - default: // 'req' - return { headers: d.reqHeaders, cap: d.reqBody, isReq: true, sub: `${d.method || 'POST'} ${d.url || d.path || ''}` }; - } -} -function drawerTabs(d) { - return d && d.translated - ? [['creq', I18n.t('drawer.tabClientReq')], ['req', I18n.t('drawer.tabUpstreamReq')], - ['ures', I18n.t('drawer.tabUpstreamRes')], ['res', I18n.t('drawer.tabClientRes')]] - : [['req', I18n.t('drawer.req')], ['res', I18n.t('drawer.res')]]; -} -const DR_TAB_CLS = 'dr-tab border-none bg-transparent text-muted font-semibold text-[13px] leading-none p-[8px_14px] rounded-t-md cursor-pointer border-b-2 border-transparent -mb-[1px] hover:text-fg [&.active]:text-brand [&.active]:border-b-brand'; -function renderDrawerTabs() { - const wrap = $('drTabs'); - if (!wrap) return; - wrap.innerHTML = drawerTabs(reqDrawerData) - .map(([k, label]) => ``) - .join(''); -} -function renderReqDrawerBody() { - const d = reqDrawerData; - if (!d) return; - const body = $('reqDrawerBody'); - const view = drawerTabView(d, reqDrawerTab); - const isReq = view.isReq; - const headers = view.headers; - const cap = view.cap; - const which = reqDrawerTab; - const copyLabel = cap && cap.truncated ? I18n.t('drawer.copyPartial') : I18n.t('drawer.copy'); - const headTitle = `${escapeHtml(I18n.t(isReq ? 'drawer.reqHeaders' : 'drawer.resHeaders'))} ${escapeHtml(view.sub)}`; - drBodyText = prettyText(cap).text; - drMatches = []; drMatchIdx = -1; - const searchBar = drBodyText ? `
- - 0/0 - - -
` : ''; - const copyCls = drBodyText ? '' : 'ml-auto '; - body.innerHTML = `
${headTitle}
${kvTable(headers)}
${escapeHtml(isReq ? I18n.t('drawer.reqBody') : I18n.t('drawer.resBody'))}${searchBar}
${prettyBody(cap)}`; - // Skip syntax highlighting on very large bodies — hljs on multi-MB text freezes the UI. - body.querySelectorAll('pre code').forEach((b) => { if (b.textContent.length > 100000) return; try { if (window.hljs) window.hljs.highlightElement(b); } catch (_) {} }); - const codeEl = drCodeEl(); - drBodyHTML = codeEl ? codeEl.innerHTML : ''; -} -async function openReqDetail(id) { - let d = null; - try { d = await api.monitorGet(id); } catch (_) {} - if (!d) { - // Entry rolled out of the bounded capture buffer — give feedback instead of a stale drawer. - reqDrawerData = null; - $('drMethod').textContent = '—'; - const drStatus = $('drStatus'); - if (drStatus) { - drStatus.textContent = ''; - drStatus.classList.remove('ok', 'err'); - } - $('drModel').textContent = ''; - $('reqMeta').innerHTML = ''; - $('reqDrawerBody').innerHTML = `
${escapeHtml(I18n.t('drawer.expired'))}
`; - $('reqDrawer').classList.remove('hidden'); - return; - } - // Translated exchanges open on the client request (what the gateway received) so the - // before/after of the translation reads left-to-right across the tabs. - reqDrawerData = d; reqDrawerTab = d.translated ? 'creq' : 'req'; - const ok = d.status >= 200 && d.status < 400; - $('drMethod').textContent = d.method || 'POST'; - const drStatus = $('drStatus'); - if (drStatus) { - drStatus.textContent = d.status != null ? d.status : '—'; - drStatus.classList.toggle('ok', ok); - drStatus.classList.toggle('err', !ok); - } - $('drModel').innerHTML = `${escapeHtml(d.requestedModel || '-')} ${escapeHtml(d.outgoingModel || '-')}${d.rewritten ? ` ` : ''}`; - const meta = [ - [I18n.t('drawer.service'), d.provider], - d.translated ? [I18n.t('drawer.translated'), d.translated] : null, - d.aborted ? [I18n.t('drawer.aborted'), I18n.t('drawer.abortedVal')] : null, - [I18n.t('drawer.latency'), d.ms != null ? d.ms + ' ms' : ''], - [I18n.t('drawer.session'), d.sessionId ? String(d.sessionId).slice(0, 8) : ''], - d.agentId ? [I18n.t('drawer.agent'), I18n.t('drawer.subagent')] : null, - [I18n.t('drawer.time'), d.ts ? fmtTime(d.ts) : ''], - d.error ? [I18n.t('drawer.error'), d.error] : null, - ].filter((r) => r && r[1]); - $('reqMeta').innerHTML = meta.map((r) => `${escapeHtml(r[0])} ${escapeHtml(r[1])}`).join(''); - renderDrawerTabs(); - renderReqDrawerBody(); - $('reqDrawer').classList.remove('hidden'); -} -function closeReqDrawer() { const d = $('reqDrawer'); if (d) d.classList.add('hidden'); reqDrawerData = null; } - -/* ---------- modal ---------- */ -function renderPresetGrid() { - const grid = $('presetGrid'); - grid.innerHTML = ''; - Object.keys(PRESET_LABELS).forEach((key) => { - const b = document.createElement('button'); - b.type = 'button'; b.className = 'preset-chip bg-bg-input border border-border-custom rounded-full px-3 py-[4.5px] text-[12px] font-medium text-fg cursor-pointer transition-all duration-140 hover:border-brand hover:text-brand active:scale-[0.97]'; b.dataset.preset = key; b.textContent = key === 'custom' ? I18n.t('preset.custom') : PRESET_LABELS[key]; - grid.appendChild(b); - }); -} -function selectPreset(key) { - document.querySelectorAll('.preset-chip').forEach((c) => c.classList.toggle('selected', c.dataset.preset === key)); - const p = PRESETS[key] || PRESETS.custom; - $('fName').value = p.name; $('fBaseUrl').value = p.baseUrl; $('fDefaultModel').value = p.defaultModel; $('fSmallModel').value = p.smallFastModel; - setProtocol(p.protocol || 'anthropic'); // preset declares its wire protocol up front - modalIcon = null; // a preset uses its brand logo - updateIconPreview(); - if (key !== 'custom') $('fToken').focus(); -} -// Segmented protocol control: get/set the selected wire protocol. -function getProtocol() { - const g = $('fProtocol'); if (!g) return 'anthropic'; - const b = g.querySelector('.proto-seg-btn.selected'); - return (b && b.dataset.proto) || 'anthropic'; -} -function setProtocol(v) { - const g = $('fProtocol'); if (!g) return; - v = v || 'anthropic'; - g.querySelectorAll('.proto-seg-btn').forEach((b) => b.classList.toggle('selected', b.dataset.proto === v)); - syncProtocolHint(); -} -// Reflect the chosen protocol as a prominent status line so the user always knows whether their -// requests pass through directly (Anthropic) or get auto-translated (OpenAI Chat / Responses). -function syncProtocolHint() { - const badge = $('protoBadge'); - if (!badge) return; - const v = getProtocol(); - const map = { - 'anthropic': { k: 'modal.protoBadgeDirect', cls: 'proto-badge-direct' }, - 'openai-chat': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' }, - 'openai-responses': { k: 'modal.protoBadgeXlate', cls: 'proto-badge-xlate' }, - }; - const m = map[v] || map['anthropic']; - badge.className = 'proto-badge ' + m.cls; - badge.textContent = I18n.t(m.k); -} -function updateIconPreview() { - const el = $('fIconPreview'); - const iconData = renderProviderIcon($('fName').value || '?', modalIcon); - el.setAttribute('style', iconData.style); - el.innerHTML = iconData.html; -} -function resizeImage(file, size) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const img = new Image(); - img.onload = () => { - try { - const c = document.createElement('canvas'); c.width = size; c.height = size; - const ctx = c.getContext('2d'); - const s = Math.min(img.width, img.height); - ctx.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, size, size); - resolve(c.toDataURL('image/png')); - } catch (e) { reject(e); } - }; - img.onerror = reject; - img.src = reader.result; - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} -function openIconPicker(anchor) { - const existing = document.querySelector('.icon-picker'); - if (existing) { existing.remove(); return; } - const pop = document.createElement('div'); - pop.className = 'icon-picker'; - pop.innerHTML = - `
${ICON_EMOJIS.map((e) => ``).join('')}
` + - `
` + - ``; - document.body.appendChild(pop); - const r = anchor.getBoundingClientRect(); - let x = Math.max(10, Math.min(Math.round(r.left + r.width / 2 - pop.offsetWidth / 2), window.innerWidth - pop.offsetWidth - 10)); - let y = Math.round(r.bottom + 8); - if (y + pop.offsetHeight > window.innerHeight - 10) y = Math.max(10, Math.round(r.top - pop.offsetHeight - 8)); - pop.style.left = x + 'px'; pop.style.top = y + 'px'; - const close = () => { pop.remove(); document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; - const onDoc = (e) => { if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); }; - const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; - setTimeout(() => { document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); }, 0); - pop.addEventListener('click', (e) => { - const em = e.target.closest('.ip-emoji'); - if (em) { modalIcon = em.dataset.emoji; updateIconPreview(); close(); return; } - const act = e.target.closest('.ip-act'); - if (!act) return; - if (act.dataset.act === 'random') { modalIcon = ICON_EMOJIS[Math.floor(Math.random() * ICON_EMOJIS.length)]; updateIconPreview(); close(); } - else if (act.dataset.act === 'reset') { modalIcon = null; updateIconPreview(); close(); } - else if (act.dataset.act === 'upload') { pop.querySelector('.ip-file').click(); } - }); - pop.querySelector('.ip-file').addEventListener('change', (e) => { - const f = e.target.files && e.target.files[0]; - if (!f) return; - resizeImage(f, 72).then((d) => { modalIcon = d; updateIconPreview(); close(); }).catch(() => close()); - }); -} -function addMapRow(alias = '', upstream = '') { - const row = document.createElement('div'); - row.className = 'map-row flex items-center gap-1.75'; - const mapInputCls = 'flex-1 min-w-0 bg-bg-input border border-border-custom rounded-md px-2 py-1.5 text-fg font-mono text-[12px] outline-none transition-colors duration-120 focus:border-primary'; - row.innerHTML = ` - - - - `; - row.querySelector('.m-alias').value = alias; - row.querySelector('.m-upstream').value = upstream; - row.querySelector('.m-del').addEventListener('click', () => row.remove()); - $('mapRows').appendChild(row); -} -function openModal(provider) { - editingId = provider ? provider.id : null; - modalIcon = provider ? (provider.icon || null) : null; - $('modalTitle').textContent = provider ? I18n.t('modal.editTitle') : I18n.t('modal.addTitle'); - document.querySelectorAll('.preset-chip').forEach((c) => c.classList.remove('selected')); - $('fName').value = provider ? provider.name : ''; - $('fBaseUrl').value = provider ? provider.baseUrl : ''; - $('fToken').value = provider ? provider.authToken : ''; - $('fToken').type = 'password'; $('fTokenToggle').textContent = I18n.t('modal.show'); - $('fDefaultModel').value = provider ? provider.defaultModel : ''; - $('fSmallModel').value = provider ? provider.smallFastModel : ''; - $('fMapDefault').checked = provider ? provider.mapDefaultModels !== false : true; - setProtocol((provider && provider.protocol) || 'anthropic'); - $('mapRows').innerHTML = ''; - if (provider && provider.models) provider.models.forEach((m) => addMapRow(m.alias, m.upstream)); - if (!$('mapRows').children.length) addMapRow(); // always show one empty row to add into - const mapDetails = $('mapRows').closest('details'); - if (mapDetails) mapDetails.open = true; - updateIconPreview(); - $('modal').classList.remove('hidden'); - $('fName').focus(); -} -function closeModal() { $('modal').classList.add('hidden'); editingId = null; } -function collectProvider() { - const models = []; - $('mapRows').querySelectorAll('.map-row').forEach((row) => { - const alias = row.querySelector('.m-alias').value.trim(); - const upstream = row.querySelector('.m-upstream').value.trim(); - if (alias || upstream) models.push({ alias, upstream }); - }); - const p = { - name: $('fName').value.trim() || I18n.t('providers.unnamed'), - baseUrl: $('fBaseUrl').value.trim(), - authToken: $('fToken').value.trim(), - defaultModel: $('fDefaultModel').value.trim(), - smallFastModel: $('fSmallModel').value.trim(), - mapDefaultModels: $('fMapDefault').checked, - protocol: getProtocol(), - models, - }; - if (modalIcon) p.icon = modalIcon; - if (editingId) p.id = editingId; - return p; -} - -/* ---------- actions ---------- */ -async function refresh() { - config = await api.getConfig(); - status = await api.serverStatus(); - // Reconcile the boot language (from localStorage) with the persisted config truth. - try { - if (config.language && config.language !== I18n.lang) { I18n.setLang(config.language); I18n.apply(document); } - else localStorage.setItem('ccbud-lang', I18n.lang); - } catch (_) {} - renderAll(); - refreshGatewayLog(); -} -async function persist(patch) { - try { config = await api.saveConfig(Object.assign({}, config, patch)); } - catch (e) { pushRawLog({ level: 'error', msg: I18n.t('err.saveFailed', { msg: (e && e.message ? e.message : e) }) }); } - status = await api.serverStatus(); - renderAll(); -} -// The hero button is the gateway SERVICE switch (start/stop). CLI config wiring lives in -// Settings → connect targets. -async function toggleConnect() { - const btn = $('btnConnect'); - const on = !status.running; - btn.disabled = true; - let res; - try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; } - btn.disabled = false; - config = await api.getConfig(); - status = await api.serverStatus(); - renderAll(); - if (res && res.ok === false) { - showHeroNote(res.message || I18n.t('err.opFailed'), true); - } -} -function copyFeedback(btn, text) { - const orig = btn.dataset.copyOrig || (btn.dataset.copyOrig = btn.textContent); - api.copy(text); - btn.textContent = I18n.t('copy.copiedCheck'); - clearTimeout(btn._t); - btn._t = setTimeout(() => (btn.textContent = orig), 1500); -} -// Lightweight styled confirm dialog → Promise. For actions that are easy to mis-trigger. -function confirmDialog({ title, message, confirmText, cancelText, danger }) { - return new Promise((resolve) => { - const ov = document.createElement('div'); - ov.className = 'overlay fixed inset-0 bg-black/35 flex items-center justify-center z-[200] backdrop-blur-md'; - ov.innerHTML = `
-

${escapeHtml(title || '')}

-

${escapeHtml(message || '')}

-
- - -
-
`; - document.body.appendChild(ov); - const done = (v) => { document.removeEventListener('keydown', onKey); ov.remove(); resolve(v); }; - const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } else if (e.key === 'Enter') { e.preventDefault(); done(true); } }; - ov.querySelector('.cd-ok').addEventListener('click', () => done(true)); - ov.querySelector('.cd-cancel').addEventListener('click', () => done(false)); - ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); }); - document.addEventListener('keydown', onKey); - setTimeout(() => { const b = ov.querySelector('.cd-ok'); if (b) b.focus(); }, 0); - }); -} -function genToken() { - const a = new Uint8Array(18); - crypto.getRandomValues(a); - return 'ccbud_' + Array.from(a).map((b) => b.toString(16).padStart(2, '0')).join(''); -} -function switchView(view) { - document.querySelectorAll('#tabs .nav-item, #tabs .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); - - // Smooth fade between views - const viewIds = { - providers: 'view-providers', - plugins: 'view-plugins', - monitor: 'view-monitor', - conversations: 'view-conversations', - settings: 'view-settings', - }; - const views = Object.values(viewIds).map((id) => $(id)); - - const current = views.find(el => el && !el.classList.contains('hidden')); - const targetId = viewIds[view] || 'view-providers'; - const target = $(targetId); - - const doSwitch = () => { - views.forEach(el => { - if (!el) return; - const isTarget = el === target; - el.classList.toggle('hidden', !isTarget); - if (!isTarget) { - el.style.transition = ''; - el.style.opacity = ''; - } - }); - $('btnAdd').classList.toggle('hidden', view !== 'providers'); - const emptyAdd = $('btnAddEmpty'); - if (emptyAdd) emptyAdd.classList.toggle('hidden', view !== 'providers'); - - if (target) { - target.style.transition = 'none'; - target.style.opacity = '0'; - // Restart the fade on the next frame instead of `void target.offsetWidth` — that read forced a - // synchronous full-document layout on every view switch (costly on the heavy 对话 view; traced). - requestAnimationFrame(() => { - target.style.transition = 'opacity 0.22s cubic-bezier(0.23, 1, 0.32, 1)'; - target.style.opacity = '1'; - setTimeout(() => { if (target) target.style.transition = ''; }, 280); - }); - } - - if (view === 'conversations' && window.ccbudConversations) window.ccbudConversations.onShow(); - if (view === 'plugins') loadPlugins(); - if (view === 'monitor') refreshGatewayLog(); - // Lock the window to a fixed, non-resizable size on Settings; restore it elsewhere. - if (api.setSettingsMode) api.setSettingsMode(view === 'settings'); - // 对话 needs the wide 3-column layout (min 1300); other views can be narrower (900) so a wide - // window doesn't leave big side gaps. Switching to 对话 auto-grows the window to ≥1300. - if (api.setViewMinWidth) api.setViewMinWidth(view === 'conversations' ? 1300 : 900); - }; - - if (current && current !== target) { - current.style.transition = 'opacity 0.12s ease'; - current.style.opacity = '0'; - setTimeout(() => { - current.style.transition = ''; - current.style.opacity = ''; - doSwitch(); - }, 110); - } else { - doSwitch(); - } -} -function applyTheme(t) { - document.documentElement.setAttribute('data-theme', t); - try { localStorage.setItem('ccbud-theme', t); } catch (_) {} - const dark = t === 'dark'; - const hd = document.getElementById('hljs-dark'); - const hl = document.getElementById('hljs-light'); - // Media-attribute swap instead of .disabled: both sheets stay in document.styleSheets - // (so Clarity's desktop mode can inline them) and each flip is an attribute mutation - // the recording captures, keeping highlight colors faithful in replay. - if (hd) hd.media = dark ? 'all' : 'not all'; - if (hl) hl.media = dark ? 'not all' : 'all'; - // Theme-toggle icon reflects the current mode: sun in light, moon in dark. - const tbIcon = document.querySelector('#btnTheme [data-icon]'); - if (tbIcon) { const nm = dark ? 'moon' : 'theme'; tbIcon.dataset.icon = nm; if (I[nm]) tbIcon.innerHTML = I[nm]; } -} - -/* ---------- drag reorder ---------- */ -function wireDrag() { - const list = $('providerList'); - list.addEventListener('dragstart', (e) => { - const card = e.target.closest('.provider'); if (!card) return; - dragId = card.dataset.id; card.classList.add('dragging'); - }); - list.addEventListener('dragend', (e) => { - const card = e.target.closest('.provider'); if (card) card.classList.remove('dragging'); - document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over')); - }); - list.addEventListener('dragover', (e) => { - e.preventDefault(); - const card = e.target.closest('.provider'); - document.querySelectorAll('.provider.drag-over').forEach((c) => c.classList.remove('drag-over')); - if (card && card.dataset.id !== dragId) card.classList.add('drag-over'); - }); - list.addEventListener('drop', async (e) => { - e.preventDefault(); - const card = e.target.closest('.provider'); - if (!card || !dragId || card.dataset.id === dragId) return; - const ids = config.providers.map((p) => p.id); - const from = ids.indexOf(dragId), to = ids.indexOf(card.dataset.id); - if (from < 0 || to < 0) return; - const reordered = config.providers.slice(); - const [moved] = reordered.splice(from, 1); - reordered.splice(to, 0, moved); - await persist({ providers: reordered }); - }); -} - -/* ---------- wire up ---------- */ -// Settings sub-nav: keep the main panel focused on one section at a time. -function switchSettings(pane) { - const nav = $('settingsNav'); - if (nav) nav.querySelectorAll('.settings-subnav-item').forEach((b) => b.classList.toggle('active', b.dataset.settings === pane)); - const panes = $('settingsPanes'); - if (panes) panes.querySelectorAll('[data-pane]').forEach((p) => p.classList.toggle('hidden', p.dataset.pane !== pane)); - // Refresh the live cards the moment their section is revealed. - if (pane === 'about') loadUpdateState(); -} - -function bind() { - if ($('appLogo') && I.logo) $('appLogo').innerHTML = I.logo(30); - injectIcons(); - - $('tabs').addEventListener('click', (e) => { - const btn = e.target.closest('.nav-item, .seg-btn'); - if (btn && btn.dataset.view) switchView(btn.dataset.view); - }); - const settingsNav = $('settingsNav'); - if (settingsNav) settingsNav.addEventListener('click', (e) => { - const b = e.target.closest('.settings-subnav-item'); - if (b && b.dataset.settings) switchSettings(b.dataset.settings); - }); - // Settings sub-nav collapse (icons-only, auto-shrinks width) — persisted like the main sidebar. - const subnavBtn = $('btnSubnavCollapse'); - if (settingsNav && subnavBtn) { - try { - if (localStorage.getItem('ccbud-subnav-collapsed') === '1') { - settingsNav.classList.add('collapsed'); - const ic = subnavBtn.querySelector('[data-icon]'); - if (ic && I.chevronRight) ic.innerHTML = I.chevronRight; - } - } catch (_) {} - subnavBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const collapsed = settingsNav.classList.toggle('collapsed'); - const ic = subnavBtn.querySelector('[data-icon]'); - if (ic) ic.innerHTML = collapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹'); - try { localStorage.setItem('ccbud-subnav-collapsed', collapsed ? '1' : '0'); } catch (_) {} - }); - } - $('btnTheme').addEventListener('click', () => { - const cur = document.documentElement.getAttribute('data-theme') || 'light'; - applyTheme(cur === 'light' ? 'dark' : 'light'); - }); - - // Main sidebar collapse (affects all views) - const sidebar = document.querySelector('.sidebar'); - const collapseBtn = $('btnCollapseSidebar'); - if (collapseBtn && sidebar) { - // restore - try { - if (localStorage.getItem('ccbud-sidebar-collapsed') === '1') { - sidebar.classList.add('collapsed'); - const icon = collapseBtn.querySelector('[data-icon]'); - if (icon && I.chevronRight) icon.innerHTML = I.chevronRight; - } - } catch (_) {} - collapseBtn.addEventListener('click', () => { - const isCollapsed = sidebar.classList.toggle('collapsed'); - const icon = collapseBtn.querySelector('[data-icon]'); - if (icon) icon.innerHTML = isCollapsed ? (I.chevronRight || '›') : (I.chevronLeft || '‹'); - try { localStorage.setItem('ccbud-sidebar-collapsed', isCollapsed ? '1' : '0'); } catch (_) {} - }); - } - $('btnConnect').addEventListener('click', toggleConnect); - const heroRanges = $('heroRanges'); - if (heroRanges) heroRanges.addEventListener('click', (e) => { - const b = e.target.closest('[data-hrange]'); - if (!b) return; - heroRange = b.dataset.hrange; - heroRanges.querySelectorAll('.seg-btn').forEach((x) => x.classList.toggle('active', x === b)); - renderHeroUsage(); - }); - const heroEndpoint = $('heroEndpoint'); - if (heroEndpoint) heroEndpoint.addEventListener('click', () => { - const port = (status.running && status.port) || config.port; - if (api.copy) api.copy(`http://localhost:${port}`); - const t = $('heroEndpointText'); - if (t) { const restore = `localhost:${port}`; t.textContent = I18n.t('copy.copiedCheck'); clearTimeout(t._t); t._t = setTimeout(() => { t.textContent = restore; }, 1400); } - }); - - $('portInput').addEventListener('change', async (e) => { - const port = Number(e.target.value); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - e.target.value = config.port; - pushRawLog({ level: 'error', msg: I18n.t('err.portInvalid') }); - return; - } - await persist({ port }); - }); - $('btnCopyExport').addEventListener('click', (e) => copyFeedback(e.currentTarget, $('exportBlock').textContent)); - document.querySelectorAll('[data-copy]').forEach((b) => b.addEventListener('click', () => copyFeedback(b, $(b.getAttribute('data-copy')).textContent))); - - $('fOpenAtLogin').addEventListener('change', (e) => persist({ openAtLogin: e.target.checked })); - $('fRequireToken').addEventListener('change', (e) => { - const requireToken = e.target.checked; - const patch = { requireToken }; - if (requireToken && !config.gatewayToken) patch.gatewayToken = genToken(); - persist(patch); - }); - $('fGatewayToken').addEventListener('change', (e) => persist({ gatewayToken: e.target.value.trim() })); - $('btnGenToken').addEventListener('click', () => persist({ gatewayToken: genToken(), requireToken: true })); - if ($('fTargetClaude')) $('fTargetClaude').addEventListener('change', (e) => toggleTarget('claude', e.target.checked)); - if ($('fTargetCodex')) $('fTargetCodex').addEventListener('change', (e) => toggleTarget('codex', e.target.checked)); - if ($('fGatewayEnabled')) $('fGatewayEnabled').addEventListener('change', async (e) => { - const on = e.target.checked; - let res; - try { res = await api.gatewaySetEnabled(on); } catch (_) { res = null; } - if (res && res.ok === false) { - e.target.checked = !on; // couldn't bind the port → revert + surface - try { showHeroNote(res.message || I18n.t('err.opFailed'), true); } catch (_) {} - } - config = await api.getConfig(); - status = await api.serverStatus(); - renderAll(); - }); - if ($('fRetry429')) $('fRetry429').addEventListener('change', (e) => persist({ retry429: Object.assign({}, config.retry429, { enabled: e.target.checked }) })); - if ($('fInsecureTls')) $('fInsecureTls').addEventListener('change', (e) => persist({ insecureSkipVerify: e.target.checked })); - $('fTrayUsage').addEventListener('change', (e) => persist({ trayUsage: { enabled: e.target.checked, range: $('fTrayRange').value } })); - $('fTrayRange').addEventListener('change', (e) => persist({ trayUsage: { enabled: $('fTrayUsage').checked, range: e.target.value } })); - if ($('fLang')) $('fLang').addEventListener('change', async (e) => { - const language = e.target.value; - I18n.setLang(language); // updates + localStorage['ccbud-lang'] - I18n.apply(document); // static data-i18n nodes - renderAll(); // dynamic strings (hero/status/monitor/providers/settings) - if (window.ccbudConversations && window.ccbudConversations.setLang) window.ccbudConversations.setLang(); - await persist({ language }); // → config:save → main rebuilds tray on next open - }); - - // History directories — primary action opens a native picker (hidden dirs shown) - const btnPickHistDir = $('btnPickHistDir'); - if (btnPickHistDir) btnPickHistDir.addEventListener('click', pickHistDir); - // (Manual text input removed in favor of native dialog picker) - const histDirList = $('histDirList'); - if (histDirList) histDirList.addEventListener('click', async (e) => { - const btn = e.target.closest('[data-del-dir]'); - if (!btn || btn.disabled) return; - const id = btn.dataset.delDir; - if (id === '~/.claude') return; - const dirs = (config.historyDirs || []).filter((d) => d !== id); - await persist({ historyDirs: dirs.length ? dirs : ['~/.claude'] }); - }); - - // 会话正文字号: preset segments persist directly; 自定义 opens the px input (which persists - // on change). The preview + open Sessions view update live through the --conv-fs root var. - const convFontSeg = $('fConvFontSeg'); - if (convFontSeg) convFontSeg.addEventListener('click', (e) => { - const b = e.target.closest('.seg-btn'); - if (!b) return; - const mode = b.dataset.cfs; - if (mode === 'custom') { - convFontCustomOpen = true; - renderConvFontControl(); - const input = $('fConvFontPx'); - if (input) { input.focus(); input.select(); } - return; - } - convFontCustomOpen = false; - const px = mode === 'large' ? CONV_FONT_PRESETS.large : mode === 'xlarge' ? CONV_FONT_PRESETS.xlarge : CONV_FONT_BASE; - persist({ convFontPx: px === CONV_FONT_BASE ? null : px }); - }); - const fConvFontPx = $('fConvFontPx'); - if (fConvFontPx) { - const commit = () => { - const n = Math.round(Number(fConvFontPx.value)); - if (!Number.isFinite(n)) { fConvFontPx.value = convFontPx(); return; } - const px = Math.min(CONV_FONT_MAX, Math.max(CONV_FONT_MIN, n)); - fConvFontPx.value = px; - persist({ convFontPx: px === CONV_FONT_BASE ? null : px }); - }; - fConvFontPx.addEventListener('change', commit); - fConvFontPx.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); - } - - $('btnAdd').addEventListener('click', () => openModal(null)); - const btnAddEmpty = $('btnAddEmpty'); - if (btnAddEmpty) btnAddEmpty.addEventListener('click', () => openModal(null)); - - $('providerList').addEventListener('click', async (e) => { - // Resolve the actual button (the click may land on the inner SVG icon). - const btn = e.target.closest('button'); - if (btn && btn.dataset.edit) { openModal(config.providers.find((p) => p.id === btn.dataset.edit)); return; } - if (btn && btn.dataset.del) { - // Not window.confirm: the Tauri webview never shows it (silent no-op on macOS). - const ok = await confirmDialog({ - title: I18n.t('providers.delete'), - message: I18n.t('providers.confirmDelete'), - confirmText: I18n.t('providers.delete'), - danger: true, - }); - if (ok) { config = await api.deleteProvider(btn.dataset.del); renderAll(); } - return; - } - if (btn && btn.dataset.test) { - const p = config.providers.find((pp) => pp.id === btn.dataset.test); - const orig = btn.innerHTML; // preserve the SVG icon, restore it after - btn.innerHTML = '…'; btn.disabled = true; - const res = await api.testProvider(p); - btn.disabled = false; btn.innerHTML = res.ok ? '✓' : '✗'; - pushRawLog({ level: res.ok ? 'info' : 'error', msg: I18n.t('modal.testLog', { name: p.name, msg: res.message }) }); - setTimeout(() => { btn.innerHTML = orig; }, 1800); - return; - } - if (btn) return; // some other button — ignore - // click anywhere else on the card → set it as the active service - const card = e.target.closest('.provider'); - if (!card || !card.dataset.id) return; - if (card.dataset.id === config.activeProviderId) return; // already active — nothing to switch - // While the gateway is running, switching is easy to mis-trigger and would re-point every new - // Claude Code session — so confirm first. - if (status.running) { - const p = config.providers.find((pp) => pp.id === card.dataset.id); - const ok = await confirmDialog({ - title: I18n.t('switch.confirmTitle', { name: p ? p.name : '' }), - message: I18n.t('switch.confirmMsg'), - confirmText: I18n.t('switch.confirmOk'), - }); - if (!ok) return; - } - try { - config = await api.setActive(card.dataset.id); - } catch (err) { - const msg = (err && err.message) || String(err); - showToast(msg.includes('pluginNotRunning') ? I18n.t('providers.pluginOff') : msg, 'err'); - return; - } - renderAll(); - }); - wireDrag(); - - $('modalClose').addEventListener('click', closeModal); - $('btnCancel').addEventListener('click', closeModal); - $('presetGrid').addEventListener('click', (e) => { if (e.target.dataset.preset) selectPreset(e.target.dataset.preset); }); - { const fp = $('fProtocol'); if (fp) fp.addEventListener('click', (e) => { const b = e.target.closest('.proto-seg-btn'); if (b) setProtocol(b.dataset.proto); }); } - $('fName').addEventListener('input', updateIconPreview); - const fIconPreview = $('fIconPreview'); - if (fIconPreview && fIconPreview.parentElement) fIconPreview.parentElement.addEventListener('click', () => openIconPicker(fIconPreview)); - $('btnAddMap').addEventListener('click', () => addMapRow()); - $('fTokenToggle').addEventListener('click', () => { - const f = $('fToken'); const show = f.type === 'password'; - f.type = show ? 'text' : 'password'; $('fTokenToggle').textContent = show ? I18n.t('modal.hide') : I18n.t('modal.show'); - }); - $('btnSave').addEventListener('click', async () => { - const p = collectProvider(); - if (!p.baseUrl) { - showToast(I18n.t('modal.fillUrl'), 'err'); - return; - } - config = await api.upsertProvider(p); - closeModal(); renderAll(); - }); - $('btnTest').addEventListener('click', async () => { - // Surface the result as a floating toast — the in-modal alert sits at the bottom of a - // scrollable sheet and was easily hidden, leaving users unsure whether the test ran. - const pending = showToast(I18n.t('modal.testing'), 'pending'); - const testedProvider = collectProvider(); - const res = await api.testProvider(testedProvider); - if (res.ok && res.baseUrl && $('fBaseUrl').value.trim() === testedProvider.baseUrl) { - $('fBaseUrl').value = res.baseUrl; - } - let msg; - if (res.reason === 'baseUrlEmpty') msg = I18n.t('err.baseUrlEmpty'); - else if (res.reason === 'baseUrlInvalid') msg = I18n.t('err.baseUrlInvalid'); - else if (res.reason === 'timeout') msg = I18n.t('err.timeout'); - else if (res.ok) msg = I18n.t('err.testOk', { model: res.model || '' }); - else msg = res.message || ('HTTP ' + (res.status || '')); - if (pending) pending.dismiss(); - showToast((res.ok ? '✓ ' : '✗ ') + msg, res.ok ? 'ok' : 'err'); - }); - - $('btnClearLog').addEventListener('click', () => { - $('streamList').innerHTML = `
${escapeHtml(I18n.t('monitor.streamEmpty'))}
`; - gwLog.items.length = 0; gwLog.seen.clear(); - renderGatewayLog(); - stats.total = stats.ok = stats.sumMs = 0; stats.last = null; - $('streamHint').textContent = I18n.t('monitor.waitingDots'); - renderMonitor(); - if (api.monitorClear) api.monitorClear(); - if (api.logsClear) api.logsClear(); - closeReqDrawer(); - }); - - // Request inspector: click a stream row to open its full captured exchange. - const streamList = $('streamList'); - if (streamList) streamList.addEventListener('click', (e) => { - const row = e.target.closest('.stream-row'); - if (row && row.dataset.id) openReqDetail(row.dataset.id); - }); - const rdClose = $('reqDrawerClose'); - if (rdClose) rdClose.addEventListener('click', closeReqDrawer); - const reqDrawer = $('reqDrawer'); - if (reqDrawer) reqDrawer.addEventListener('click', (e) => { if (e.target === reqDrawer) closeReqDrawer(); }); - // Tabs are re-rendered per exchange (2 or 4 of them) — delegate on the container. - const drTabsWrap = $('drTabs'); - if (drTabsWrap) drTabsWrap.addEventListener('click', (e) => { - const t = e.target.closest('.dr-tab'); - if (!t) return; - reqDrawerTab = t.dataset.tab; - drTabsWrap.querySelectorAll('.dr-tab').forEach((x) => x.classList.toggle('active', x === t)); - renderReqDrawerBody(); - }); - const reqDrawerBody = $('reqDrawerBody'); - if (reqDrawerBody) { - reqDrawerBody.addEventListener('click', (e) => { - if (e.target.closest('.dr-search-prev')) { drNavSearch(-1); return; } - if (e.target.closest('.dr-search-next')) { drNavSearch(1); return; } - const cb = e.target.closest('[data-copy-body]'); - if (cb && reqDrawerData) { - const cap = drawerTabView(reqDrawerData, cb.dataset.copyBody).cap; - api.copy((cap && cap.text) || ''); - cb.textContent = I18n.t('copy.copied'); setTimeout(() => { cb.textContent = I18n.t('drawer.copy'); }, 1200); - } - }); - let _drSearchT = null; - reqDrawerBody.addEventListener('input', (e) => { - if (!e.target.classList || !e.target.classList.contains('dr-search')) return; - const v = e.target.value; - clearTimeout(_drSearchT); - _drSearchT = setTimeout(() => applyDrSearch(v), 110); - }); - reqDrawerBody.addEventListener('keydown', (e) => { - if (!e.target.classList || !e.target.classList.contains('dr-search')) return; - if (e.key === 'Enter') { e.preventDefault(); drNavSearch(e.shiftKey ? -1 : 1); } - else if (e.key === 'Escape') { - if (e.target.value) { e.preventDefault(); e.stopPropagation(); e.target.value = ''; applyDrSearch(''); } - else e.target.blur(); - } - }); - } - document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqDrawer && !reqDrawer.classList.contains('hidden')) closeReqDrawer(); }); - - api.onRequest((r) => pushStreamRow(r)); - api.onLog((l) => pushRawLog(l)); - api.onStatus((s) => { status = s; renderAll(); }); - if (api.onConfigChanged) api.onConfigChanged(async (next) => { - const previous = config; - const previousProvider = editingId && previous.providers.find((p) => p.id === editingId); - const baseUrlInput = editingId ? $('fBaseUrl') : null; - const baseUrlWasUnchanged = !!(previousProvider && baseUrlInput && baseUrlInput.value === (previousProvider.baseUrl || '')); - config = next && Array.isArray(next.providers) ? next : await api.getConfig(); - if (baseUrlWasUnchanged) { - const updatedProvider = config.providers.find((p) => p.id === editingId); - if (updatedProvider) baseUrlInput.value = updatedProvider.baseUrl || ''; - } - renderAll(); - }); - - // ----- in-app updates ----- - const bUpdCheck = $('btnUpdateCheck'); - if (bUpdCheck) bUpdCheck.addEventListener('click', checkUpdate); - const bUpdDl = $('btnUpdateDownload'); - if (bUpdDl) bUpdDl.addEventListener('click', downloadUpdate); - const bUpdApply = $('btnUpdateApply'); - if (bUpdApply) bUpdApply.addEventListener('click', async () => { - const ok = await confirmDialog({ title: I18n.t('about.restartTitle'), message: I18n.t('about.restartMsg'), confirmText: I18n.t('about.restartNow') }); - if (ok) api.updateApply(); - }); - const bUpdOpen = $('btnUpdateOpen'); - if (bUpdOpen) bUpdOpen.addEventListener('click', () => api.openExternal((updateState && updateState.releaseUrl) || 'https://github.com/ccbud/ccbud/releases/latest')); - const bUpdBrew = $('btnUpdateBrew'); - if (bUpdBrew) bUpdBrew.addEventListener('click', (e) => copyFeedback(e.currentTarget, (updateState && updateState.brewCommand) || 'brew upgrade --cask ccbud')); - const bRepo = $('btnRepo'); - if (bRepo) bRepo.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud')); - const bReleases = $('btnReleases'); - if (bReleases) bReleases.addEventListener('click', () => api.openExternal('https://github.com/ccbud/ccbud/releases')); - const fAutoCheck = $('fAutoCheck'); - if (fAutoCheck) fAutoCheck.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ check: e.target.checked }); }); - const fAutoDownload = $('fAutoDownload'); - if (fAutoDownload) fAutoDownload.addEventListener('change', async (e) => { config.autoUpdate = await api.updateSetAuto({ autoDownload: e.target.checked }); }); - - if (api.onUpdateState) api.onUpdateState((s) => { updateState = s; renderUpdate(); }); - if (api.onUpdateStaged) api.onUpdateStaged(() => { loadUpdateState(); pushRawLog({ level: 'info', msg: I18n.t('about.stagedLog') }); }); - if (api.onUpdateOpenPane) api.onUpdateOpenPane(() => { switchView('settings'); switchSettings('about'); checkUpdate(); }); -} - -try { applyTheme(localStorage.getItem('ccbud-theme') || 'light'); } catch (_) { applyTheme('light'); } -// Apply the UI language synchronously before first paint (mirrors theme; no flash). Boot from -// localStorage; the async refresh() then reconciles with config.language (the source of truth). -function bootLang() { - let l = ''; - try { l = localStorage.getItem('ccbud-lang') || ''; } catch (_) {} - if (!l) { - const nav = (navigator.language || 'en').toLowerCase(); - l = nav.startsWith('zh') ? ((/-(tw|hk|mo)\b/.test(nav) || nav.includes('hant')) ? 'zh-TW' : 'zh') - : nav.startsWith('ja') ? 'ja' : nav.startsWith('ko') ? 'ko' : 'en'; - } - try { I18n.setLang(l); I18n.apply(document); } catch (_) {} -} -bootLang(); -renderPresetGrid(); -bind(); -refresh(); diff --git a/src/renderer/tauri-bridge.js b/src/renderer/tauri-bridge.js deleted file mode 100644 index f94fb97..0000000 --- a/src/renderer/tauri-bridge.js +++ /dev/null @@ -1,130 +0,0 @@ -'use strict'; -/* - * Tauri IPC bridge — exposes the `window.ccbud` API consumed by the renderer on top of - * Tauri's invoke()/listen(). Loaded before renderer.js (which does `const api = window.ccbud` - * at line 1). - * - * Backend commands are snake_case Tauri commands (see src-tauri/src/lib.rs); event names - * keep their original "namespace:event" form so the renderer's onX handlers are untouched. - */ -(function () { - const T = window.__TAURI__; - if (!T) { console.error('[ccbud] Tauri API not found — window.__TAURI__ missing'); return; } - const invoke = T.core.invoke; - const listen = T.event.listen; - const inv = (cmd, args) => invoke(cmd, args || {}); - const on = (event, cb) => { listen(event, (e) => cb(e.payload)); }; - let droppedPaths = []; - - function fileName(path) { - return String(path || '').split(/[\\/]/).filter(Boolean).pop() || ''; - } - function rememberDrop(payload) { - const paths = Array.isArray(payload && payload.paths) ? payload.paths - : Array.isArray(payload) ? payload - : []; - if (paths.length) droppedPaths = paths.map(String); - } - try { - listen('tauri://drag-drop', (e) => rememberDrop(e.payload)); - listen('tauri://file-drop', (e) => rememberDrop(e.payload)); - } catch (_) {} - - window.ccbud = { - getConfig: () => inv('config_get'), - saveConfig: (cfg) => inv('config_save', { cfg }), - onConfigChanged: (cb) => on('config:changed', cb), - - upsertProvider: (p) => inv('provider_upsert', { p }), - deleteProvider: (id) => inv('provider_delete', { id }), - setActive: (id) => inv('provider_set_active', { id }), - testProvider: (p) => inv('provider_test', { p }), - - pluginList: () => inv('plugin_list'), - pluginStatus: (id) => inv('plugin_status', { id }), - pluginSetEnabled: (id, enabled) => inv('plugin_set_enabled', { id, enabled }), - pluginAction: (id, action, values) => inv('plugin_action', { id, action, values: values || {} }), - pluginActionLoad: (id, action) => inv('plugin_action_load', { id, action }), - pluginInstall: (title) => inv('plugin_install', { title }), - pluginUninstall: (id) => inv('plugin_uninstall', { id }), - pluginOpenDir: () => inv('plugin_open_dir'), - pluginInstallGit: (url) => inv('plugin_install_git', { url }), - pluginCheckUpdate: (id) => inv('plugin_check_update', { id }), - pluginUpdate: (id) => inv('plugin_update', { id }), - - connect: () => inv('claude_connect'), - disconnect: () => inv('claude_disconnect'), - setConnectTarget: (target, on) => inv('set_connect_target', { target, on }), - - desktopReplay: (file, prompt) => inv('desktop_replay', { file, prompt }), - chatgptReplay: (file, prompt) => inv('chatgpt_replay', { file, prompt }), - - serverStatus: () => inv('server_status'), - usageGet: (range) => inv('usage_get', { range }), - - monitorGet: (id) => inv('monitor_get', { id }), - gatewaySetEnabled: (on) => inv('gateway_set_enabled', { on }), - monitorClear: () => inv('monitor_clear'), - logsGet: () => inv('logs_get'), - logsClear: () => inv('logs_clear'), - - openMain: () => inv('app_open_main'), - quitApp: () => inv('app_quit'), - setSettingsMode: (on) => inv('window_settings_mode', { on }), - setViewMinWidth: (w) => inv('window_view_min_width', { w }), - - historyProjects: () => inv('history_projects'), - historyList: () => inv('history_list'), - historyGet: (file) => inv('history_get', { file }), - historySearch: (query) => inv('history_search', { query }), - historyDirs: () => inv('history_dirs'), - historyPickDir: () => inv('history_pick_dir'), - historySetActive: (id) => inv('history_set_active', { id }), - historyImport: () => inv('history_import'), - historyImportPaths: (paths) => inv('history_import_paths', { paths }), - historyRemoveImport: (file) => inv('history_remove_import', { file }), - historySetMeta: (file, patch) => inv('history_set_meta', { file, patch }), - historyDeleteForever: (file) => inv('history_delete_forever', { file }), - historyExportRaw: (file) => inv('history_export_raw', { file }), - historyExportHtml: (payload) => inv('history_export_html', { payload }), - pathForFile: (file) => { - const name = file && file.name; - if (!name) return ''; - return droppedPaths.find((p) => fileName(p) === name) || ''; - }, - onHistoryChanged: (cb) => on('history:changed', cb), - - copy: (t) => inv('util_copy', { text: t }), - openExternal: (u) => inv('util_open_external', { url: u }), - - updateState: () => inv('update_state'), - updateCheck: () => inv('update_check'), - updateDownload: () => inv('update_download'), - updateApply: () => inv('update_apply'), - updateSetAuto: (patch) => inv('update_set_auto', { patch }), - onUpdateState: (cb) => on('update:state', cb), - onUpdateStaged: (cb) => on('update:staged', cb), - onUpdateOpenPane: (cb) => on('update:openPane', cb), - - onLog: (cb) => on('gateway:log', cb), - onRequest: (cb) => on('gateway:request', cb), - onStatus: (cb) => on('gateway:status', cb), - onPopoverShow: (cb) => on('popover:show', cb), - }; - - // Window dragging: map the existing `.drag-region` elements onto Tauri's `data-tauri-drag-region` - // (its bundled handler starts a window drag on mousedown over an element carrying that attr; - // `.no-drag` children like buttons/inputs are untouched since the event target is the child). - function wireDrag(root) { - (root || document).querySelectorAll('.drag-region').forEach((el) => { - el.setAttribute('data-tauri-drag-region', ''); - }); - } - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => wireDrag()); - else wireDrag(); - // Re-apply for any drag bars the renderer injects after first paint (view switches, etc.). - try { - const mo = new MutationObserver(() => wireDrag()); - mo.observe(document.documentElement, { childList: true, subtree: true }); - } catch (_) {} -})(); diff --git a/src/renderer/theme-boot.js b/src/renderer/theme-boot.js new file mode 100644 index 0000000..1a0e318 --- /dev/null +++ b/src/renderer/theme-boot.js @@ -0,0 +1,26 @@ +'use strict'; +/* Pre-paint boot stamp — the ONLY synchronous script on the startup path. Sets the theme + and locale attributes from localStorage before first paint so neither flashes; every + other script is deferred (analytics) or an ES module (the app), which never block paint. */ +(function () { + var doc = document.documentElement; + var theme = 'light'; + try { theme = localStorage.getItem('ccbud-theme') || 'light'; } catch (_) {} + doc.setAttribute('data-theme', theme); + if (theme === 'dark') { + // The hljs sheets are toggled via media attrs; stamp the dark one on before paint. + var hd = document.getElementById('hljs-dark'); + var hl = document.getElementById('hljs-light'); + if (hd) hd.media = 'all'; + if (hl) hl.media = 'not all'; + } + var lang = ''; + try { lang = localStorage.getItem('ccbud-lang') || ''; } catch (_) {} + if (!lang) { + var nav = (navigator.language || 'en').toLowerCase(); + lang = nav.indexOf('zh') === 0 ? ((/-(tw|hk|mo)\b/.test(nav) || nav.indexOf('hant') >= 0) ? 'zh-TW' : 'zh') + : nav.indexOf('ja') === 0 ? 'ja' : nav.indexOf('ko') === 0 ? 'ko' : 'en'; + } + var tags = { en: 'en-US', zh: 'zh-CN', 'zh-TW': 'zh-TW', ja: 'ja-JP', ko: 'ko-KR' }; + doc.setAttribute('lang', tags[lang] || 'en-US'); +})(); diff --git a/src/shared/i18n-dict.js b/src/shared/i18n-dict.js deleted file mode 100644 index b621e2d..0000000 --- a/src/shared/i18n-dict.js +++ /dev/null @@ -1,2270 +0,0 @@ -'use strict'; - -/* - * ccbud i18n dictionary — 5 locales. Dual-export so the SAME file is used by: - * - the renderer + popover windows via