diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index cbdee326..d1ecf744 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -27,6 +27,7 @@ export const cssOutput = path.join(rendererAssetsDir, "main.css"); const nodeExternals = [ "electron", + "better-sqlite3", ...builtinModules, ...builtinModules.map((moduleName) => `node:${moduleName}`) ]; diff --git a/electron-builder.json b/electron-builder.json index 329ff6f9..e97cf839 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -2,7 +2,10 @@ "appId": "com.claudecoderouter.desktop", "productName": "Claude Code Router", "asar": true, - "npmRebuild": false, + "asarUnpack": [ + "**/*.node" + ], + "npmRebuild": true, "publish": [ { "provider": "github", diff --git a/package-lock.json b/package-lock.json index 4f8b0b00..4f9932c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,9 @@ "@dnd-kit/utilities": "^3.2.2", "@the-next-ai/ai-gateway": "file:../../next-ai/gateway", "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", - "sql.js": "^1.14.1", "undici": "^7.27.2" }, "bin": { @@ -23,11 +23,11 @@ }, "devDependencies": { "@tailwindcss/cli": "^4.3.0", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/node-forge": "^1.3.14", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", - "@types/sql.js": "^1.4.11", "baseui": "^16.1.1", "clsx": "^2.1.1", "electron": "^42.3.3", @@ -2087,6 +2087,16 @@ "node": ">=22.0.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -2182,13 +2192,6 @@ "@types/ms": "*" } }, - "node_modules/@types/emscripten": { - "version": "1.41.5", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", - "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -2288,17 +2291,6 @@ "@types/node": "*" } }, - "node_modules/@types/sql.js": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", - "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/emscripten": "*", - "@types/node": "*" - } - }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2691,7 +2683,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2830,6 +2821,54 @@ "dev": true, "license": "0BSD" }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2871,6 +2910,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3739,7 +3802,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -3755,7 +3817,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3764,6 +3825,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -4213,7 +4283,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -4394,6 +4463,15 @@ "dev": true, "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -4517,6 +4595,12 @@ "node": ">= 10" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4642,6 +4726,12 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -4742,6 +4832,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gl-matrix": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", @@ -5017,7 +5113,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -5061,7 +5156,12 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, "node_modules/inline-style-prefixer": { @@ -5864,7 +5964,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5921,6 +6020,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mockdate": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-2.0.5.tgz", @@ -6005,6 +6110,12 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/node-abi": { "version": "4.31.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", @@ -6185,7 +6296,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6474,6 +6584,54 @@ "dev": true, "license": "ISC" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/prebuild-install/node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -6594,7 +6752,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -6647,6 +6804,21 @@ "dev": true, "license": "ISC" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -7204,7 +7376,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safe-regex2": { @@ -7362,6 +7533,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -7432,12 +7648,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", - "license": "MIT" - }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -7452,7 +7662,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -7486,6 +7695,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/styletron-engine-atomic": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/styletron-engine-atomic/-/styletron-engine-atomic-1.6.2.tgz", @@ -7608,6 +7826,54 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -7808,6 +8074,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -7956,7 +8234,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/victory-vendor": { @@ -8067,7 +8344,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index f5a3f46d..259289c0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build:app:mac": "npm run build:assets && electron-builder --mac", "build:app:win": "npm run build:assets && electron-builder --win", "preview": "npm run build:assets && electron .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -23,18 +24,18 @@ "@dnd-kit/utilities": "^3.2.2", "@the-next-ai/ai-gateway": "file:../../next-ai/gateway", "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", - "sql.js": "^1.14.1", "undici": "^7.27.2" }, "devDependencies": { "@tailwindcss/cli": "^4.3.0", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/node-forge": "^1.3.14", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", - "@types/sql.js": "^1.4.11", "baseui": "^16.1.1", "clsx": "^2.1.1", "electron": "^42.3.3", diff --git a/src/main/api-key-store.ts b/src/main/api-key-store.ts index 8f1575e2..a91aa8d0 100644 --- a/src/main/api-key-store.ts +++ b/src/main/api-key-store.ts @@ -1,17 +1,12 @@ import { safeStorage } from "electron"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import initSqlJs from "sql.js"; import { API_KEYS_DB_FILE } from "./constants"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; import type { ApiKeyConfig, ApiKeyLimitConfig } from "../shared/app"; -type SqlDatabase = InstanceType>["Database"]>; -type SqlValue = number | string | Uint8Array | null; -type QueryExecResult = { - columns: string[]; - values: SqlValue[][]; -}; +type SqlDatabase = BetterSqliteDatabase; +type SqlValue = bigint | Buffer | number | string | null; type StoredApiKeyRow = { createdAt: string; @@ -23,7 +18,6 @@ type StoredApiKeyRow = { name: string; }; -const requireFromHere = createRequire(__filename); const plainStorage = "plain"; const safeStorageEncryption = "electron-safe-storage"; @@ -35,8 +29,9 @@ class ApiKeyStore { async list(): Promise { const database = await this.getDatabase(); - const rows = readRows( - database.exec(` + const rows = queryRows( + database, + ` SELECT id, name, @@ -47,7 +42,7 @@ class ApiKeyStore { limits_json FROM api_keys ORDER BY rowid - `)[0] + ` ); return uniqueApiKeyConfigs(rows.map(toApiKeyConfig)); @@ -69,11 +64,11 @@ class ApiKeyStore { `); try { - database.run("BEGIN TRANSACTION"); - database.run("DELETE FROM api_keys"); + database.exec("BEGIN TRANSACTION"); + database.exec("DELETE FROM api_keys"); for (const apiKey of normalized) { const stored = encryptApiKey(apiKey.key); - statement.run([ + statement.run( apiKey.id, apiKey.name ?? "", stored.value, @@ -81,20 +76,17 @@ class ApiKeyStore { apiKey.createdAt, apiKey.expiresAt ?? "", apiKey.limits ? JSON.stringify(apiKey.limits) : "" - ]); + ); } - database.run("COMMIT"); - this.persist(); + database.exec("COMMIT"); return normalized; } catch (error) { try { - database.run("ROLLBACK"); + database.exec("ROLLBACK"); } catch { // Ignore rollback errors; the original write error is more useful. } throw error; - } finally { - statement.free(); } } @@ -109,13 +101,10 @@ class ApiKeyStore { private async open(): Promise { mkdirSync(dirname(this.dbFile), { recursive: true }); - const wasmFile = requireFromHere.resolve("sql.js/dist/sql-wasm.wasm"); - const SQL = await initSqlJs({ locateFile: () => wasmFile }); - const database = existsSync(this.dbFile) - ? new SQL.Database(readFileSync(this.dbFile)) - : new SQL.Database(); + const database = createBetterSqliteDatabase(this.dbFile); + configureSqliteDatabase(database); - database.run(` + database.exec(` CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', @@ -129,16 +118,8 @@ class ApiKeyStore { `); this.database = database; - this.persist(); return database; } - - private persist(): void { - if (!this.database) { - return; - } - writeFileSync(this.dbFile, Buffer.from(this.database.export())); - } } export const apiKeyStore = new ApiKeyStore(API_KEYS_DB_FILE); @@ -241,18 +222,14 @@ function parseApiKeyLimits(value: string): ApiKeyLimitConfig | undefined { } } -function readRows(result: QueryExecResult | undefined): Array> { - if (!result) { - return []; - } +function configureSqliteDatabase(database: SqlDatabase): void { + database.pragma("journal_mode = WAL"); + database.pragma("synchronous = NORMAL"); + database.pragma("busy_timeout = 5000"); +} - return result.values.map((values) => { - const row: Record = {}; - result.columns.forEach((column, index) => { - row[column] = values[index] ?? null; - }); - return row; - }); +function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Array> { + return database.prepare(sql).all(...params) as Array>; } function uniqueApiKeyConfigs(values: Array): ApiKeyConfig[] { diff --git a/src/main/app-menu.ts b/src/main/app-menu.ts new file mode 100644 index 00000000..eb95d347 --- /dev/null +++ b/src/main/app-menu.ts @@ -0,0 +1,146 @@ +import { app, dialog, Menu, type BrowserWindow, type MenuItemConstructorOptions } from "electron"; +import { APP_NAME, IPC_CHANNELS } from "./constants"; +import windowsManager from "./windows"; + +export function setupApplicationMenu(): void { + Menu.setApplicationMenu(Menu.buildFromTemplate(createMenuTemplate())); +} + +function createMenuTemplate(): MenuItemConstructorOptions[] { + const template: MenuItemConstructorOptions[] = []; + + if (process.platform === "darwin") { + template.push({ + label: APP_NAME, + submenu: [ + { label: `About ${APP_NAME}`, click: showAboutPanel }, + { type: "separator" }, + { label: "Settings...", accelerator: "CmdOrCtrl+,", click: openSettings }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" } + ] + }); + } else { + template.push({ + label: "File", + submenu: [ + { label: "Settings...", accelerator: "Ctrl+,", click: openSettings }, + { type: "separator" }, + { role: "quit" } + ] + }); + } + + template.push( + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + ...(process.platform === "darwin" + ? [ + { role: "pasteAndMatchStyle" as const }, + { role: "delete" as const }, + { role: "selectAll" as const }, + { type: "separator" as const }, + { + label: "Speech", + submenu: [ + { role: "startSpeaking" as const }, + { role: "stopSpeaking" as const } + ] + } + ] + : [ + { role: "delete" as const }, + { type: "separator" as const }, + { role: "selectAll" as const } + ]) + ] + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" } + ] + }, + { + label: "Window", + submenu: process.platform === "darwin" + ? [ + { role: "minimize" }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" } + ] + : [ + { role: "minimize" }, + { role: "close" } + ] + } + ); + + if (process.platform !== "darwin") { + template.push({ + label: "Help", + submenu: [ + { label: `About ${APP_NAME}`, click: showAboutPanel } + ] + }); + } + + return template; +} + +function openSettings(): void { + const window = windowsManager.showMainWindow(); + sendWhenReady(window, IPC_CHANNELS.appOpenSettings); +} + +function showAboutPanel(): void { + const window = windowsManager.getWindow("main"); + const options = { + detail: `Version ${app.getVersion()}`, + message: APP_NAME, + title: `About ${APP_NAME}`, + type: "info" + } as const; + void (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)); +} + +function sendWhenReady(window: BrowserWindow, channel: string): void { + if (window.isDestroyed() || window.webContents.isDestroyed()) { + return; + } + + const send = () => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send(channel); + } + }; + + if (window.webContents.isLoading()) { + window.webContents.once("did-finish-load", send); + return; + } + + send(); +} diff --git a/src/main/backend-service.ts b/src/main/backend-service.ts index 40f899f7..be75d703 100644 --- a/src/main/backend-service.ts +++ b/src/main/backend-service.ts @@ -1,12 +1,27 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { createRequire } from "node:module"; import path from "node:path"; -import initSqlJs from "sql.js"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase, type BetterSqliteStatement } from "./sqlite-native"; type MaybePromise = T | Promise; -export type SqlDatabase = InstanceType>["Database"]>; -export type SqliteValue = number | string | Uint8Array | null; +export type SqliteValue = bigint | Buffer | number | string | Uint8Array | null; +export type SqliteExecResult = { + columns: string[]; + values: SqliteValue[][]; +}; +export type SqliteStatement = { + bind: (params?: SqliteValue[]) => void; + free: () => void; + getAsObject: () => Record; + run: (params?: SqliteValue[]) => void; + step: () => boolean; +}; +export type SqlDatabase = { + close: () => void; + exec: (sql: string, params?: SqliteValue[]) => SqliteExecResult[]; + prepare: (sql: string) => SqliteStatement; + run: (sql: string, params?: SqliteValue[]) => SqlDatabase; +}; export type HttpBackendRegistration = { handler: (request: IncomingMessage, response: ServerResponse) => MaybePromise; @@ -39,12 +54,9 @@ type RegisteredBackendServer = RegisteredHttpBackend & { server: Server; }; -const requireFromHere = createRequire(__filename); - class BackendService { private backends: RegisteredBackendServer[] = []; private sqliteStores: SqliteStoreImpl[] = []; - private sqlJs?: Promise>>; async registerHttpBackend(ownerId: string, backend: HttpBackendRegistration): Promise { const server = http.createServer((request, response) => { @@ -88,8 +100,7 @@ class BackendService { const filename = options.filename || `${sanitizeFileSegment(ownerId)}.sqlite`; const dbFile = path.isAbsolute(filename) ? filename : path.join(dataDir, filename); mkdirSync(path.dirname(dbFile), { recursive: true }); - const SQL = await this.getSqlJs(); - const database = openSqliteDatabaseWithRecovery(SQL, ownerId, dbFile); + const database = openSqliteDatabaseWithRecovery(ownerId, dbFile); const store = new SqliteStoreImpl(ownerId, dbFile, database); this.sqliteStores.push(store); if (options.migrate) { @@ -122,11 +133,6 @@ class BackendService { ]); await Promise.all([...ownerIds].map((ownerId) => this.stopOwner(ownerId))); } - - private getSqlJs(): Promise>> { - this.sqlJs ??= initSqlJs({ locateFile: () => requireFromHere.resolve("sql.js/dist/sql-wasm.wasm") }); - return this.sqlJs; - } } class SqliteStoreImpl implements SqliteStore { @@ -141,28 +147,20 @@ class SqliteStoreImpl implements SqliteStore { } persist(): void { - writeFileSync(this.dbFile, Buffer.from(this.database.export())); + // better-sqlite3 writes mutations directly to the database/WAL. Keep this + // method for the plugin API without reintroducing whole-database rewrites. } close(): void { - this.persist(); this.database.close(); } } export const backendService = new BackendService(); -function openSqliteDatabaseWithRecovery( - SQL: Awaited>, - ownerId: string, - dbFile: string -): SqlDatabase { - if (!existsSync(dbFile)) { - return new SQL.Database(); - } - +function openSqliteDatabaseWithRecovery(ownerId: string, dbFile: string): SqlDatabase { try { - const database = new SQL.Database(readFileSync(dbFile)); + const database = openBetterSqliteDatabase(dbFile); assertSqliteDatabaseIntegrity(database); return database; } catch (error) { @@ -171,15 +169,145 @@ function openSqliteDatabaseWithRecovery( } const backupFile = nextCorruptSqliteBackupPath(dbFile); - copyFileSync(dbFile, backupFile); + if (existsSync(dbFile)) { + copyFileSync(dbFile, backupFile); + } + removeSqliteDatabaseFiles(dbFile); console.warn( `[backend:${ownerId}] SQLite store is corrupt and will be rebuilt: ${dbFile}. ` + `Corrupt copy saved to ${backupFile}. Error: ${formatError(error)}` ); - return new SQL.Database(); + return openBetterSqliteDatabase(dbFile); } } +function openBetterSqliteDatabase(dbFile: string): SqlDatabase { + const raw = createBetterSqliteDatabase(dbFile); + raw.pragma("journal_mode = WAL"); + raw.pragma("synchronous = NORMAL"); + raw.pragma("busy_timeout = 5000"); + return new SqliteCompatDatabase(raw); +} + +class SqliteCompatDatabase implements SqlDatabase { + constructor(private readonly raw: BetterSqliteDatabase) {} + + close(): void { + this.raw.close(); + } + + exec(sql: string, params: SqliteValue[] = []): SqliteExecResult[] { + const normalizedParams = normalizeSqliteParams(params); + if (normalizedParams.length === 0 && !sqlCanReturnRows(sql)) { + this.raw.exec(sql); + return []; + } + + const statement = this.raw.prepare(sql); + if (!statement.reader) { + statement.run(...normalizedParams); + return []; + } + + const columns = statement.columns().map((column) => column.name); + const rows = statement.all(...normalizedParams) as Array>; + return [{ + columns, + values: rows.map((row) => columns.map((column) => normalizeSqliteValue(row[column]))) + }]; + } + + prepare(sql: string): SqliteStatement { + return new SqliteCompatStatement(this.raw.prepare(sql)); + } + + run(sql: string, params: SqliteValue[] = []): SqlDatabase { + const normalizedParams = normalizeSqliteParams(params); + if (normalizedParams.length === 0) { + this.raw.exec(sql); + } else { + this.raw.prepare(sql).run(...normalizedParams); + } + return this; + } +} + +class SqliteCompatStatement implements SqliteStatement { + private boundParams: SqliteValue[] = []; + private currentRow: Record = {}; + private rowIndex = -1; + private rows?: Array>; + + constructor(private readonly statement: BetterSqliteStatement) {} + + bind(params: SqliteValue[] = []): void { + this.boundParams = normalizeSqliteParams(params); + this.currentRow = {}; + this.rowIndex = -1; + this.rows = undefined; + } + + free(): void { + this.currentRow = {}; + this.rows = undefined; + } + + getAsObject(): Record { + return { ...this.currentRow }; + } + + run(params?: SqliteValue[]): void { + const normalizedParams = params === undefined ? this.boundParams : normalizeSqliteParams(params); + this.statement.run(...normalizedParams); + } + + step(): boolean { + if (!this.statement.reader) { + return false; + } + this.rows ??= (this.statement.all(...this.boundParams) as Array>) + .map((row) => normalizeSqliteRow(row)); + this.rowIndex += 1; + const row = this.rows[this.rowIndex]; + if (!row) { + this.currentRow = {}; + return false; + } + this.currentRow = row; + return true; + } +} + +function sqlCanReturnRows(sql: string): boolean { + return /^(?:\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)*(?:select|pragma|with|explain)\b/i.test(sql); +} + +function normalizeSqliteParams(params: SqliteValue[]): SqliteValue[] { + return params.map((value) => normalizeSqliteValue(value)); +} + +function normalizeSqliteRow(row: Record): Record { + return Object.fromEntries( + Object.entries(row).map(([key, value]) => [key, normalizeSqliteValue(value)]) + ); +} + +function normalizeSqliteValue(value: unknown): SqliteValue { + if (value === undefined || value === null) { + return null; + } + if (typeof value === "bigint" || typeof value === "number" || typeof value === "string") { + return value; + } + if (Buffer.isBuffer(value)) { + return value; + } + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + return String(value); +} + function assertSqliteDatabaseIntegrity(database: SqlDatabase): void { const result = database.exec("PRAGMA integrity_check;"); const status = result[0]?.values?.[0]?.[0]; @@ -211,6 +339,12 @@ function nextCorruptSqliteBackupPath(dbFile: string): string { return `${base}-${process.pid}`; } +function removeSqliteDatabaseFiles(dbFile: string): void { + rmSync(dbFile, { force: true }); + rmSync(`${dbFile}-wal`, { force: true }); + rmSync(`${dbFile}-shm`, { force: true }); +} + function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { response.writeHead(statusCode, { "content-type": "application/json" }); response.end(`${JSON.stringify(body)}\n`); diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 3753a8a2..91c3b169 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -17,7 +17,7 @@ const BOT_SESSION_ENTRY_VERSION = 2; const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60 * 1000); const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000); const CONFIG_DIR = path.join(os.homedir(), ".claude-code-router"); -const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || path.join(CONFIG_DIR, "codex-cli-middleware.log"); +const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || ""; let BOT_BRIDGE_INSTANCE = null; function botBridge() { @@ -3367,6 +3367,7 @@ function formatError(error) { } function log(event, fields) { + if (!LOG_PATH) return; try { fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); fs.appendFileSync(LOG_PATH, JSON.stringify({ tsMs: Date.now(), event, ...fields }) + "\n"); diff --git a/src/main/config.ts b/src/main/config.ts index d2669df1..4411a215 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store"; import { CONFIGDIR, CONFIG_FILE, GATEWAY_CONFIG_FILE } from "./constants"; -import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app"; +import { CLAUDE_CODE_DEFAULT_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app"; import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "../shared/provider-presets"; import type { AppConfig, @@ -171,7 +171,7 @@ const DEFAULT_CONFIG: AppConfig = { { agent: "claude-code", enabled: true, - env: {}, + env: { ...CLAUDE_CODE_DEFAULT_ENV }, id: "default-claude-code", model: "", name: "Claude Code", @@ -1040,7 +1040,9 @@ function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | return undefined; } - const id = readString(item.id) || readString(item.name) || readString(item.label) || `key-${index + 1}`; + const legacyLabel = readString(item.label); + const id = readString(item.id) || readString(item.name) || legacyLabel || `key-${index + 1}`; + const name = readString(item.name) || legacyLabel || id; const priority = readNumber(item.priority); const weight = readNumber(item.weight); return { @@ -1048,7 +1050,8 @@ function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | api_key: apiKey, enabled: typeof item.enabled === "boolean" ? item.enabled : undefined, id, - label: readString(item.label) || readString(item.name), + ...(legacyLabel ? { label: legacyLabel } : {}), + name, limits: parseApiKeyLimits(item.limits), priority: priority !== undefined ? priority : undefined, weight: weight !== undefined && weight > 0 ? weight : undefined @@ -1953,7 +1956,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { ...(botConfigId ? { botConfigId } : {}), ...(botGateway ? { botGateway } : {}), enabled, - env, + env: claudeCodeProfileEnv(env), id, model, name, @@ -2011,7 +2014,7 @@ function profileFromClaudeCodeConfig(config: ClaudeCodeProfileConfig): ProfileCo return { agent: "claude-code", enabled: config.enabled, - env: {}, + env: claudeCodeProfileEnv(), id: "default-claude-code", model: config.model, name: "Claude Code", @@ -2022,6 +2025,13 @@ function profileFromClaudeCodeConfig(config: ClaudeCodeProfileConfig): ProfileCo }; } +function claudeCodeProfileEnv(env: Record = {}): Record { + return { + ...CLAUDE_CODE_DEFAULT_ENV, + ...env + }; +} + function profileFromCodexConfig(config: CodexProfileConfig): ProfileConfig { return { agent: "codex", diff --git a/src/main/gateway/service.ts b/src/main/gateway/service.ts index 88488797..93aa7854 100644 --- a/src/main/gateway/service.ts +++ b/src/main/gateway/service.ts @@ -154,6 +154,7 @@ type ClaudeCodeDiscoverableModel = { type UpstreamAttempt = { body?: Buffer; credentialChain?: string[]; + credentialIds?: string[]; credentialProtocol?: GatewayProviderProtocol; headers?: Record; index: number; @@ -163,6 +164,7 @@ type UpstreamAttempt = { type UpstreamFailedAttempt = { credentialChain?: string[]; + credentialIds?: string[]; error?: string; model?: string; statusCode?: number; @@ -189,9 +191,6 @@ class UpstreamRequestError extends Error { const requireFromHere = createRequire(__filename); const localObservabilityHeaderNames = new Set([ - "x-agent-session-id", - "x-claude-design-chat-id", - "x-claude-design-project-id", "x-ccr-claude-model-discovery", "x-ccr-codex-model-rewrite", "x-ccr-cursor-openai-compat", @@ -1189,14 +1188,15 @@ function sanitizeMcpServerName(value: string): string { } function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record { + const enabled = rawTraceEnabledFromEnv(); return { deleteLocalAfterUpload: false, - enabled: true, + enabled, maxPartBytes: maxUsageCaptureBytes, mode: "wire_raw", spoolDir: RAW_TRACE_SPOOL_DIR, sync: { - enabled: true, + enabled, endpoint: `${endpoint(config.gateway.host, config.gateway.port)}${rawTraceSyncPath}`, headers: { [rawTraceSyncHeader]: rawTraceSyncToken @@ -1206,6 +1206,11 @@ function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Reco }; } +function rawTraceEnabledFromEnv(): boolean { + const value = (process.env.CCR_RAW_TRACE_ENABLED ?? process.env.CCR_RAW_TRACE ?? "").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + function readRawTraceRequestLogUpdate(manifest: Record): RequestLogRawTraceUpdateInput | undefined { const requestId = stringValue(manifest.turnKey); const parts = Array.isArray(manifest.parts) @@ -1598,6 +1603,7 @@ async function fetchUpstreamWithFallback(input: { if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { failedAttempts.push({ credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, model: attempt.model, statusCode: response.status }); @@ -1614,6 +1620,7 @@ async function fetchUpstreamWithFallback(input: { const message = formatError(error); failedAttempts.push({ credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, error: message, model: attempt.model }); @@ -1680,6 +1687,7 @@ function prepareUpstreamCredentialAttempt(input: { ...input.attempt, body: target.body ?? input.attempt.body, credentialChain: selection.credentials.map((candidate) => candidate.internalName), + credentialIds: selection.credentials.map((candidate) => candidate.credential.id), credentialProtocol: target.protocol, headers, logicalProvider: target.provider.name @@ -1920,15 +1928,25 @@ function serializeJsonBodyWithModel(body: Record, model: string } function mergeFallbackResponseHeaders(headers: Headers, result: UpstreamFetchResult): Headers { - if (result.failedAttempts.length === 0) { + const credentialIds = result.attempt.credentialIds ?? []; + const credentialSaturated = result.attempt.headers?.["x-ccr-provider-credential-saturated"] === "true"; + if (result.failedAttempts.length === 0 && credentialIds.length === 0 && !credentialSaturated) { return headers; } const merged = new Headers(headers); - merged.set("x-ccr-fallback-attempts", String(result.failedAttempts.length + 1)); - merged.set("x-ccr-fallback-failures", formatFallbackFailures(result.failedAttempts)); - if (result.attempt.model) { - merged.set("x-ccr-fallback-model", result.attempt.model); + if (result.failedAttempts.length > 0) { + merged.set("x-ccr-fallback-attempts", String(result.failedAttempts.length + 1)); + merged.set("x-ccr-fallback-failures", formatFallbackFailures(result.failedAttempts)); + if (result.attempt.model) { + merged.set("x-ccr-fallback-model", result.attempt.model); + } + } + if (credentialIds.length) { + merged.set("x-ccr-provider-credential-chain", credentialIds.join(",")); + } + if (credentialSaturated) { + merged.set("x-ccr-provider-credential-saturated", "true"); } return merged; } diff --git a/src/main/main.ts b/src/main/main.ts index 96b4abc0..88e5e53a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,4 +1,5 @@ import { app } from "electron"; +import { setupApplicationMenu } from "./app-menu"; import { loadAppConfig } from "./config"; import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; import { deepLinkService } from "./deep-link"; @@ -35,6 +36,7 @@ function startPrimaryInstance(): void { }); app.whenReady().then(() => { + setupApplicationMenu(); windowsManager.createMainWindow(); trayController.start(); appUpdateService.start(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 6fae816a..8526ed94 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -111,6 +111,11 @@ contextBridge.exposeInMainWorld("ccr", { ipcRenderer.on(IPC_CHANNELS.appProviderDeepLink, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.appProviderDeepLink, handler); }, + onOpenSettingsRequest: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on(IPC_CHANNELS.appOpenSettings, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenSettings, handler); + }, onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status); ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler); diff --git a/src/main/provider-account-service.ts b/src/main/provider-account-service.ts index 53f52b16..61d00632 100644 --- a/src/main/provider-account-service.ts +++ b/src/main/provider-account-service.ts @@ -24,6 +24,7 @@ import type { ProviderAccountTestRequest, ProviderAccountTestResult, ProviderAccountStandardConnectorConfig, + ProviderCredentialConfig, ProviderAccountStatus } from "../shared/app"; @@ -40,6 +41,12 @@ type ConnectorResult = { status?: ProviderAccountStatus; }; +type ProviderAccountTarget = { + account: ProviderAccountConfig; + credential?: ProviderCredentialConfig; + provider: GatewayProviderConfig; +}; + const defaultRefreshIntervalMs = 5 * 60 * 1000; const minRefreshIntervalMs = 30 * 1000; const standardAccountPaths = ["/.well-known/ccr/account", "/v1/account/limits"]; @@ -55,7 +62,11 @@ export async function getProviderAccountSnapshots(providerName?: string): Promis return provider.name.trim().toLowerCase() === normalizedProviderName; }); - const snapshots = await Promise.all(providers.map((provider) => resolveProviderAccountSnapshot(config, provider))); + const snapshots = await Promise.all( + providers.flatMap((provider) => + providerAccountTargets(provider).map((target) => resolveProviderAccountSnapshot(config, target)) + ) + ); return snapshots.filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); } @@ -86,19 +97,22 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR }; } -async function resolveProviderAccountSnapshot(config: AppConfig, provider: GatewayProviderConfig): Promise { - const account = effectiveProviderAccount(provider); - if (!account?.enabled) { - return undefined; - } - +async function resolveProviderAccountSnapshot(config: AppConfig, target: ProviderAccountTarget): Promise { + const { account, credential } = target; + const provider = credential + ? providerWithCredentialApiKey(target.provider, credential, account) + : { ...target.provider, account }; const providerName = provider.name.trim(); if (!providerName) { return undefined; } const refreshIntervalMs = normalizeRefreshInterval(account.refreshIntervalMs); - const cacheKey = `${providerName}:${JSON.stringify(account.connectors ?? [])}`; + const cacheKey = [ + providerName, + credential ? `credential:${credential.id}` : "provider", + JSON.stringify(account.connectors ?? []) + ].join(":"); const cached = cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.snapshot; @@ -106,9 +120,9 @@ async function resolveProviderAccountSnapshot(config: AppConfig, provider: Gatew const now = new Date(); const connectorResults = await Promise.all( - normalizeConnectors(account).map((connector) => resolveConnector(config, provider, connector, now)) + normalizeConnectors(account).map((connector) => resolveConnector(config, provider, connector, now, credential?.id)) ); - const snapshot = mergeConnectorResults(providerName, connectorResults, now, refreshIntervalMs); + const snapshot = mergeConnectorResults(providerName, connectorResults, now, refreshIntervalMs, credential); cache.set(cacheKey, { expiresAt: Date.now() + refreshIntervalMs, snapshot @@ -116,8 +130,45 @@ async function resolveProviderAccountSnapshot(config: AppConfig, provider: Gatew return snapshot; } +function providerAccountTargets(provider: GatewayProviderConfig): ProviderAccountTarget[] { + const providerAccount = effectiveProviderAccount(provider); + const credentials = activeProviderCredentials(provider); + if (credentials.length === 0) { + return providerAccount ? [{ account: providerAccount, provider }] : []; + } + + const credentialTargets = credentials + .map((credential): ProviderAccountTarget | undefined => { + const account = effectiveProviderCredentialAccount(provider, credential, providerAccount); + return account ? { account, credential, provider } : undefined; + }) + .filter((target): target is ProviderAccountTarget => Boolean(target)); + if (credentialTargets.length > 0) { + return credentialTargets; + } + + return providerAccount && providerApiKey(provider) ? [{ account: providerAccount, provider }] : []; +} + function effectiveProviderAccount(provider: GatewayProviderConfig): ProviderAccountConfig | undefined { - const account = provider.account; + return effectiveProviderAccountConfig(provider, provider.account); +} + +function effectiveProviderCredentialAccount( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + inheritedAccount: ProviderAccountConfig | undefined +): ProviderAccountConfig | undefined { + if (credential.account !== undefined) { + return effectiveProviderAccountConfig(provider, credential.account); + } + return inheritedAccount; +} + +function effectiveProviderAccountConfig( + provider: GatewayProviderConfig, + account: ProviderAccountConfig | undefined +): ProviderAccountConfig | undefined { if (!account?.enabled) { return undefined; } @@ -136,6 +187,27 @@ function effectiveProviderAccount(provider: GatewayProviderConfig): ProviderAcco }; } +function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] { + return (provider.credentials ?? []).filter((credential) => + credential.enabled !== false && + Boolean(providerCredentialApiKey(credential)) + ); +} + +function providerWithCredentialApiKey( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + account: ProviderAccountConfig +): GatewayProviderConfig { + return { + ...provider, + account, + api_key: providerCredentialApiKey(credential), + apiKey: undefined, + apikey: undefined + }; +} + function normalizeConnectors(account: ProviderAccountConfig): ProviderAccountConnectorConfig[] { return Array.isArray(account.connectors) ? account.connectors : []; } @@ -158,7 +230,8 @@ async function resolveConnector( config: AppConfig, provider: GatewayProviderConfig, connector: ProviderAccountConnectorConfig, - now: Date + now: Date, + credentialId?: string ): Promise { try { if (connector.type === "standard") { @@ -171,7 +244,7 @@ async function resolveConnector( return await resolvePluginConnector(config, provider, connector, now); } if (connector.type === "local-estimate") { - return await resolveLocalEstimateConnector(provider, connector, now); + return await resolveLocalEstimateConnector(provider, connector, now, credentialId); } return connectorError("unsupported", `Unsupported account connector type: ${readConnectorType(connector)}`, connectorId(connector)); } catch (error) { @@ -266,10 +339,11 @@ async function resolvePluginConnector( async function resolveLocalEstimateConnector( provider: GatewayProviderConfig, connector: ProviderAccountLocalEstimateConnectorConfig, - now: Date + now: Date, + credentialId?: string ): Promise { const meters = await Promise.all( - connector.windows.map((window) => localEstimateMeter(provider, window, now)) + connector.windows.map((window) => localEstimateMeter(provider, window, now, credentialId)) ); return { @@ -283,7 +357,8 @@ async function resolveLocalEstimateConnector( async function localEstimateMeter( provider: GatewayProviderConfig, window: ProviderAccountLocalWindowConfig, - now: Date + now: Date, + credentialId?: string ): Promise { const limit = normalizeNumber(window.limit); if (!window.id || !window.label || !limit || limit <= 0) { @@ -291,7 +366,10 @@ async function localEstimateMeter( } const since = localEstimateWindowStart(window.window, now); - const totals = await getUsageTotalsSince(since, { provider: provider.name }); + const totals = await getUsageTotalsSince(since, { + ...(credentialId ? { credential: credentialId } : {}), + provider: provider.name + }); const used = window.unit === "tokens" ? totals.totalTokens : window.unit === "requests" @@ -316,7 +394,8 @@ function mergeConnectorResults( provider: string, results: ConnectorResult[], now: Date, - refreshIntervalMs: number + refreshIntervalMs: number, + credential?: ProviderCredentialConfig ): ProviderAccountSnapshot { const errors = results.flatMap((result) => result.errors); const metersById = new Map(); @@ -342,6 +421,8 @@ function mergeConnectorResults( const message = results.find((result) => result.message)?.message ?? (errors.length > 0 && meters.length === 0 ? errors[0]?.message : undefined); return { + credentialId: credential?.id, + credentialLabel: credential?.name ?? credential?.label ?? credential?.id, errors: errors.length > 0 ? errors : undefined, message, meters, @@ -580,6 +661,10 @@ function providerApiKey(provider: GatewayProviderConfig): string { return provider.api_key || provider.apiKey || provider.apikey || ""; } +function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return credential.api_key || credential.apiKey || credential.apikey || ""; +} + function localEstimateWindowStart(window: string, now: Date): Date { const start = new Date(now); if (window === "5h") { diff --git a/src/main/request-log-store.ts b/src/main/request-log-store.ts index be947eff..747cabaf 100644 --- a/src/main/request-log-store.ts +++ b/src/main/request-log-store.ts @@ -1,14 +1,15 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import initSqlJs from "sql.js"; import { REQUEST_LOGS_DB_FILE } from "./constants"; import { estimateUsageCostUsd } from "./model-pricing-service"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; import { normalizeUsageInputTokens } from "./usage-normalization"; import type { AgentAnalysisAgentRow, AgentAnalysisFilter, AgentAnalysisRequestRow, + AgentAnalysisSessionDetail, + AgentAnalysisSessionModelRow, AgentAnalysisSessionRow, AgentAnalysisSnapshot, AgentAnalysisSubagentRow, @@ -28,12 +29,8 @@ import type { UsageStatsRange } from "../shared/app"; -type SqlDatabase = InstanceType>["Database"]>; -type SqlValue = number | string | Uint8Array | null; -type QueryExecResult = { - columns: string[]; - values: SqlValue[][]; -}; +type SqlDatabase = BetterSqliteDatabase; +type SqlValue = bigint | Buffer | number | string | null; type HeaderRecord = Record; @@ -96,6 +93,9 @@ type StoredRequestLogEntry = { completedAt: string; costUsd: number | undefined; createdAt: string; + credentialChain: string[]; + credentialId: string; + credentialSaturated: boolean; durationMs: number; error: string; id: number; @@ -133,9 +133,9 @@ type AgentLogDetails = { userAgent?: string; }; -const requireFromHere = createRequire(__filename); const maxBodyBytes = 2 * 1024 * 1024; const maxAgentAnalysisRows = 5000; +const maxAgentSessionDetailRequests = 250; const emptyAgentAnalysisTotals: AgentAnalysisTotals = { avgDurationMs: 0, cacheRatio: 0, @@ -202,6 +202,7 @@ class RequestLogStore { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; const model = normalizeLabel(usage.model ?? route.model ?? requestModel ?? input.fallbackModel, "unknown"); const providerName = normalizeLabel(provider, "unknown"); + const credentialInfo = readCredentialLogInfo(responseHeaders, requestHeaders); const cost = await estimateUsageCostUsd({ cacheReadTokens, cacheWriteTokens, @@ -238,6 +239,9 @@ class RequestLogStore { path, url, provider, + credential_id, + credential_chain, + credential_saturated, model, is_stream, status_code, @@ -262,48 +266,46 @@ class RequestLogStore { response_body_size_bytes, response_body_truncated, error - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - try { - statement.run([ - input.startedAt, - input.completedAt ?? new Date().toISOString(), - input.requestId ?? "", - normalizeLabel(input.client, "unknown"), - input.method, - input.path, - input.url, - providerName, - model, - isStream ? 1 : 0, - normalizeCount(input.statusCode), - isSuccessStatus(input.statusCode, input.error) ? 1 : 0, - normalizeCount(input.durationMs), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - cost?.amountUsd ?? null, - JSON.stringify(requestHeaders), - JSON.stringify(responseHeaders), - requestBody.text, - requestBody.encoding, - requestBody.contentType ?? "", - requestBody.sizeBytes, - requestBody.truncated ? 1 : 0, - responseBody.text, - responseBody.encoding, - responseBody.contentType ?? "", - responseBody.sizeBytes, - responseBody.truncated ? 1 : 0, - input.error ?? "" - ]); - this.persist(); - } finally { - statement.free(); - } + statement.run( + input.startedAt, + input.completedAt ?? new Date().toISOString(), + input.requestId ?? "", + normalizeLabel(input.client, "unknown"), + input.method, + input.path, + input.url, + providerName, + credentialInfo.id, + credentialInfo.chain.join(","), + credentialInfo.saturated ? 1 : 0, + model, + isStream ? 1 : 0, + normalizeCount(input.statusCode), + isSuccessStatus(input.statusCode, input.error) ? 1 : 0, + normalizeCount(input.durationMs), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + cost?.amountUsd ?? null, + JSON.stringify(requestHeaders), + JSON.stringify(responseHeaders), + requestBody.text, + requestBody.encoding, + requestBody.contentType ?? "", + requestBody.sizeBytes, + requestBody.truncated ? 1 : 0, + responseBody.text, + responseBody.encoding, + responseBody.contentType ?? "", + responseBody.sizeBytes, + responseBody.truncated ? 1 : 0, + input.error ?? "" + ); } async updateFromRawTrace(input: RequestLogRawTraceUpdateInput): Promise { @@ -352,6 +354,12 @@ class RequestLogStore { if (responseHeaders) { pushValue("response_headers", JSON.stringify(responseHeaders)); } + if (hasCredentialLogHeaders(responseHeaders ?? {}) || hasCredentialLogHeaders(mergedRequestHeaders ?? {})) { + const credentialInfo = readCredentialLogInfo(responseHeaders ?? {}, mergedRequestHeaders ?? {}); + pushValue("credential_id", credentialInfo.id); + pushValue("credential_chain", credentialInfo.chain.join(",")); + pushValue("credential_saturated", credentialInfo.saturated ? 1 : 0); + } const hasStreamSignal = input.isStream !== undefined || input.path !== undefined || @@ -392,8 +400,7 @@ class RequestLogStore { return true; } - database.run(`UPDATE request_logs SET ${sets.join(", ")} WHERE request_id = ?`, [...params, requestId]); - this.persist(); + database.prepare(`UPDATE request_logs SET ${sets.join(", ")} WHERE request_id = ?`).run(...params, requestId); return true; } @@ -403,12 +410,12 @@ class RequestLogStore { const pageSize = clampInteger(filter.pageSize, 1, 100, 25); const page = clampInteger(filter.page, 1, Number.MAX_SAFE_INTEGER, 1); const query = buildLogWhereClause(filter); - const count = firstNumber(database.exec(`SELECT COUNT(*) AS total FROM request_logs ${query.where}`, query.params)[0], "total"); + const count = firstNumber(queryRows(database, `SELECT COUNT(*) AS total FROM request_logs ${query.where}`, query.params), "total"); const totalPages = Math.max(1, Math.ceil(count / pageSize)); const normalizedPage = Math.min(page, totalPages); const offset = (normalizedPage - 1) * pageSize; - const rows = readRows( - database.exec( + const rows = queryRows( + database, ` SELECT rowid AS id, @@ -420,6 +427,9 @@ class RequestLogStore { path, url, provider, + credential_id, + credential_chain, + credential_saturated, model, is_stream, status_code, @@ -450,7 +460,6 @@ class RequestLogStore { LIMIT ? OFFSET ? `, [...query.params, pageSize, offset] - )[0] ).map(toRequestLogEntry); return { @@ -470,8 +479,8 @@ class RequestLogStore { const now = new Date(); const range = normalizeAgentAnalysisRange(filter.range); const since = getAgentAnalysisSince(range, now); - const rows = readRows( - database.exec( + const rows = queryRows( + database, ` SELECT rowid AS id, @@ -483,6 +492,9 @@ class RequestLogStore { path, url, provider, + credential_id, + credential_chain, + credential_saturated, model, is_stream, status_code, @@ -515,7 +527,6 @@ class RequestLogStore { LIMIT ? `, ["%/count_tokens%", since.toISOString(), maxAgentAnalysisRows] - )[0] ) .map(toRequestLogEntry) .reverse(); @@ -525,6 +536,7 @@ class RequestLogStore { .map(toAnalyzedAgentRequest) .filter((request) => requestedAgent === "all" || request.agent === requestedAgent); const requests = applyRequestConcurrency(analyzed); + const selectedSession = buildSelectedAgentSessionDetail(requests, filter); return { agents: buildAgentRows(requests), @@ -537,6 +549,7 @@ class RequestLogStore { recentRequests: requests.slice(-50).reverse().map(stripAnalysisInternals), routes: buildAgentRouteRows(requests), scannedRequestCount: rows.length, + ...(selectedSession ? { selectedSession } : {}), sessions: buildAgentSessionRows(requests), subagents: buildAgentSubagentRows(requests), tools: buildAgentToolRows(requests), @@ -547,6 +560,7 @@ class RequestLogStore { private async getFilterOptions(): Promise { const database = await this.getDatabase(); return { + credentials: readDistinctValues(database, "credential_id"), models: readDistinctValues(database, "model"), providers: readDistinctValues(database, "provider") }; @@ -563,13 +577,10 @@ class RequestLogStore { private async open(): Promise { mkdirSync(dirname(this.dbFile), { recursive: true }); - const wasmFile = requireFromHere.resolve("sql.js/dist/sql-wasm.wasm"); - const SQL = await initSqlJs({ locateFile: () => wasmFile }); - const database = existsSync(this.dbFile) - ? new SQL.Database(readFileSync(this.dbFile)) - : new SQL.Database(); + const database = createBetterSqliteDatabase(this.dbFile); + configureSqliteDatabase(database); - database.run(` + database.exec(` CREATE TABLE IF NOT EXISTS request_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_usage_id INTEGER, @@ -581,6 +592,9 @@ class RequestLogStore { path TEXT NOT NULL, url TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT 'unknown', + credential_id TEXT NOT NULL DEFAULT '', + credential_chain TEXT NOT NULL DEFAULT '', + credential_saturated INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL DEFAULT 'unknown', is_stream INTEGER NOT NULL DEFAULT 0, status_code INTEGER NOT NULL DEFAULT 0, @@ -612,7 +626,6 @@ class RequestLogStore { this.database = database; this.pruneOldRequestLogs(database); - this.persist(); return database; } @@ -625,10 +638,11 @@ class RequestLogStore { const cutoff = floorDay(now).toISOString(); const staleCount = firstNumber( - database.exec( + queryRows( + database, "SELECT COUNT(*) AS total FROM request_logs WHERE source_usage_id IS NULL AND created_at < ?", [cutoff] - )[0], + ), "total" ); @@ -637,19 +651,10 @@ class RequestLogStore { return; } - database.run( + database.prepare( "DELETE FROM request_logs WHERE source_usage_id IS NULL AND created_at < ?", - [cutoff] - ); + ).run(cutoff); this.lastRetentionCleanupDay = dayKey; - this.persist(); - } - - private persist(): void { - if (!this.database) { - return; - } - writeFileSync(this.dbFile, Buffer.from(this.database.export())); } } @@ -1216,28 +1221,80 @@ function buildAgentErrorRows(requests: AnalyzedAgentRequest[]): AgentObservabili function buildAgentSessionRows(requests: AnalyzedAgentRequest[]): AgentAnalysisSessionRow[] { const grouped = groupBy(requests, (request) => `${request.agent}:${request.sessionId}`); + return Array.from(grouped.values()) + .map(buildAgentSessionRow) + .sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)) + .slice(0, 100); +} + +function buildAgentSessionRow(items: AnalyzedAgentRequest[]): AgentAnalysisSessionRow { + const first = items[0]; + const last = items.at(-1) ?? first; + const totals = buildAgentAnalysisTotals(items); + return { + ...totals, + agent: first.agent, + client: first.client, + durationMs: Math.max(0, last.endedAtMs - first.startedAtMs), + id: first.sessionId, + lastRequestId: last.requestId, + lastSeenAt: last.completedAt || last.createdAt, + models: uniqueNonEmpty(items.map((item) => item.model)).slice(0, 8), + providers: uniqueNonEmpty(items.map((item) => item.provider)).slice(0, 8), + startedAt: first.createdAt, + topTools: topToolCounts(items, 5), + userAgent: first.userAgent + }; +} + +function buildSelectedAgentSessionDetail( + requests: AnalyzedAgentRequest[], + filter: AgentAnalysisFilter +): AgentAnalysisSessionDetail | undefined { + const sessionId = normalizeFilterValue(filter.sessionId); + if (!sessionId) { + return undefined; + } + + const sessionAgent = normalizeSessionAgentFilter(filter.sessionAgent); + const sessionRequests = requests.filter((request) => + request.sessionId === sessionId && + (!sessionAgent || request.agent === sessionAgent) + ); + if (sessionRequests.length === 0) { + return undefined; + } + + return { + endpoints: buildAgentEndpointRows(sessionRequests), + errors: buildAgentErrorRows(sessionRequests), + models: buildAgentSessionModelRows(sessionRequests), + requests: sessionRequests.slice(-maxAgentSessionDetailRequests).reverse().map(stripAnalysisInternals), + routes: buildAgentRouteRows(sessionRequests), + session: buildAgentSessionRow(sessionRequests), + statusCodes: buildStatusCodeCounts(sessionRequests), + subagents: buildAgentSubagentRows(sessionRequests), + tools: buildAgentToolRows(sessionRequests), + totals: buildAgentAnalysisTotals(sessionRequests) + }; +} + +function buildAgentSessionModelRows(requests: AnalyzedAgentRequest[]): AgentAnalysisSessionModelRow[] { + const grouped = groupBy(requests, (request) => `${request.provider}:${request.model}`); return Array.from(grouped.values()) .map((items) => { const first = items[0]; const last = items.at(-1) ?? first; - const totals = buildAgentAnalysisTotals(items); return { - ...totals, - agent: first.agent, - client: first.client, - durationMs: Math.max(0, last.endedAtMs - first.startedAtMs), - id: first.sessionId, - lastRequestId: last.requestId, + ...buildAgentAnalysisTotals(items), + key: `${first.provider}:${first.model}`, lastSeenAt: last.completedAt || last.createdAt, - models: uniqueNonEmpty(items.map((item) => item.model)).slice(0, 8), - providers: uniqueNonEmpty(items.map((item) => item.provider)).slice(0, 8), - startedAt: first.createdAt, - topTools: topToolCounts(items, 5), - userAgent: first.userAgent + model: first.model, + provider: first.provider }; }) - .sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)) - .slice(0, 100); + .sort((a, b) => b.totalTokens - a.totalTokens || b.requestCount - a.requestCount || a.model.localeCompare(b.model)) + .slice(0, 50); } function buildAgentToolRows(requests: AnalyzedAgentRequest[]): AgentAnalysisToolRow[] { @@ -1527,6 +1584,10 @@ function normalizeAgentFilter(value: AgentAnalysisFilter["agent"] | undefined): return value === "claude-code" || value === "codex" || value === "claude-design" || value === "unknown" ? value : "all"; } +function normalizeSessionAgentFilter(value: AgentAnalysisFilter["sessionAgent"] | undefined): AgentKind | undefined { + return value === "claude-code" || value === "codex" || value === "claude-design" || value === "unknown" ? value : undefined; +} + function agentDisplayName(agent: AgentKind): string { if (agent === "claude-code") { return "Claude Code"; @@ -1573,6 +1634,50 @@ function readHeaderValue(headers: Record, name: strin return normalizeFilterValue(value); } +function hasCredentialLogHeaders(headers: Record): boolean { + return Boolean( + readHeaderValue(headers, "x-ccr-provider-credential-id") || + readHeaderValue(headers, "x-ccr-provider-credential-chain") || + readHeaderValue(headers, "x-ccr-provider-credential-saturated") + ); +} + +function readCredentialLogInfo( + responseHeaders: Record, + requestHeaders: Record +): { chain: string[]; id: string; saturated: boolean } { + const responseChain = parseCredentialChain(readHeaderValue(responseHeaders, "x-ccr-provider-credential-chain")); + const requestChain = parseCredentialChain(readHeaderValue(requestHeaders, "x-ccr-provider-credential-chain")); + const id = normalizeLabel( + readHeaderValue(responseHeaders, "x-ccr-provider-credential-id") ?? + readHeaderValue(requestHeaders, "x-ccr-provider-credential-id") ?? + responseChain[0] ?? + requestChain[0], + "" + ); + const chain = responseChain.length > 0 + ? responseChain + : requestChain.length > 0 + ? requestChain + : id + ? [id] + : []; + const saturated = readHeaderFlag( + readHeaderValue(responseHeaders, "x-ccr-provider-credential-saturated") ?? + readHeaderValue(requestHeaders, "x-ccr-provider-credential-saturated") + ); + return { chain, id, saturated }; +} + +function parseCredentialChain(value: string | undefined): string[] { + return uniqueNonEmpty((value ?? "").split(",")); +} + +function readHeaderFlag(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + function stringifyForSearch(value: unknown): string { if (typeof value === "string") { return value; @@ -1591,13 +1696,13 @@ function parseDateMs(value: string): number { function ensureRequestLogSchema(database: SqlDatabase): void { const columns = new Set( - readRows(database.exec("PRAGMA table_info(request_logs)")[0]) + queryRows(database, "PRAGMA table_info(request_logs)") .map((row) => String(row.name ?? "")) .filter(Boolean) ); const addColumn = (name: string, definition: string) => { if (!columns.has(name)) { - database.run(`ALTER TABLE request_logs ADD COLUMN ${name} ${definition}`); + database.exec(`ALTER TABLE request_logs ADD COLUMN ${name} ${definition}`); columns.add(name); } }; @@ -1611,6 +1716,9 @@ function ensureRequestLogSchema(database: SqlDatabase): void { addColumn("path", "TEXT NOT NULL DEFAULT ''"); addColumn("url", "TEXT NOT NULL DEFAULT ''"); addColumn("provider", "TEXT NOT NULL DEFAULT 'unknown'"); + addColumn("credential_id", "TEXT NOT NULL DEFAULT ''"); + addColumn("credential_chain", "TEXT NOT NULL DEFAULT ''"); + addColumn("credential_saturated", "INTEGER NOT NULL DEFAULT 0"); addColumn("model", "TEXT NOT NULL DEFAULT 'unknown'"); addColumn("is_stream", "INTEGER NOT NULL DEFAULT 0"); addColumn("status_code", "INTEGER NOT NULL DEFAULT 0"); @@ -1636,16 +1744,17 @@ function ensureRequestLogSchema(database: SqlDatabase): void { addColumn("response_body_truncated", "INTEGER NOT NULL DEFAULT 0"); addColumn("error", "TEXT NOT NULL DEFAULT ''"); - database.run("CREATE INDEX IF NOT EXISTS request_logs_created_at_idx ON request_logs(created_at)"); - database.run("CREATE INDEX IF NOT EXISTS request_logs_model_idx ON request_logs(model)"); - database.run("CREATE INDEX IF NOT EXISTS request_logs_provider_idx ON request_logs(provider)"); - database.run("CREATE INDEX IF NOT EXISTS request_logs_source_usage_id_idx ON request_logs(source_usage_id)"); - database.run("CREATE INDEX IF NOT EXISTS request_logs_status_idx ON request_logs(ok, status_code)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_created_at_idx ON request_logs(created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_credential_id_idx ON request_logs(credential_id)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_model_idx ON request_logs(model)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_idx ON request_logs(provider)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_source_usage_id_idx ON request_logs(source_usage_id)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_idx ON request_logs(ok, status_code)"); } function backfillRequestLogStreamFlags(database: SqlDatabase): void { - const rows = readRows( - database.exec( + const rows = queryRows( + database, ` SELECT rowid AS id, @@ -1667,32 +1776,27 @@ function backfillRequestLogStreamFlags(database: SqlDatabase): void { response_body_content_type LIKE '%event-stream%' ) ` - )[0] ); if (rows.length === 0) { return; } const statement = database.prepare("UPDATE request_logs SET is_stream = 1 WHERE rowid = ?"); - try { - for (const row of rows) { - const requestBodyText = String(row.request_body_encoding ?? "utf8") === "utf8" - ? String(row.request_body_text ?? "") - : undefined; - const isStream = inferRequestLogIsStream({ - path: String(row.path ?? ""), - requestBodyText, - requestHeaders: parseHeaderJson(row.request_headers), - responseBodyContentType: String(row.response_body_content_type ?? ""), - responseHeaders: parseHeaderJson(row.response_headers), - url: String(row.url ?? "") - }); - if (isStream) { - statement.run([normalizeCount(row.id)]); - } + for (const row of rows) { + const requestBodyText = String(row.request_body_encoding ?? "utf8") === "utf8" + ? String(row.request_body_text ?? "") + : undefined; + const isStream = inferRequestLogIsStream({ + path: String(row.path ?? ""), + requestBodyText, + requestHeaders: parseHeaderJson(row.request_headers), + responseBodyContentType: String(row.response_body_content_type ?? ""), + responseHeaders: parseHeaderJson(row.response_headers), + url: String(row.url ?? "") + }); + if (isStream) { + statement.run(normalizeCount(row.id)); } - } finally { - statement.free(); } } @@ -1758,6 +1862,7 @@ function buildLogWhereClause(filter: RequestLogListFilter): { params: SqlValue[] const where: string[] = ["source_usage_id IS NULL", "path NOT LIKE ?"]; const params: SqlValue[] = ["%/count_tokens%"]; const status = normalizeStatusFilter(filter.status); + const credential = normalizeFilterValue(filter.credential); const model = normalizeFilterValue(filter.model); const provider = normalizeFilterValue(filter.provider); const query = normalizeFilterValue(filter.query); @@ -1775,6 +1880,10 @@ function buildLogWhereClause(filter: RequestLogListFilter): { params: SqlValue[] where.push("provider = ?"); params.push(provider); } + if (credential) { + where.push("credential_id = ?"); + params.push(credential); + } if (query) { const like = `%${query}%`; where.push(`( @@ -1784,12 +1893,14 @@ function buildLogWhereClause(filter: RequestLogListFilter): { params: SqlValue[] path LIKE ? OR url LIKE ? OR provider LIKE ? OR + credential_id LIKE ? OR + credential_chain LIKE ? OR model LIKE ? OR request_body_text LIKE ? OR response_body_text LIKE ? OR error LIKE ? )`); - params.push(like, like, like, like, like, like, like, like, like, like); + params.push(like, like, like, like, like, like, like, like, like, like, like, like); } return { @@ -1798,17 +1909,18 @@ function buildLogWhereClause(filter: RequestLogListFilter): { params: SqlValue[] }; } -function readRows(result: QueryExecResult | undefined): Record[] { - if (!result) { - return []; - } - return result.values.map((values) => - Object.fromEntries(result.columns.map((column, index) => [column, values[index] ?? null])) - ); +function configureSqliteDatabase(database: SqlDatabase): void { + database.pragma("journal_mode = WAL"); + database.pragma("synchronous = NORMAL"); + database.pragma("busy_timeout = 5000"); } -function firstNumber(result: QueryExecResult | undefined, column: string): number { - const row = readRows(result)[0]; +function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Record[] { + return database.prepare(sql).all(...params) as Record[]; +} + +function firstNumber(rows: Record[], column: string): number { + const row = rows[0]; return normalizeCount(row?.[column]); } @@ -1833,6 +1945,9 @@ function toRequestLogEntry(row: Record): StoredRequestLogEntry completedAt: String(row.completed_at ?? ""), costUsd, createdAt: String(row.created_at ?? ""), + credentialChain: parseCredentialChain(String(row.credential_chain ?? "")), + credentialId: normalizeLabel(String(row.credential_id ?? ""), ""), + credentialSaturated: normalizeCount(row.credential_saturated) === 1, durationMs: normalizeCount(row.duration_ms), error: String(row.error ?? ""), id: normalizeCount(row.id), @@ -1905,9 +2020,9 @@ function parseHeaderJson(value: SqlValue): Record { } } -function readDistinctValues(database: SqlDatabase, column: "model" | "provider"): string[] { - return readRows( - database.exec( +function readDistinctValues(database: SqlDatabase, column: "credential_id" | "model" | "provider"): string[] { + return queryRows( + database, ` SELECT DISTINCT ${column} AS value FROM request_logs @@ -1916,7 +2031,6 @@ function readDistinctValues(database: SqlDatabase, column: "model" | "provider") LIMIT 100 `, ["%/count_tokens%"] - )[0] ) .map((row) => String(row.value ?? "")) .filter(Boolean); @@ -1982,15 +2096,13 @@ function isTextLikeContentType(contentType: string | undefined): boolean { function hasRequestLogWithRequestId(database: SqlDatabase, requestId: string): boolean { return firstNumber( - database.exec("SELECT COUNT(*) AS total FROM request_logs WHERE request_id = ?", [requestId])[0], + queryRows(database, "SELECT COUNT(*) AS total FROM request_logs WHERE request_id = ?", [requestId]), "total" ) > 0; } function readRequestHeadersForRequestId(database: SqlDatabase, requestId: string): Record { - const row = readRows( - database.exec("SELECT request_headers FROM request_logs WHERE request_id = ? LIMIT 1", [requestId])[0] - )[0]; + const row = queryRows(database, "SELECT request_headers FROM request_logs WHERE request_id = ? LIMIT 1", [requestId])[0]; return row ? parseHeaderJson(row.request_headers) : {}; } diff --git a/src/main/sqlite-native.ts b/src/main/sqlite-native.ts new file mode 100644 index 00000000..3cb7698a --- /dev/null +++ b/src/main/sqlite-native.ts @@ -0,0 +1,31 @@ +import { createRequire } from "node:module"; +import DatabaseConstructor, { type Database as BetterSqliteDatabase } from "better-sqlite3"; + +export type { + Database as BetterSqliteDatabase, + Statement as BetterSqliteStatement +} from "better-sqlite3"; + +const requireFromHere = createRequire(__filename); +let resolvedNativeBinding: string | undefined; +let nativeBindingResolved = false; + +export function createBetterSqliteDatabase(filename: string): BetterSqliteDatabase { + const nativeBinding = resolveBetterSqliteNativeBinding(); + return nativeBinding + ? new DatabaseConstructor(filename, { nativeBinding }) + : new DatabaseConstructor(filename); +} + +function resolveBetterSqliteNativeBinding(): string | undefined { + if (nativeBindingResolved) { + return resolvedNativeBinding; + } + nativeBindingResolved = true; + try { + resolvedNativeBinding = requireFromHere.resolve("better-sqlite3/build/Release/better_sqlite3.node"); + } catch { + resolvedNativeBinding = undefined; + } + return resolvedNativeBinding; +} diff --git a/src/main/usage-store.ts b/src/main/usage-store.ts index 514837f3..da17971f 100644 --- a/src/main/usage-store.ts +++ b/src/main/usage-store.ts @@ -1,10 +1,9 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { EventEmitter } from "node:events"; -import { createRequire } from "node:module"; import { dirname } from "node:path"; -import initSqlJs from "sql.js"; import { USAGE_DB_FILE } from "./constants"; import { estimateUsageCostUsd } from "./model-pricing-service"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; import { normalizeUsageInputTokens } from "./usage-normalization"; import type { GatewayProviderProtocol, @@ -16,12 +15,8 @@ import type { UsageTotals } from "../shared/app"; -type SqlDatabase = InstanceType>["Database"]>; -type SqlValue = number | string | Uint8Array | null; -type QueryExecResult = { - columns: string[]; - values: SqlValue[][]; -}; +type SqlDatabase = BetterSqliteDatabase; +type SqlValue = bigint | Buffer | number | string | null; type UsageNumbers = { cacheReadTokens?: number; @@ -35,6 +30,7 @@ type UsageNumbers = { type UsageEventInput = { client?: string; createdAt?: string; + credentialId?: string; durationMs: number; method: string; model?: string; @@ -69,6 +65,7 @@ type StoredUsageEvent = { costSource: string; costUsd: number; createdAt: string; + credentialId: string; durationMs: number; id: number; inputTokens: number; @@ -86,7 +83,6 @@ type UsageSnapshot = UsageNumbers & { model?: string; }; -const requireFromHere = createRequire(__filename); const usageEvents = new EventEmitter(); const emptyTotals: UsageTotals = { avgDurationMs: 0, @@ -119,6 +115,7 @@ class UsageStore { const route = splitRouteSelector(event.model); const model = normalizeLabel(route.model ?? event.model, "unknown"); const provider = normalizeLabel(event.provider ?? route.provider, "unknown"); + const credentialId = normalizeLabel(event.credentialId, ""); const cost = await estimateUsageCostUsd({ cacheReadTokens, cacheWriteTokens, @@ -137,6 +134,7 @@ class UsageStore { path, model, provider, + credential_id, status_code, duration_ms, input_tokens, @@ -146,33 +144,29 @@ class UsageStore { total_tokens, cost_usd, cost_source - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - try { - statement.run([ - event.createdAt ?? new Date().toISOString(), - event.requestId ?? "", - normalizeLabel(event.client, "unknown"), - event.method, - event.path, - model, - provider, - normalizeCount(event.statusCode), - normalizeCount(event.durationMs), - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - totalTokens, - cost?.amountUsd ?? null, - cost?.source ?? "" - ]); - this.persist(); - usageEvents.emit("recorded"); - } finally { - statement.free(); - } + statement.run( + event.createdAt ?? new Date().toISOString(), + event.requestId ?? "", + normalizeLabel(event.client, "unknown"), + event.method, + event.path, + model, + provider, + credentialId, + normalizeCount(event.statusCode), + normalizeCount(event.durationMs), + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens, + cost?.amountUsd ?? null, + cost?.source ?? "" + ); + usageEvents.emit("recorded"); } async getStats(range: UsageStatsRange = "7d", filter: UsageStatsFilter = {}): Promise { @@ -180,9 +174,7 @@ class UsageStore { const now = new Date(); const since = getRangeSince(range, now); const query = buildUsageStatsQuery(since, filter); - const events = readRows( - database.exec(query.sql, query.params)[0] - ).map(toStoredUsageEvent); + const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); return { clientModels: buildClientModelRows(events), @@ -199,9 +191,7 @@ class UsageStore { async getTotalsSince(since: Date, filter: UsageStatsFilter = {}, options: UsageStatsQueryOptions = {}): Promise { const database = await this.getDatabase(); const query = buildUsageStatsQuery(since, filter, options); - const events = readRows( - database.exec(query.sql, query.params)[0] - ).map(toStoredUsageEvent); + const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); return buildTotals(events); } @@ -217,13 +207,10 @@ class UsageStore { private async open(): Promise { mkdirSync(dirname(this.dbFile), { recursive: true }); - const wasmFile = requireFromHere.resolve("sql.js/dist/sql-wasm.wasm"); - const SQL = await initSqlJs({ locateFile: () => wasmFile }); - const database = existsSync(this.dbFile) - ? new SQL.Database(readFileSync(this.dbFile)) - : new SQL.Database(); + const database = createBetterSqliteDatabase(this.dbFile); + configureSqliteDatabase(database); - database.run(` + database.exec(` CREATE TABLE IF NOT EXISTS usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, @@ -233,6 +220,7 @@ class UsageStore { path TEXT NOT NULL, model TEXT NOT NULL DEFAULT 'unknown', provider TEXT NOT NULL DEFAULT 'unknown', + credential_id TEXT NOT NULL DEFAULT '', status_code INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -250,16 +238,8 @@ class UsageStore { ensureUsageSchema(database); this.database = database; - this.persist(); return database; } - - private persist(): void { - if (!this.database) { - return; - } - writeFileSync(this.dbFile, Buffer.from(this.database.export())); - } } export const usageStore = new UsageStore(USAGE_DB_FILE); @@ -273,22 +253,29 @@ export function onUsageRecorded(listener: () => void): () => void { function ensureUsageSchema(database: SqlDatabase): void { const columns = new Set( - readRows(database.exec("PRAGMA table_info(usage_events)")[0]) + queryRows(database, "PRAGMA table_info(usage_events)") .map((row) => String(row.name ?? "")) .filter(Boolean) ); if (!columns.has("client")) { - database.run("ALTER TABLE usage_events ADD COLUMN client TEXT NOT NULL DEFAULT 'unknown'"); + database.exec("ALTER TABLE usage_events ADD COLUMN client TEXT NOT NULL DEFAULT 'unknown'"); } if (!columns.has("cost_usd")) { - database.run("ALTER TABLE usage_events ADD COLUMN cost_usd REAL"); + database.exec("ALTER TABLE usage_events ADD COLUMN cost_usd REAL"); } if (!columns.has("cost_source")) { - database.run("ALTER TABLE usage_events ADD COLUMN cost_source TEXT NOT NULL DEFAULT ''"); + database.exec("ALTER TABLE usage_events ADD COLUMN cost_source TEXT NOT NULL DEFAULT ''"); + } + if (!columns.has("credential_id")) { + database.exec("ALTER TABLE usage_events ADD COLUMN credential_id TEXT NOT NULL DEFAULT ''"); } - database.run("CREATE INDEX IF NOT EXISTS usage_events_client_idx ON usage_events(client)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_client_idx ON usage_events(client)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_created_at_idx ON usage_events(created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_id_idx ON usage_events(credential_id)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events(model)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_path_idx ON usage_events(path)"); } export async function getUsageStats(range?: UsageStatsRange, filter?: UsageStatsFilter): Promise { @@ -340,6 +327,7 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi path: input.path, client: input.client, provider, + credentialId: readCredentialId(input.responseHeaders), requestId: input.requestId, statusCode: input.statusCode, usage @@ -356,6 +344,7 @@ function buildUsageStatsQuery( ): { params: SqlValue[]; sql: string } { const where = ["created_at >= ?"]; const params: SqlValue[] = [since.toISOString()]; + const credential = normalizeFilterValue(filter.credential); const provider = normalizeFilterValue(filter.provider); const model = normalizeFilterValue(filter.model); @@ -370,6 +359,10 @@ function buildUsageStatsQuery( where.push("model = ?"); params.push(model); } + if (credential) { + where.push("credential_id = ?"); + params.push(credential); + } return { params, @@ -383,6 +376,7 @@ function buildUsageStatsQuery( path, model, provider, + credential_id, status_code, duration_ms, input_tokens, @@ -399,13 +393,14 @@ function buildUsageStatsQuery( }; } -function readRows(result: QueryExecResult | undefined): Record[] { - if (!result) { - return []; - } - return result.values.map((values) => - Object.fromEntries(result.columns.map((column, index) => [column, values[index] ?? null])) - ); +function configureSqliteDatabase(database: SqlDatabase): void { + database.pragma("journal_mode = WAL"); + database.pragma("synchronous = NORMAL"); + database.pragma("busy_timeout = 5000"); +} + +function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): Record[] { + return database.prepare(sql).all(...params) as Record[]; } function toStoredUsageEvent(row: Record): StoredUsageEvent { @@ -416,6 +411,7 @@ function toStoredUsageEvent(row: Record): StoredUsageEvent { costSource: String(row.cost_source ?? ""), costUsd: normalizeCost(row.cost_usd), createdAt: String(row.created_at ?? ""), + credentialId: normalizeLabel(String(row.credential_id ?? ""), ""), durationMs: normalizeCount(row.duration_ms), id: normalizeCount(row.id), inputTokens: normalizeCount(row.input_tokens), @@ -495,6 +491,7 @@ function buildModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { return { ...buildTotals(groupedEvents), caption: latest?.provider || "unknown", + credentialId: latest?.credentialId || undefined, key, label: latest?.model || "unknown", maxShare: 0, @@ -511,7 +508,7 @@ function buildModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { function buildClientModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { const grouped = new Map(); for (const event of events) { - const key = `${event.client}::${event.provider}::${event.model}`; + const key = `${event.client}::${event.provider}::${event.credentialId}::${event.model}`; const bucket = grouped.get(key) ?? []; bucket.push(event); grouped.set(key, bucket); @@ -522,10 +519,12 @@ function buildClientModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] const latest = groupedEvents.at(-1); const model = latest?.model || "unknown"; const provider = latest?.provider || "unknown"; + const credentialId = latest?.credentialId || ""; return { ...buildTotals(groupedEvents), - caption: `${provider} / ${model}`, + caption: credentialId ? `${provider} / ${credentialId} / ${model}` : `${provider} / ${model}`, client: latest?.client, + credentialId: credentialId || undefined, key, label: latest?.client || "unknown", maxShare: 0, @@ -542,7 +541,7 @@ function buildClientModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] function buildProviderModelRows(events: StoredUsageEvent[]): UsageComparisonRow[] { const grouped = new Map(); for (const event of events) { - const key = `${event.provider}::${event.model}`; + const key = `${event.provider}::${event.credentialId}::${event.model}`; const bucket = grouped.get(key) ?? []; bucket.push(event); grouped.set(key, bucket); @@ -553,9 +552,11 @@ function buildProviderModelRows(events: StoredUsageEvent[]): UsageComparisonRow[ const latest = groupedEvents.at(-1); const model = latest?.model || "unknown"; const provider = latest?.provider || "unknown"; + const credentialId = latest?.credentialId || ""; return { ...buildTotals(groupedEvents), - caption: model, + caption: credentialId ? `${credentialId} / ${model}` : model, + credentialId: credentialId || undefined, key, label: provider, maxShare: 0, @@ -575,6 +576,7 @@ function buildRecentRequestRows(events: StoredUsageEvent[]): UsageComparisonRow[ ...buildTotals([event]), caption: `${formatRequestTime(event.createdAt)} · ${event.client} · ${event.path} · ${event.statusCode}`, client: event.client, + credentialId: event.credentialId || undefined, key: String(event.id), label: event.model || "unknown", maxShare: 0, @@ -781,6 +783,24 @@ function readHeader(headers: Headers, name: string): string | undefined { return value || undefined; } +function readCredentialId(headers: Headers): string | undefined { + return readHeader(headers, "x-ccr-provider-credential-id") ?? parseCredentialChain(readHeader(headers, "x-ccr-provider-credential-chain"))[0]; +} + +function parseCredentialChain(value: string | undefined): string[] { + const result: string[] = []; + const seen = new Set(); + for (const item of (value ?? "").split(",")) { + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + function readNumberHeader(headers: Headers, name: string): number | undefined { return asNumber(readHeader(headers, name)); } diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 08dd8ec2..e794d52a 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -1,5 +1,5 @@ import { - AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSnapshot, AgentFilterValue, + AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentFilterValue, ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppUpdateStatus, AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, @@ -19,12 +19,13 @@ import { normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, + providerCredentialsFromDraft, providerFailoverFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, ProfileConfig, profileConfigFromDraft, providerAccountApiKeySafetyIssue, profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, RouterRule, ServerActionBusy, + ResolvedTheme, resolvePluginInstallPlan, RouterRule, ServerActionBusy, SettingsPageId, splitLines, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, @@ -42,7 +43,7 @@ type ProfileOpenDialogState = { profile: ProfileConfig; }; -type UpdateActionBusy = "" | "check" | "download" | "install"; +type UpdateActionBusy = "" | "download" | "install"; function App() { const [activeView, setActiveView] = useState("onboarding"); @@ -57,7 +58,6 @@ function App() { const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); const [updateStatus, setUpdateStatus] = useState(fallbackUpdateStatus); const [updateActionBusy, setUpdateActionBusy] = useState(""); - const [updateActionError, setUpdateActionError] = useState(""); const [actionBusy, setActionBusy] = useState(""); const [gatewayActionBusy, setGatewayActionBusy] = useState(false); const [actionMessage, setActionMessage] = useState(""); @@ -111,7 +111,7 @@ function App() { const [savedConfig, setSavedConfig] = useState(fallbackConfig); const [sidebarOpen, setSidebarOpen] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsInitialPage, setSettingsInitialPage] = useState<"appearance" | "bots" | "tray" | "update">("appearance"); + const [settingsInitialPage, setSettingsInitialPage] = useState("appearance"); const [settingsBotAddRequestKey, setSettingsBotAddRequestKey] = useState(0); const [compactLayout, setCompactLayout] = useState(() => window.matchMedia("(max-width: 720px)").matches); const [toast, setToast] = useState(); @@ -131,6 +131,7 @@ function App() { const [agentAnalysisError, setAgentAnalysisError] = useState(""); const [agentAnalysisLoading, setAgentAnalysisLoading] = useState(false); const [agentAnalysisRange, setAgentAnalysisRange] = useState("7d"); + const [agentAnalysisSession, setAgentAnalysisSession] = useState(); const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); @@ -194,6 +195,7 @@ function App() { void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([])); void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus); void window.ccr.getUpdateStatus().then(setUpdateStatus).catch(() => setUpdateStatus(fallbackUpdateStatus)); + const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog); const unsubscribeUpdateStatus = window.ccr.onUpdateStatusChanged(setUpdateStatus); const refreshRuntimeStatus = () => { void window.ccr?.getGatewayStatus().then(setGatewayStatus); @@ -203,6 +205,7 @@ function App() { const timer = window.setInterval(refreshRuntimeStatus, 2000); return () => { window.clearInterval(timer); + unsubscribeOpenSettings(); unsubscribeUpdateStatus(); }; }, []); @@ -293,7 +296,12 @@ function App() { }; }, [draftConfig.Providers]); - const agentAnalysisFilterKey = JSON.stringify({ agent: agentAnalysisAgent, range: agentAnalysisRange }); + const agentAnalysisFilterKey = JSON.stringify({ + agent: agentAnalysisAgent, + range: agentAnalysisRange, + sessionAgent: agentAnalysisSession?.agent, + sessionId: agentAnalysisSession?.id + }); useEffect(() => { if (activeView !== "observability") { @@ -309,7 +317,12 @@ function App() { if (showLoading) { setAgentAnalysisLoading(true); } - void window.ccr?.getAgentAnalysis({ agent: agentAnalysisAgent, range: agentAnalysisRange }) + void window.ccr?.getAgentAnalysis({ + agent: agentAnalysisAgent, + range: agentAnalysisRange, + sessionAgent: agentAnalysisSession?.agent, + sessionId: agentAnalysisSession?.id + }) .then((snapshot) => { if (!cancelled) { setAgentAnalysis(snapshot); @@ -556,33 +569,16 @@ function App() { }, 1800); } - async function checkForAppUpdate() { - if (!window.ccr) { - setUpdateActionError(t("Updates are only available in packaged builds.")); - return; - } - setUpdateActionBusy("check"); - setUpdateActionError(""); - try { - setUpdateStatus(await window.ccr.updateCheck()); - } catch (error) { - setUpdateActionError(formatUnknownError(error)); - } finally { - setUpdateActionBusy(""); - } - } - async function downloadAppUpdate() { if (!window.ccr) { - setUpdateActionError(t("Updates are only available in packaged builds.")); + showToast(t("Updates are only available in packaged builds.")); return; } setUpdateActionBusy("download"); - setUpdateActionError(""); try { setUpdateStatus(await window.ccr.updateDownload()); } catch (error) { - setUpdateActionError(formatUnknownError(error)); + showToast(formatUnknownError(error)); } finally { setUpdateActionBusy(""); } @@ -590,15 +586,14 @@ function App() { async function installAppUpdate() { if (!window.ccr) { - setUpdateActionError(t("Updates are only available in packaged builds.")); + showToast(t("Updates are only available in packaged builds.")); return; } setUpdateActionBusy("install"); - setUpdateActionError(""); try { await window.ccr.updateInstall(); } catch (error) { - setUpdateActionError(formatUnknownError(error)); + showToast(formatUnknownError(error)); setUpdateActionBusy(""); } } @@ -1017,6 +1012,12 @@ function App() { setProviderProbeError(accountConfig); return false; } + const credentials = providerCredentialsFromDraft(providerDraft); + if (typeof credentials === "string") { + setProviderProbeError(credentials); + return false; + } + const failover = providerFailoverFromDraft(providerDraft); const fallbackProtocol = probe?.detectedProtocol ?? providerDraft.protocol; const fallbackBaseUrl = probe?.normalizedBaseUrl || providerDraft.baseUrl; @@ -1057,6 +1058,18 @@ function App() { setProviderProbeError(keySafetyIssue.message); return false; } + for (const credential of credentials) { + const credentialKeySafetyIssue = providerApiKeySafetyIssue({ + apiKey: credential.api_key || credential.apiKey || credential.apikey, + baseUrl, + name: providerName, + presetId: providerDraft.presetId + }); + if (credentialKeySafetyIssue) { + setProviderProbeError(credentialKeySafetyIssue.message); + return false; + } + } const identityIssue = providerIdentitySafetyIssue({ baseUrl, name: providerName, @@ -1078,14 +1091,13 @@ function App() { return false; } - const existingProvider = providerEditIndex === undefined ? undefined : draftConfig.Providers[providerEditIndex]; const provider: GatewayProviderConfig = { api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), api_key: providerDraft.apiKey.trim(), capabilities: capabilities.length > 0 ? capabilities : undefined, account: accountConfig, - credentials: existingProvider?.credentials, - failover: existingProvider?.failover, + credentials: credentials.length > 0 ? credentials : undefined, + failover, icon: providerDraft.icon.trim() || undefined, models, name: providerName, @@ -1737,6 +1749,11 @@ function App() { setSettingsOpen(true); } + function openSettingsDialog() { + setSettingsInitialPage("appearance"); + setSettingsOpen(true); + } + function changeOverviewWidgets(widgets: OverviewWidgetConfig[]) { updateConfig((config) => ({ ...config, @@ -1923,7 +1940,12 @@ function App() { setAgentAnalysisLoading(true); try { - setAgentAnalysis(await window.ccr.getAgentAnalysis({ agent: agentAnalysisAgent, range: agentAnalysisRange })); + setAgentAnalysis(await window.ccr.getAgentAnalysis({ + agent: agentAnalysisAgent, + range: agentAnalysisRange, + sessionAgent: agentAnalysisSession?.agent, + sessionId: agentAnalysisSession?.id + })); setAgentAnalysisError(""); } catch (error) { setAgentAnalysisError(error instanceof Error ? error.message : String(error)); @@ -1940,6 +1962,16 @@ function App() { })); } + function updateAgentAnalysisAgent(value: AgentFilterValue) { + setAgentAnalysisAgent(value); + setAgentAnalysisSession(undefined); + } + + function updateAgentAnalysisRange(value: UsageStatsRange) { + setAgentAnalysisRange(value); + setAgentAnalysisSession(undefined); + } + async function clearProxyNetworkCaptures() { if (!window.ccr) { setProxyNetworkSnapshot(fallbackProxyNetworkSnapshot); @@ -2242,17 +2274,18 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} + onDownloadUpdate={downloadAppUpdate} + onInstallUpdate={installAppUpdate} onOpenServerView={() => setActiveView("server")} - onOpenSettings={() => { - setSettingsInitialPage("appearance"); - setSettingsOpen(true); - }} + onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} proxyStatus={proxyStatus} shouldReduceMotion={shouldReduceMotion} sidebarOpen={sidebarOpen} toggleGatewayService={toggleGatewayService} + updateActionBusy={updateActionBusy} + updateStatus={updateStatus} visibleNavigation={visibleNavigation} viewProps={{ apiKeys: { @@ -2294,8 +2327,10 @@ function App() { loading: agentAnalysisLoading, range: agentAnalysisRange, refreshAnalysis: () => void refreshAgentAnalysis(), - setAgentFilter: setAgentAnalysisAgent, - setRange: setAgentAnalysisRange, + selectedSession: agentAnalysisSession, + setAgentFilter: updateAgentAnalysisAgent, + setRange: updateAgentAnalysisRange, + setSelectedSession: setAgentAnalysisSession, snapshot: agentAnalysis }, overview: { @@ -2530,15 +2565,12 @@ function App() { isMac, languagePreference, onChangeBotConfigs: changeBotConfigs, - onCheckUpdate: checkForAppUpdate, onChangeLanguage: changeLanguagePreference, onChangeTheme: changeThemePreference, onChangeTrayBalanceProgress: changeTrayBalanceProgress, onChangeTrayIcon: changeTrayIconPreference, onChangeTrayWidgets: changeTrayWidgets, onClose: () => setSettingsOpen(false), - onDownloadUpdate: downloadAppUpdate, - onInstallUpdate: installAppUpdate, profiles: draftConfig.profile.profiles, systemLanguage, systemTheme, @@ -2546,10 +2578,7 @@ function App() { providerAccountSnapshots, trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress), trayIconPreference: draftConfig.trayIcon || "random", - trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants), - updateActionBusy, - updateActionError, - updateStatus + trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants) } : undefined} virtualModelUpsert={virtualModelDialogOpen ? { canSubmit: canSubmitVirtualModel, diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index 1f1e7e15..3c0e7475 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -1,23 +1,24 @@ import { - agentAnalysisRangeOptions, AgentAnalysisSnapshot, agentFilterOptions, AgentFilterValue, agentKindLabel, + agentAnalysisRangeOptions, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, agentFilterOptions, AgentFilterValue, agentKindLabel, Area, arrayMove, Badge, Bar, BarChart, Button, Card, CardContent, CardHeader, CardTitle, CartesianGrid, Cell, Check, ChevronLeft, ChevronRight, CircleAlert, cn, compactId, compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, - formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterValue, formatProviderAccountSchedule, + formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone, metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus, PointerSensor, primaryProviderAccountMeter, providerAccountBadgeVariant, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, + providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, systemStatusTooltipPositionClass, Tooltip, translateOptions, Trash2, UsageComparisonRow, usageRangeOptions, UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, - useState, XAxis, YAxis + useState, X, XAxis, YAxis } from "../shared"; export function OverviewView({ onWidgetsChange, @@ -233,7 +234,6 @@ export function OverviewView({ >
-
+

{t("Overview")}

+
{editing ? ( @@ -335,6 +335,35 @@ export function OverviewView({ ); } +function OverviewUsageRangeSelector({ + range, + setRange +}: { + range: UsageStatsRange; + setRange: (range: UsageStatsRange) => void; +}) { + const t = useAppText(); + + return ( +
+ {usageRangeOptions.map((option) => ( + + ))} +
+ ); +} + function isEditableKeyboardTarget(target: Element | undefined): boolean { return Boolean(target?.closest("input, textarea, select, [contenteditable='true'], [contenteditable='plaintext-only'], [role='textbox']")); } @@ -502,13 +531,11 @@ function SortableOverviewWidget({ function OverviewWidgetDragOverlay({ providerAccounts, - setUsageRange, usageRange, usageStats, widget }: { providerAccounts: ProviderAccountSnapshot[]; - setUsageRange: (range: UsageStatsRange) => void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -517,7 +544,6 @@ function OverviewWidgetDragOverlay({
void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -773,7 +797,7 @@ function OverviewWidgetRenderer({ } else if (widget.type === "metric") { content = ; } else if (widget.type === "usage-trend") { - content = ; + content = ; } else if (widget.type === "token-mix") { content = ; } else if (widget.type === "client-analysis") { @@ -867,43 +891,24 @@ function OverviewRingMetric({ ratio, tone }: { ratio: number; tone: MetricTone } function UsageTrendWidget({ dimensions, - setUsageRange, usageRange, usageStats, variant }: { dimensions: OverviewWidgetDimensions; - setUsageRange: (range: UsageStatsRange) => void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; variant: "area" | "bar" | "composed" | "line"; }) { const t = useAppText(); - const showRangeControls = dimensions.width >= 3 && dimensions.height >= 2; const chartMargin = dimensions.height <= 1 ? { bottom: 0, left: 0, right: 4, top: 8 } - : { bottom: 4, left: 0, right: 8, top: 28 }; + : { bottom: 4, left: 0, right: 8, top: 8 }; return ( {t("Usage Trend")} - {showRangeControls ?
- {usageRangeOptions.map((option) => ( - - ))} -
: null}
@@ -1068,6 +1073,7 @@ function OverviewAnalysisWidget({ ] : [ { key: "provider", label: t("Provider") }, + { key: "credentialId", label: t("Credential") }, { key: "model", label: t("Model") } ]; @@ -1155,7 +1161,7 @@ function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccount const options = providerAccounts .filter((account) => account.provider) .sort(compareProviderAccountSnapshots) - .map((account) => ({ label: account.provider, value: account.provider })); + .map((account) => ({ label: providerAccountSnapshotLabel(account), value: providerAccountSnapshotKey(account) })); if (widget.accountProvider && !options.some((option) => option.value === widget.accountProvider)) { options.push({ label: widget.accountProvider, value: widget.accountProvider }); } @@ -1538,7 +1544,7 @@ function ProviderAccountsOverview({ const sortedAccounts = [...accounts].sort(compareProviderAccountSnapshots); const accountLimit = selectedAccountProvider ? 1 : providerAccountVisibleLimit(dimensions, variant); const visibleAccounts = selectedAccountProvider - ? sortedAccounts.filter((account) => account.provider === selectedAccountProvider).slice(0, 1) + ? sortedAccounts.filter((account) => providerAccountSelectionMatches(account, selectedAccountProvider)).slice(0, 1) : sortedAccounts .filter((account) => account.meters.length > 0 || account.status === "error") .slice(0, accountLimit); @@ -1558,9 +1564,9 @@ function ProviderAccountsOverview({ {visibleAccounts.map((account) => { const meter = primaryProviderAccountDisplayMeter(account); return ( -
+
-
{account.provider}
+
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null}
@@ -1577,10 +1583,10 @@ function ProviderAccountsOverview({ const meter = primaryProviderAccountDisplayMeter(account); const progress = meter && isProviderAccountQuotaMeter(meter) ? providerAccountMeterProgress(meter) : undefined; return ( -
+
-
{account.provider}
+
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null}
{meter ? formatProviderAccountMeterValue(meter) : account.status}
@@ -1597,7 +1603,7 @@ function ProviderAccountsOverview({ ) : (
{visibleAccounts.map((account) => { - return ; + return ; })}
)} @@ -1625,7 +1631,7 @@ function ProviderAccountSinglePanel({
-
{account.provider}
+
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null} @@ -1669,7 +1675,7 @@ function ProviderAccountSummaryCard({
-
{account.provider}
+
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null} @@ -1711,12 +1717,11 @@ function ProviderAccountMeterLine({ }) { const t = useAppText(); const progress = isProviderAccountQuotaMeter(meter) ? providerAccountMeterProgress(meter) : undefined; - const schedule = formatProviderAccountSchedule(meter, t); return (
-
= 2 ? "text-[13px]" : "text-[12px]")}>{t(meter.label)}
+
= 2 ? "text-[13px]" : "text-[12px]")}>{formatProviderAccountMeterTitle(meter, t)}
= 2 ? "text-[18px]" : "text-[15px]")}>{formatProviderAccountMeterValue(meter)}
{progress !== undefined && providerAccountShowProgress(dimensions) ? ( @@ -1724,9 +1729,6 @@ function ProviderAccountMeterLine({
) : null} - {schedule && providerAccountShowReset(dimensions) ? ( -
{schedule}
- ) : null}
); } @@ -1745,7 +1747,7 @@ function ProviderAccountBalanceMetric({ return (
-
{t(meter.label)}
+
{formatProviderAccountMeterTitle(meter, t)}
{formatProviderAccountMeterValue(meter)}
); @@ -1777,14 +1779,10 @@ function ProviderAccountQuotaVisual({ {showLabels ? (
{displayMeters.slice(0, variant === "nested-rings" ? 2 : 1).map((meter) => { - const schedule = formatProviderAccountSchedule(meter, t); return (
-
{t(meter.label)}
+
{formatProviderAccountMeterTitle(meter, t)}
{formatProviderAccountMeterValue(meter)}
- {schedule && providerAccountShowReset(dimensions) ? ( -
{schedule}
- ) : null}
); })} @@ -1843,7 +1841,7 @@ function ProviderAccountQuotaGauge({ ); } @@ -1889,6 +1887,10 @@ function primaryProviderAccountDisplayMeter(account: ProviderAccountSnapshot): P return providerAccountQuotaMeters(account)[0] ?? primaryProviderAccountBalanceMeter(account) ?? primaryProviderAccountMeter(account); } +function providerAccountSelectionMatches(account: ProviderAccountSnapshot, value: string): boolean { + return providerAccountSnapshotKey(account) === value || account.provider === value; +} + function primaryProviderAccountBalanceMeter(account: ProviderAccountSnapshot): ProviderAccountMeter | undefined { return providerAccountBalanceMeters(account)[0]; } @@ -2058,10 +2060,6 @@ function providerAccountShowProgress(dimensions: OverviewWidgetDimensions): bool return dimensions.height >= 1; } -function providerAccountShowReset(dimensions: OverviewWidgetDimensions): boolean { - return dimensions.height >= 2; -} - function providerAccountShowExtraCount(dimensions: OverviewWidgetDimensions): boolean { return dimensions.height >= 3; } @@ -2072,8 +2070,10 @@ export function AgentAnalysisView({ loading, range, refreshAnalysis, + selectedSession, setAgentFilter, setRange, + setSelectedSession, snapshot }: { agentFilter: AgentFilterValue; @@ -2081,8 +2081,10 @@ export function AgentAnalysisView({ loading: boolean; range: UsageStatsRange; refreshAnalysis: () => void; + selectedSession?: AgentAnalysisSessionSelection; setAgentFilter: (value: AgentFilterValue) => void; setRange: (range: UsageStatsRange) => void; + setSelectedSession: (value?: AgentAnalysisSessionSelection) => void; snapshot: AgentAnalysisSnapshot; }) { const t = useAppText(); @@ -2206,8 +2208,20 @@ export function AgentAnalysisView({ + {selectedSession || snapshot.selectedSession ? ( + setSelectedSession(undefined)} + detail={snapshot.selectedSession} + selectedSession={selectedSession} + /> + ) : null} +
- +
@@ -2407,7 +2421,177 @@ function AgentErrorsCard({ errors }: { errors: AgentAnalysisSnapshot["errors"] } ); } -function AgentSessionsCard({ sessions }: { sessions: AgentAnalysisSnapshot["sessions"] }) { +function AgentSessionDetailCard({ + clearSession, + detail, + selectedSession +}: { + clearSession: () => void; + detail?: AgentAnalysisSnapshot["selectedSession"]; + selectedSession?: AgentAnalysisSessionSelection; +}) { + const t = useAppText(); + const session = detail?.session; + const headerLabel = session + ? `${t(agentKindLabel(session.agent))} / ${compactId(session.id)}` + : selectedSession + ? `${t(agentKindLabel(selectedSession.agent))} / ${compactId(selectedSession.id)}` + : t("Session"); + + return ( + + +
+ {t("Session Detail")} +
+ {headerLabel} +
+
+ +
+ + {!detail ? ( + + ) : ( +
+
+ + + + + + + + +
+ +
+
+
+
{t("Models")}
+ {detail.models.length} +
+ {detail.models.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {detail.models.map((model) => ( + + + + + + + + + ))} + +
{t("Model")}{t("Requests")}{t("Tokens")}{t("Cache")}{t("P95")}{t("Cost")}
{model.provider}/{model.model}{formatCompactNumber(model.requestCount)}{formatCompactNumber(model.totalTokens)}{formatCompactNumber(model.cacheTokens)}{formatDuration(model.p95DurationMs)}{formatUsdCost(model.costUsd)}
+
+ )} +
+ +
+
+
{t("Session Requests")}
+ {detail.requests.length} +
+ {detail.requests.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {detail.requests.map((request) => ( + + + + + + + + + + ))} + +
{t("Time")}{t("Status")}{t("Route")}{t("Model")}{t("Tools")}{t("Tokens")}{t("Duration")}
{formatLogDateTime(request.createdAt)}{request.statusCode || "-"}{request.routeReason || "-"}{request.provider}/{request.model}{formatCompactNumber(request.toolCallCount)}{formatCompactNumber(request.totalTokens)}{formatDuration(request.durationMs)}
+
+ )} +
+
+ +
+ + + +
+
+ )} +
+
+ ); +} + +function SessionMetricCell({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function SessionInlineList({ title, value }: { title: string; value: string }) { + return ( +
+
{title}
+
{value || "-"}
+
+ ); +} + +function formatToolRows(tools: AgentAnalysisSnapshot["tools"]): string { + return tools.slice(0, 5).map((tool) => `${tool.name} (${formatCompactNumber(tool.count)})`).join(", "); +} + +function formatRouteRows(routes: AgentAnalysisSnapshot["routes"]): string { + return routes.slice(0, 5).map((route) => `${route.routeReason}: ${formatCompactNumber(route.requestCount)}`).join(", "); +} + +function AgentSessionsCard({ + onSelectSession, + selectedSession, + sessions +}: { + onSelectSession: (value: AgentAnalysisSessionSelection) => void; + selectedSession?: AgentAnalysisSessionSelection; + sessions: AgentAnalysisSnapshot["sessions"]; +}) { const t = useAppText(); return ( @@ -2438,21 +2622,32 @@ function AgentSessionsCard({ sessions }: { sessions: AgentAnalysisSnapshot["sess - {sessions.map((session) => ( - - {compactId(session.id)} - {t(agentKindLabel(session.agent))} - {session.client} - {formatCompactNumber(session.requestCount)} - {formatCompactNumber(session.toolCallCount)} - {formatCompactNumber(session.subagentCallCount)} - {formatCompactNumber(session.cacheTokens)} - {formatCompactNumber(session.maxConcurrentRequests)} - {formatToolCounts(session.topTools) || "-"} - {compactUserAgent(session.userAgent)} - {formatLogDateTime(session.lastSeenAt)} - - ))} + {sessions.map((session) => { + const selected = selectedSession?.agent === session.agent && selectedSession.id === session.id; + return ( + + + + + {t(agentKindLabel(session.agent))} + {session.client} + {formatCompactNumber(session.requestCount)} + {formatCompactNumber(session.toolCallCount)} + {formatCompactNumber(session.subagentCallCount)} + {formatCompactNumber(session.cacheTokens)} + {formatCompactNumber(session.maxConcurrentRequests)} + {formatToolCounts(session.topTools) || "-"} + {compactUserAgent(session.userAgent)} + {formatLogDateTime(session.lastSeenAt)} + + ); + })}
@@ -2614,7 +2809,7 @@ function AnalysisEmptyState({ label }: { label: string }) { } type UsageAnalysisColumn = { - key: "client" | "model" | "provider"; + key: "client" | "credentialId" | "model" | "provider"; label: string; }; @@ -2669,7 +2864,7 @@ function UsageAnalysisCard({ {visibleColumns.map((column) => ( - {row[column.key] ?? "unknown"} + {row[column.key] || "-"} ))} {formatCompactNumber(row.totalTokens)} diff --git a/src/renderer/pages/home/components/layout.tsx b/src/renderer/pages/home/components/layout.tsx index 4df61739..9aad91c3 100644 --- a/src/renderer/pages/home/components/layout.tsx +++ b/src/renderer/pages/home/components/layout.tsx @@ -1,9 +1,9 @@ import type { ComponentProps } from "react"; import { - AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, + AnimatePresence, AppConfig, AppCopy, AppUpdateStatus, Button, Check, cn, EndpointTitleBar, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, - NavigationId, PanelLeftClose, PanelLeftOpen, ProxyStatus, - reducedMotionTransition, ServiceControlButton, Settings, ViewId, + LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, ProxyStatus, + reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared"; import { ApiKeysView } from "./api-keys"; @@ -22,6 +22,8 @@ type MainNavigationItem = { id: NavigationId; }; +type UpdateActionBusy = "" | "download" | "install"; + type MainViewProps = { apiKeys: ComponentProps; extensions: ComponentProps; @@ -62,6 +64,8 @@ export function MainLayout({ isMac, needsTrafficLightSafeArea, networkCaptureEnabled, + onDownloadUpdate, + onInstallUpdate, onOpenServerView, onOpenSettings, onSelectNavigationItem, @@ -70,6 +74,8 @@ export function MainLayout({ shouldReduceMotion, sidebarOpen, toggleGatewayService, + updateActionBusy, + updateStatus, viewProps, visibleNavigation }: { @@ -82,6 +88,8 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; + onDownloadUpdate: () => Promise; + onInstallUpdate: () => Promise; onOpenServerView: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; @@ -90,10 +98,13 @@ export function MainLayout({ shouldReduceMotion: boolean | null; sidebarOpen: boolean; toggleGatewayService: () => void; + updateActionBusy: UpdateActionBusy; + updateStatus: AppUpdateStatus; viewProps: MainViewProps; visibleNavigation: MainNavigationItem[]; }) { const windowControlSafeAreaWidth = isMac ? 152 : 88; + const showUpdateButton = isUpdateButtonVisible(updateStatus); return ( <> @@ -144,6 +155,18 @@ export function MainLayout({
+ {showUpdateButton ? ( +
+ +
+ ) : null} +