From 1b0c4813768028f5ac4d6eb0eb1a1fff7326edff Mon Sep 17 00:00:00 2001 From: musistudio Date: Sun, 28 Jun 2026 21:45:45 +0800 Subject: [PATCH 001/101] Remove CDN workflow and add MIT license info --- .github/workflows/cdn.yml | 33 --------------------------------- LICENSE | 21 +++++++++++++++++++++ README.md | 5 +++++ README_zh.md | 5 +++++ cdn/package.json | 1 + docs/package-lock.json | 1 + docs/package.json | 1 + package-lock.json | 1 + package.json | 1 + 9 files changed, 36 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/cdn.yml create mode 100644 LICENSE diff --git a/.github/workflows/cdn.yml b/.github/workflows/cdn.yml deleted file mode 100644 index 6223aa6..0000000 --- a/.github/workflows/cdn.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: CDN - -on: - push: - branches: - - main - paths: - - "cdn/**" - - ".github/workflows/cdn.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: cdn - cancel-in-progress: false - -jobs: - deploy: - name: Deploy Cloudflare Pages - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Deploy CDN assets - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: cdn - command: pages deploy public --project-name=claude-code-router-cdn --branch=${{ github.ref_name }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea7e8ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 musistudio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1377e08..e53da3b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Chinese README Discord License + Documentation

@@ -281,3 +282,7 @@ A huge thank you to all our sponsors for their generous support. - @x\*g (If your name is masked, please contact me via my homepage email to update it with your GitHub username.) + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/README_zh.md b/README_zh.md index dc81f80..dfe6b3a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,6 +4,7 @@ English README Discord License + 文档

@@ -281,3 +282,7 @@ macOS 打包 hook 会在产物生成前验证代码签名、公证票据 staplin - @x\*g (如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。) + +## 许可证 + +本项目基于 [MIT License](LICENSE) 发布。 diff --git a/cdn/package.json b/cdn/package.json index 3968876..f49b77a 100644 --- a/cdn/package.json +++ b/cdn/package.json @@ -2,6 +2,7 @@ "name": "claude-code-router-cdn", "private": true, "version": "0.1.0", + "license": "MIT", "type": "module", "scripts": { "deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn", diff --git a/docs/package-lock.json b/docs/package-lock.json index 02de48c..92adc81 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "claude-code-router-docs", "version": "0.1.0", + "license": "MIT", "devDependencies": { "astro": "7.0.0", "lucide-astro": "^0.556.0" diff --git a/docs/package.json b/docs/package.json index b2046bf..0f0aa87 100644 --- a/docs/package.json +++ b/docs/package.json @@ -2,6 +2,7 @@ "name": "claude-code-router-docs", "private": true, "version": "0.1.0", + "license": "MIT", "type": "module", "scripts": { "dev": "astro dev", diff --git a/package-lock.json b/package-lock.json index 036ccac..a741305 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "claude-code-router", "version": "3.0.2", + "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.1", "@the-next-ai/bot-gateway-sdk": "^0.1.0", diff --git a/package.json b/package.json index 6666ca5..1a070ff 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "claude-code-router", "private": true, "version": "3.0.2", + "license": "MIT", "description": "Desktop scaffold for Claude Code Router.", "main": "dist/main/main.js", "bin": { From c899b51b04df560dce7c9852eeece71374f6584d Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 29 Jun 2026 08:44:27 +0800 Subject: [PATCH 002/101] Add remote sync to Claude wrapper and provider IDs --- src/main/codex-cli-middleware-runtime.ts | 218 ++++++- src/main/config.ts | 45 +- src/main/profile-service.ts | 25 +- src/main/request-log-store.ts | 2 + src/main/usage-store.ts | 2 + src/renderer/pages/home/shared/providers.ts | 2 +- src/server/gateway/remote-control-service.ts | 627 +++++++++++++++++++ src/server/gateway/service.ts | 134 +++- src/shared/app.ts | 1 + 9 files changed, 1023 insertions(+), 33 deletions(-) create mode 100644 src/server/gateway/remote-control-service.ts diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 6791ffb..fcd0a11 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -58,12 +58,34 @@ async function main() { async function runClaudeCodeCliWrapper(args) { const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); log("claude_code_wrapper_start", { realCli, args }); + const remoteSync = createRemoteSyncClient({ + args, + cwd: process.cwd(), + mode: "claude-cli", + title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code" + }); + const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN"); const child = childProcess.spawn(realCli, args, { env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), - stdio: ["inherit", "pipe", "inherit"] + stdio: [injectRemoteStdin ? "pipe" : "inherit", "pipe", "inherit"] + }); + if (injectRemoteStdin && child.stdin) { + process.stdin.pipe(child.stdin); + } + remoteSync.start((event) => { + const text = remoteEventText(event); + if (!text) return; + if (injectRemoteStdin && child.stdin && !child.killed) { + child.stdin.write(text + "\n"); + return; + } + if (boolEnv("CCR_REMOTE_SYNC_NOTIFY_INBOUND") || !process.env.CCR_REMOTE_SYNC_NOTIFY_INBOUND) { + process.stdout.write("\n[CCR remote] " + text + "\n"); + } }); child.on("error", (error) => { log("claude_code_wrapper_spawn_error", { error: formatError(error) }); + remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" }); }); let pending = ""; child.stdout.on("data", (chunk) => { @@ -73,12 +95,16 @@ async function runClaudeCodeCliWrapper(args) { pending = lines.pop() || ""; for (const line of lines) { botBridge().handleClaudeCliLine(line); + remoteSync.postEvent("claude.stdout", { line }, { text: line }); } }); const code = await waitForChild(child); if (pending.trim()) { botBridge().handleClaudeCliLine(pending); + remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending }); } + await remoteSync.postEvent("claude.exit", { code }, { direction: "system" }); + remoteSync.stop(); log("claude_code_wrapper_exit", { code }); process.exitCode = code; } @@ -2365,6 +2391,196 @@ function writeLine(stream, value) { stream.write(JSON.stringify(value) + "\n"); } +function createRemoteSyncClient(options) { + const endpoint = normalizeRemoteSyncEndpoint(nonEmptyEnv("CCR_REMOTE_SYNC_ENDPOINT")); + const enabled = endpoint && !["0", "false", "no", "off"].includes(String(process.env.CCR_REMOTE_SYNC_ENABLED || "1").trim().toLowerCase()); + if (!enabled || typeof fetch !== "function") { + return { + postEvent: async () => {}, + start() {}, + stop() {} + }; + } + return new RemoteSyncClient({ + args: options.args || [], + cwd: options.cwd || process.cwd(), + endpoint, + mode: options.mode || "agent", + title: options.title || "CCR Remote", + profileId: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_ID"), + profileName: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") + }); +} + +class RemoteSyncClient { + constructor(options) { + this.options = options; + this.active = false; + this.apiKey = ""; + this.lastInboundSeq = 0; + this.pollTimer = null; + this.ready = null; + this.seenInbound = new Set(); + this.sessionId = nonEmptyEnv("CCR_REMOTE_SYNC_SESSION_ID") || "ccr-" + safePathSegment(options.profileId || options.profileName || options.mode) + "-" + uuid(); + } + + start(onInbound) { + if (this.active) return; + this.active = true; + this.ready = this.open().catch((error) => { + this.active = false; + log("remote_sync_start_failed", { error: formatError(error) }); + }); + this.ready.then(() => { + if (this.active) this.pollInbound(onInbound); + }).catch(() => {}); + } + + stop() { + this.active = false; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + async open() { + this.apiKey = await readRemoteSyncApiKey(); + const response = await this.request("POST", "/sessions", { + id: this.sessionId, + title: this.options.title, + metadata: { + args: this.options.args, + cwd: this.options.cwd, + mode: this.options.mode, + pid: process.pid, + profileId: this.options.profileId, + profileName: this.options.profileName, + startedAt: new Date().toISOString() + } + }); + const session = response && response.session; + if (session && session.id) this.sessionId = session.id; + log("remote_sync_started", { sessionId: this.sessionId, endpoint: this.options.endpoint }); + } + + postEvent(type, payload, options) { + if (!this.active) return Promise.resolve(); + const eventOptions = options || {}; + return Promise.resolve(this.ready) + .then(() => { + if (!this.active || !this.sessionId) return undefined; + return this.request("POST", "/sessions/" + encodeURIComponent(this.sessionId) + "/events", { + direction: eventOptions.direction || "local", + payload: payload || {}, + source: "ccr-claude-wrapper", + text: eventOptions.text, + type + }); + }) + .catch((error) => { + log("remote_sync_event_failed", { type, error: formatError(error) }); + }); + } + + pollInbound(onInbound) { + if (!this.active) return; + this.request("GET", "/sessions/" + encodeURIComponent(this.sessionId) + "/inbound?after=" + this.lastInboundSeq) + .then((response) => { + const events = Array.isArray(response && response.events) ? response.events : []; + for (const event of events) { + if (!event || !Number.isFinite(event.seq)) continue; + this.lastInboundSeq = Math.max(this.lastInboundSeq, event.seq); + const key = event.id || event.dedupeKey || String(event.seq); + if (this.seenInbound.has(key)) continue; + this.seenInbound.add(key); + while (this.seenInbound.size > 500) { + const oldest = this.seenInbound.values().next().value; + if (!oldest) break; + this.seenInbound.delete(oldest); + } + try { + onInbound(event); + } catch (error) { + log("remote_sync_inbound_handler_failed", { error: formatError(error) }); + } + } + }) + .catch((error) => { + log("remote_sync_poll_failed", { error: formatError(error) }); + }) + .finally(() => { + if (!this.active) return; + this.pollTimer = setTimeout(() => this.pollInbound(onInbound), numberEnv("CCR_REMOTE_SYNC_POLL_INTERVAL_MS", 2000)); + }); + } + + async request(method, suffix, body) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numberEnv("CCR_REMOTE_SYNC_REQUEST_TIMEOUT_MS", 5000)); + const headers = { "accept": "application/json" }; + if (body !== undefined) headers["content-type"] = "application/json"; + if (this.apiKey) headers.authorization = "Bearer " + this.apiKey; + try { + const response = await fetch(remoteSyncUrl(this.options.endpoint, suffix), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + if (!response.ok) { + throw new Error("HTTP " + response.status + " from CCR remote sync"); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } + } +} + +async function readRemoteSyncApiKey() { + const direct = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY"); + if (direct) return direct; + const helper = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_HELPER"); + if (!helper) return ""; + return new Promise((resolve) => { + childProcess.execFile(expandHome(helper), { + shell: process.platform === "win32", + timeout: 3000, + windowsHide: true + }, (error, stdout) => { + if (error) { + log("remote_sync_api_key_helper_failed", { error: formatError(error) }); + resolve(""); + return; + } + resolve(String(stdout || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""); + }); + }); +} + +function normalizeRemoteSyncEndpoint(value) { + const trimmed = String(value || "").trim().replace(/\/+$/, ""); + return trimmed || ""; +} + +function remoteSyncUrl(endpoint, suffix) { + return endpoint + (String(suffix || "").startsWith("/") ? suffix : "/" + suffix); +} + +function remoteEventText(event) { + if (!event || typeof event !== "object") return ""; + if (typeof event.text === "string" && event.text.trim()) return event.text.trim(); + const payload = event.payload; + if (typeof payload === "string" && payload.trim()) return payload.trim(); + if (payload && typeof payload === "object") { + if (typeof payload.text === "string" && payload.text.trim()) return payload.text.trim(); + if (typeof payload.content === "string" && payload.content.trim()) return payload.content.trim(); + if (typeof payload.message === "string" && payload.message.trim()) return payload.message.trim(); + } + return ""; +} + function createBotGatewayBridge() { const config = readBotGatewayBridgeConfig(); if (!config.enabled) { diff --git a/src/main/config.ts b/src/main/config.ts index 8fd494e..8f7f424 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store"; import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store"; @@ -623,6 +624,7 @@ function sanitizeConfigForDisk(config: AppConfig): AppConfig { ...config.gateway, coreHost: INTERNAL_GATEWAY_CORE_HOST }, + Providers: withProviderIds(config.Providers), profile: sanitizeProfileConfigForDisk(config.profile) }; } @@ -1100,6 +1102,7 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { extraBody: item.extraBody, extraHeaders: item.extraHeaders, icon: readString(item.icon), + id: readString(item.id), models, name, provider: readString(item.provider), @@ -1110,7 +1113,47 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { }) .filter((item): item is GatewayProviderConfig => Boolean(item)); - return providers; + return withProviderIds(providers); +} + +function withProviderIds(providers: GatewayProviderConfig[]): GatewayProviderConfig[] { + const counts = new Map(); + return providers.map((provider) => { + const baseId = providerRuntimeIdCandidate(provider); + const nextCount = (counts.get(baseId) ?? 0) + 1; + counts.set(baseId, nextCount); + return { + ...provider, + id: nextCount === 1 ? baseId : `${baseId}-${nextCount}` + }; + }); +} + +function providerRuntimeIdCandidate(provider: GatewayProviderConfig): string { + const explicit = sanitizeProviderId(provider.id); + if (explicit) { + return explicit; + } + const slug = sanitizeProviderId(provider.name) || "provider"; + const source = [ + provider.name, + provider.provider ?? "", + providerBaseUrl(provider), + provider.type ?? "" + ].join("\n"); + const hash = createHash("sha256").update(source).digest("hex").slice(0, 10); + return `provider-${slug.slice(0, 48)}-${hash}`; +} + +function sanitizeProviderId(value: string | undefined): string | undefined { + const normalized = value + ?.normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 72); + return normalized || undefined; } function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | undefined { diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 68b5888..f0df909 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -74,7 +74,7 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token } const helperResult = writeClaudeCodeApiKeyHelper(profile, token); - const wrapperResult = writeClaudeCodeWrapper(config, profile); + const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file); const nextSettings = { ...settings, apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file, @@ -459,7 +459,7 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string { ].join("\r\n"); } -function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { backupFile?: string; changed: boolean; file: string } { +function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig, apiKeyHelperFile: string): { backupFile?: string; changed: boolean; file: string } { const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { mode: privateDirMode, recursive: true }); const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename()); @@ -469,8 +469,8 @@ function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { ba } const file = path.join(binDir, claudeCodeWrapperFilename(profile)); const content = process.platform === "win32" - ? claudeCodeWrapperCmdScript(config, profile, runtimeFile) - : claudeCodeWrapperShellScript(config, profile, runtimeFile); + ? claudeCodeWrapperCmdScript(config, profile, runtimeFile, apiKeyHelperFile) + : claudeCodeWrapperShellScript(config, profile, runtimeFile, apiKeyHelperFile); const writeResult = writeFileWithBackup(file, content, { mode: privateExecutableMode }); if (process.platform !== "win32") { chmodSync(file, privateExecutableMode); @@ -489,9 +489,10 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string { : `ccr-claude-code-wrapper-${slug}`; } -function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { +function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); + const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => `export ${key}=${shellQuote(value)}`); @@ -505,14 +506,21 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, `export CCR_CLAUDE_CODE_WRAPPER=1`, `export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, `export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, + `if [ -z "\${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellQuote(remoteEndpoint)}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellQuote(apiKeyHelperFile)}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=${shellQuote(profile.id || profile.name || "claude-code")}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME=${shellQuote(profile.name || profile.id || "Claude Code")}; fi`, + "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME", ...nodeRuntimeShellExecLines(runtimeFile), "" ].join("\n"); } -function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { +function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); + const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => cmdSetLine(key, value)); @@ -525,6 +533,11 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"), cmdSetLine("CCR_REAL_CLAUDE_CODE_BIN", realClaude), cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaude), + `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`, + `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`, + `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`, + `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", profile.id || profile.name || "claude-code")}`, + `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", profile.name || profile.id || "Claude Code")}`, ...nodeRuntimeCmdExecLines(runtimeFile), "" ].join("\r\n"); diff --git a/src/main/request-log-store.ts b/src/main/request-log-store.ts index b791dd8..1ebfea5 100644 --- a/src/main/request-log-store.ts +++ b/src/main/request-log-store.ts @@ -71,6 +71,7 @@ type RequestLogRecordInput = { fallbackModel?: string; method: string; path: string; + providerName?: string; providerProtocol?: GatewayProviderProtocol; requestBody: Buffer; requestHeaders: HeaderRecord; @@ -250,6 +251,7 @@ class RequestLogStore { const route = splitRouteSelector(input.fallbackModel); const requestModel = extractModelFromBody(input.requestBody.toString("utf8")); const provider = + normalizeFilterValue(input.providerName) ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ?? route.provider; diff --git a/src/main/usage-store.ts b/src/main/usage-store.ts index da17971..351d8c5 100644 --- a/src/main/usage-store.ts +++ b/src/main/usage-store.ts @@ -48,6 +48,7 @@ type UsageCaptureInput = { fallbackModel?: string; method: string; path: string; + providerName?: string; providerProtocol?: GatewayProviderProtocol; requestId?: string; responseHeaders: Headers; @@ -316,6 +317,7 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi }); const route = splitRouteSelector(input.fallbackModel); const provider = + input.providerName ?? readHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? readHeader(input.responseHeaders, "x-gateway-target-provider") ?? route.provider; diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index 5fe0451..4694198 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -1866,7 +1866,7 @@ export function providerCapabilitiesSummary(provider: GatewayProviderConfig, tra } export function providerListItemKey(provider: GatewayProviderConfig, index: number): string { - return `${index}:${provider.name || "provider"}`; + return provider.id || `${index}:${provider.name || "provider"}`; } export function providerMatchesQuery(provider: GatewayProviderConfig, query: string): boolean { diff --git a/src/server/gateway/remote-control-service.ts b/src/server/gateway/remote-control-service.ts new file mode 100644 index 0000000..b3a7e70 --- /dev/null +++ b/src/server/gateway/remote-control-service.ts @@ -0,0 +1,627 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export const ccrRemoteControlPathPrefix = "/__ccr/remote"; + +type RemoteDirection = "inbound" | "local" | "remote" | "system"; + +export type CcrRemoteControlRequestContext = { + endpoint: string; + path: string; + readBody: (request: IncomingMessage) => Promise; + request: IncomingMessage; + response: ServerResponse; + sendJson: (response: ServerResponse, statusCode: number, payload: unknown) => void; +}; + +type RemoteSession = { + archivedAt?: string; + createdAt: string; + events: RemoteEvent[]; + id: string; + inboundEvents: RemoteEvent[]; + lastSeq: number; + metadata: Record; + presence: Record; + seenDedupeKeys: Map; + subscribers: Set; + title: string; + updatedAt: string; +}; + +type RemoteEvent = { + createdAt: string; + dedupeKey?: string; + direction: RemoteDirection; + id: string; + payload: unknown; + role?: string; + seq: number; + sessionId: string; + source?: string; + text?: string; + type: string; +}; + +type RemotePresence = { + lastSeenAt: string; + metadata: Record; + name: string; + role: string; +}; + +type RemoteSubscriber = { + close: () => void; + id: string; + kind: "events" | "inbound"; + response: ServerResponse; +}; + +const maxSessions = 100; +const maxEventsPerSession = 2_000; +const maxInboundEventsPerSession = 500; +const sseHeartbeatMs = 15_000; + +class CcrRemoteControlService { + private readonly sessions = new Map(); + + async handleRequest(context: CcrRemoteControlRequestContext): Promise { + const segments = remotePathSegments(context.path); + const [root, sessionId, resource] = segments; + + if (segments.length === 0 || context.path === ccrRemoteControlPathPrefix) { + this.sendCapabilities(context); + return; + } + + if (root === "capabilities") { + this.sendCapabilities(context); + return; + } + + if (root !== "sessions") { + context.sendJson(context.response, 404, { error: { message: "Remote control endpoint not found." } }); + return; + } + + if (!sessionId) { + await this.handleSessionsRequest(context); + return; + } + + if (!resource) { + await this.handleSessionRequest(context, sessionId); + return; + } + + if (resource === "events") { + await this.handleEventsRequest(context, sessionId); + return; + } + + if (resource === "inbound") { + await this.handleInboundRequest(context, sessionId); + return; + } + + if (resource === "presence") { + await this.handlePresenceRequest(context, sessionId); + return; + } + + context.sendJson(context.response, 404, { error: { message: "Remote control session endpoint not found." } }); + } + + private sendCapabilities(context: CcrRemoteControlRequestContext): void { + context.sendJson(context.response, 200, { + endpoints: { + createSession: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions`, + inbound: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/inbound`, + sessionEvents: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/events` + }, + name: "ccr-remote-control", + protocol: "ccr.remote.v1", + transport: ["json", "sse"], + capabilities: { + catchupReplay: true, + fanout: true, + inboundQueue: true, + presence: true + } + }); + } + + private async handleSessionsRequest(context: CcrRemoteControlRequestContext): Promise { + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { + sessions: [...this.sessions.values()].map((session) => this.sessionSummary(session, context.endpoint)) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const id = sanitizeSessionId(readString(body.id) || readString(body.sessionId)) || randomUUID(); + const title = readString(body.title) || readString(body.name) || `CCR Remote ${id.slice(0, 8)}`; + const metadata = readRecord(body.metadata) ?? {}; + const session = this.ensureSession(id, title, metadata); + this.appendEvent(session, { + direction: "system", + payload: { title }, + source: "ccr-gateway", + type: "session.created" + }); + + context.sendJson(context.response, 201, { + session: this.sessionSnapshot(session, context.endpoint) + }); + } + + private async handleSessionRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) }); + return; + } + + if (context.request.method === "PATCH") { + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const title = readString(body.title) || readString(body.name); + if (title) { + session.title = title; + } + const metadata = readRecord(body.metadata); + if (metadata) { + session.metadata = { ...session.metadata, ...metadata }; + } + session.updatedAt = new Date().toISOString(); + this.appendEvent(session, { + direction: "system", + payload: { metadata: metadata ?? {}, title: title ?? session.title }, + source: "ccr-gateway", + type: "session.updated" + }); + context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) }); + return; + } + + if (context.request.method === "DELETE") { + session.archivedAt = new Date().toISOString(); + session.updatedAt = session.archivedAt; + this.appendEvent(session, { + direction: "system", + payload: { archivedAt: session.archivedAt }, + source: "ccr-gateway", + type: "session.archived" + }); + context.sendJson(context.response, 200, { archived: true, session: this.sessionSummary(session, context.endpoint) }); + return; + } + + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + } + + private async handleEventsRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + const after = remoteAfterSeq(context.request); + if (wantsSse(context.request)) { + this.openSse(context, session, "events", after); + return; + } + context.sendJson(context.response, 200, { + events: session.events.filter((event) => event.seq > after), + session: this.sessionSummary(session, context.endpoint) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const events = normalizeEventInputs(body).map((event) => + this.appendEvent(session, { + ...event, + direction: event.direction ?? "local", + type: event.type || "message" + }) + ); + context.sendJson(context.response, 202, { + accepted: events.length, + events, + session: this.sessionSummary(session, context.endpoint) + }); + } + + private async handleInboundRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + const after = remoteAfterSeq(context.request); + if (wantsSse(context.request)) { + this.openSse(context, session, "inbound", after); + return; + } + context.sendJson(context.response, 200, { + events: session.inboundEvents.filter((event) => event.seq > after), + session: this.sessionSummary(session, context.endpoint) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const events = normalizeEventInputs(body).map((event) => + this.appendEvent(session, { + ...event, + direction: "remote", + type: event.type || "user.message" + }, true) + ); + context.sendJson(context.response, 202, { + accepted: events.length, + events, + session: this.sessionSummary(session, context.endpoint) + }); + } + + private async handlePresenceRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { presence: session.presence }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const clientId = sanitizeSessionId(readString(body.clientId) || readString(body.id)) || randomUUID(); + const presence: RemotePresence = { + lastSeenAt: new Date().toISOString(), + metadata: readRecord(body.metadata) ?? {}, + name: readString(body.name) || clientId, + role: readString(body.role) || "client" + }; + session.presence[clientId] = presence; + const event = this.appendEvent(session, { + direction: "system", + payload: { clientId, presence }, + source: "ccr-gateway", + type: "presence.updated" + }); + context.sendJson(context.response, 202, { event, presence: session.presence }); + } + + private ensureSession(id: string, title: string, metadata: Record): RemoteSession { + const existing = this.sessions.get(id); + if (existing) { + existing.metadata = { ...existing.metadata, ...metadata }; + existing.title = title || existing.title; + existing.updatedAt = new Date().toISOString(); + return existing; + } + + this.pruneSessions(); + const now = new Date().toISOString(); + const session: RemoteSession = { + createdAt: now, + events: [], + id, + inboundEvents: [], + lastSeq: 0, + metadata, + presence: {}, + seenDedupeKeys: new Map(), + subscribers: new Set(), + title, + updatedAt: now + }; + this.sessions.set(id, session); + return session; + } + + private appendEvent( + session: RemoteSession, + input: RemoteEventInput, + inbound = false + ): RemoteEvent { + const dedupeKey = input.dedupeKey || input.id; + if (dedupeKey) { + const duplicate = session.seenDedupeKeys.get(dedupeKey); + if (duplicate) { + return duplicate; + } + } + + const event: RemoteEvent = { + createdAt: new Date().toISOString(), + ...(dedupeKey ? { dedupeKey } : {}), + direction: input.direction ?? "local", + id: input.id || randomUUID(), + payload: input.payload ?? {}, + ...(input.role ? { role: input.role } : {}), + seq: ++session.lastSeq, + sessionId: session.id, + ...(input.source ? { source: input.source } : {}), + ...(input.text ? { text: input.text } : {}), + type: input.type || "message" + }; + + session.events.push(event); + trimArray(session.events, maxEventsPerSession); + if (inbound || event.direction === "remote" || event.direction === "inbound") { + session.inboundEvents.push(event); + trimArray(session.inboundEvents, maxInboundEventsPerSession); + } + if (dedupeKey) { + session.seenDedupeKeys.set(dedupeKey, event); + while (session.seenDedupeKeys.size > maxEventsPerSession) { + const oldest = session.seenDedupeKeys.keys().next().value; + if (!oldest) { + break; + } + session.seenDedupeKeys.delete(oldest); + } + } + session.updatedAt = event.createdAt; + this.broadcast(session, event); + return event; + } + + private openSse( + context: CcrRemoteControlRequestContext, + session: RemoteSession, + kind: RemoteSubscriber["kind"], + after: number + ): void { + context.response.writeHead(200, { + "cache-control": "no-cache, no-transform", + "connection": "keep-alive", + "content-type": "text/event-stream; charset=utf-8", + "x-accel-buffering": "no" + }); + context.response.write(": connected\n\n"); + const source = kind === "events" ? session.events : session.inboundEvents; + for (const event of source.filter((item) => item.seq > after)) { + writeSseEvent(context.response, event); + } + + const heartbeat = setInterval(() => { + if (!context.response.destroyed) { + context.response.write(": keepalive\n\n"); + } + }, sseHeartbeatMs); + const subscriber: RemoteSubscriber = { + close: () => clearInterval(heartbeat), + id: randomUUID(), + kind, + response: context.response + }; + session.subscribers.add(subscriber); + context.request.once("close", () => { + subscriber.close(); + session.subscribers.delete(subscriber); + }); + } + + private broadcast(session: RemoteSession, event: RemoteEvent): void { + for (const subscriber of session.subscribers) { + if (subscriber.kind === "inbound" && !(event.direction === "remote" || event.direction === "inbound")) { + continue; + } + if (subscriber.response.destroyed) { + subscriber.close(); + session.subscribers.delete(subscriber); + continue; + } + writeSseEvent(subscriber.response, event); + } + } + + private sessionSnapshot(session: RemoteSession, endpoint: string) { + return { + ...this.sessionSummary(session, endpoint), + events: session.events, + inboundEvents: session.inboundEvents, + metadata: session.metadata, + presence: session.presence + }; + } + + private sessionSummary(session: RemoteSession, endpoint: string) { + return { + ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), + createdAt: session.createdAt, + endpoints: { + events: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/events`, + inbound: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/inbound`, + presence: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/presence` + }, + eventCount: session.events.length, + id: session.id, + inboundCount: session.inboundEvents.length, + lastSeq: session.lastSeq, + subscriberCount: session.subscribers.size, + title: session.title, + updatedAt: session.updatedAt + }; + } + + private pruneSessions(): void { + while (this.sessions.size >= maxSessions) { + const oldest = [...this.sessions.values()] + .sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt))[0]; + if (!oldest) { + return; + } + for (const subscriber of oldest.subscribers) { + subscriber.close(); + subscriber.response.end(); + } + this.sessions.delete(oldest.id); + } + } + + private async readJsonBody(context: CcrRemoteControlRequestContext): Promise | undefined> { + const body = await context.readBody(context.request); + if (body.length === 0) { + return {}; + } + try { + const parsed = JSON.parse(body.toString("utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + context.sendJson(context.response, 400, { error: { message: "Request body must be a JSON object." } }); + return undefined; + } catch { + context.sendJson(context.response, 400, { error: { message: "Request body must be valid JSON." } }); + return undefined; + } + } +} + +type RemoteEventInput = { + dedupeKey?: string; + direction?: RemoteDirection; + id?: string; + payload?: unknown; + role?: string; + source?: string; + text?: string; + type?: string; +}; + +export const ccrRemoteControlService = new CcrRemoteControlService(); + +function normalizeEventInputs(body: Record): RemoteEventInput[] { + const rawEvents = Array.isArray(body.events) ? body.events : [body]; + return rawEvents + .filter((event): event is Record => typeof event === "object" && event !== null && !Array.isArray(event)) + .map((event) => { + const payload = Object.prototype.hasOwnProperty.call(event, "payload") + ? event.payload + : Object.prototype.hasOwnProperty.call(event, "message") + ? event.message + : {}; + return { + dedupeKey: readString(event.dedupeKey) || readString(event.uuid) || readString(event.requestId), + direction: readDirection(event.direction), + id: readString(event.id), + payload, + role: readString(event.role), + source: readString(event.source), + text: readString(event.text) || readString(event.content), + type: readString(event.type) + }; + }); +} + +function writeSseEvent(response: ServerResponse, event: RemoteEvent): void { + response.write(`id: ${event.seq}\n`); + response.write(`event: ${event.type.replace(/[\r\n]+/g, "") || "message"}\n`); + response.write(`data: ${JSON.stringify(event)}\n\n`); +} + +function wantsSse(request: IncomingMessage): boolean { + const url = new URL(request.url || "/", "http://127.0.0.1"); + return url.searchParams.get("stream") === "1" || + url.searchParams.get("stream") === "true" || + readHeader(request.headers.accept)?.toLowerCase().includes("text/event-stream") === true; +} + +function remoteAfterSeq(request: IncomingMessage): number { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const after = Number(url.searchParams.get("after") ?? readHeader(request.headers["last-event-id"]) ?? 0); + return Number.isFinite(after) && after > 0 ? Math.floor(after) : 0; +} + +function remotePathSegments(path: string): string[] { + const suffix = path.slice(ccrRemoteControlPathPrefix.length).replace(/^\/+|\/+$/g, ""); + if (!suffix) { + return []; + } + return suffix.split("/").map((segment) => decodeURIComponent(segment)).filter(Boolean); +} + +function readDirection(value: unknown): RemoteDirection | undefined { + return value === "inbound" || value === "local" || value === "remote" || value === "system" + ? value + : undefined; +} + +function readHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim(); + } + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function sanitizeSessionId(value: string | undefined): string | undefined { + const sanitized = value?.trim().replace(/[^a-zA-Z0-9:._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128); + return sanitized || undefined; +} + +function trimArray(items: T[], maxLength: number): void { + if (items.length > maxLength) { + items.splice(0, items.length - maxLength); + } +} diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 799226d..71c2122 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { createRequire } from "node:module"; import { networkInterfaces } from "node:os"; @@ -47,6 +47,7 @@ import { proxyService } from "../proxy/service"; import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "../../main/request-log-store"; import { recordGatewayUsageCapture } from "../../main/usage-store"; import { ClaudeCodeRouterPlugin, normalizeRouteSelector } from "./claude-code-router-plugin"; +import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "./remote-control-service"; import { claudeCodeEffectiveMaxInputTokens, findModelCatalogEntry, @@ -428,6 +429,22 @@ class GatewayService { return; } + if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + const authorization = authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + await ccrRemoteControlService.handleRequest({ + endpoint: this.status.endpoint, + path, + readBody: readRequestBody, + request, + response, + sendJson + }); + return; + } + if (isNetworkCaptureMcpPath(path)) { if (!this.config.proxy.captureNetwork) { sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } }); @@ -559,6 +576,7 @@ class GatewayService { fallbackModel: routedModel, method, path, + providerName: resolveProviderLogName(responseHeaders, config, routedModel), providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), requestBody: shouldSendBody(method) ? bodyToForward ?? Buffer.alloc(0) : Buffer.alloc(0), requestHeaders: headers, @@ -653,6 +671,7 @@ class GatewayService { fallbackModel: routedModel, method, path, + providerName: resolveProviderLogName(new Headers(), this.config, routedModel), providerProtocol: resolveResponseProviderProtocol(new Headers(), this.config), requestId, responseHeaders: new Headers(), @@ -681,6 +700,7 @@ class GatewayService { fallbackModel: routedModel, method, path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), requestId, responseHeaders, @@ -732,6 +752,7 @@ class GatewayService { fallbackModel: routedModel, method, path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), requestId, responseHeaders, @@ -1624,7 +1645,7 @@ function rewriteProviderListHeader( function rewriteProviderSelectorForProtocol(value: string, config: AppConfig, protocol: GatewayProviderProtocol): string { const provider = findProviderByPublicOrInternalName(config, value); const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined; - return provider && capability ? providerCapabilityInternalName(provider.name, capability.type) : value; + return provider && capability ? providerCapabilityInternalName(provider, capability.type) : value; } function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppConfig, protocol: GatewayProviderProtocol): RouterFallbackConfig { @@ -1669,7 +1690,7 @@ function rewriteModelSelectorForProtocol( const targetModel = publicModel.slice(separator + 1).trim(); const provider = findProviderByPublicOrInternalName(config, providerName); const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined; - return provider && capability ? `${providerCapabilityInternalName(provider.name, capability.type)}/${targetModel}` : publicModel; + return provider && capability ? `${providerCapabilityInternalName(provider, capability.type)}/${targetModel}` : publicModel; } function providerCapabilityForClientProtocol( @@ -1733,15 +1754,19 @@ function findProviderByPublicOrInternalName(config: AppConfig, name: string): Ga } const credentialInternalName = parseProviderCredentialInternalName(name); if (credentialInternalName) { + const internalProviderId = credentialInternalName.providerId.toLowerCase(); return config.Providers.find((provider) => - provider.name.trim().toLowerCase() === credentialInternalName.providerName.toLowerCase() + provider.name.trim().toLowerCase() === internalProviderId || + providerRuntimeId(provider).toLowerCase() === internalProviderId ); } return config.Providers.find((provider) => provider.name.trim().toLowerCase() === normalized || + provider.id?.trim().toLowerCase() === normalized || provider.provider?.trim().toLowerCase() === normalized || + providerRuntimeId(provider).toLowerCase() === normalized || normalizedProviderCapabilities(provider).some((capability) => - providerCapabilityInternalName(provider.name, capability.type).toLowerCase() === normalized + providerCapabilityNameMatches(provider, capability.type, normalized) ) ); } @@ -1753,27 +1778,27 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): } const credentialInternalName = parseProviderCredentialInternalName(providerName); if (credentialInternalName) { - const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerName); + const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerId); if (!provider) { return headers; } const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug); const rewritten = new Headers(headers); - rewritten.set("x-gateway-target-provider-name", provider.name); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol); - rewritten.set("x-ccr-provider-credential-provider", provider.name); - rewritten.set("x-ccr-provider-credential-id", credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug); + rewritten.set("x-ccr-provider-credential-provider", providerRuntimeId(provider)); + rewritten.set("x-ccr-provider-credential-id", providerCredentialSlug(credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug)); return rewritten; } const provider = findProviderByPublicOrInternalName(config, providerName); - if (!provider || provider.name === providerName) { + if (!provider) { return headers; } const capability = normalizedProviderCapabilities(provider).find((item) => - providerCapabilityInternalName(provider.name, item.type).toLowerCase() === providerName.toLowerCase() + providerCapabilityNameMatches(provider, item.type, providerName) ); const rewritten = new Headers(headers); - rewritten.set("x-gateway-target-provider-name", provider.name); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); if (capability) { rewritten.set("x-ccr-provider-protocol", capability.type); } @@ -2008,7 +2033,7 @@ function selectProviderCredentials( return { cooldown, credential, - credentialId: providerCredentialRuntimeId(provider, credential, providerIndex), + credentialId: providerCredentialSlug(providerCredentialRuntimeId(provider, credential, providerIndex)), index: providerIndex, internalName: providerCredentialInternalName(provider, protocol, credential), limitState, @@ -2378,8 +2403,8 @@ function toCoreGatewayProvider( name: credential ? providerCredentialInternalName(provider, type, credential) : capability - ? providerCapabilityInternalName(provider.name, type) - : provider.name, + ? providerCapabilityInternalName(provider, type) + : providerRuntimeId(provider), type }; } @@ -2415,21 +2440,56 @@ function normalizedProviderCapabilities(provider: GatewayProviderConfig): Gatewa return normalized; } -function providerCapabilityInternalName(providerName: string, protocol: GatewayProviderProtocol): string { +function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { + return `${providerRuntimeId(provider)}::${protocol}`; +} + +function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string { return `${providerName}::${protocol}`; } +function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean { + const normalized = value.trim().toLowerCase(); + return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized || + providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; +} + +function providerRuntimeId(provider: GatewayProviderConfig): string { + const explicit = sanitizeProviderHeaderId(provider.id); + if (explicit) { + return explicit; + } + const normalized = provider.name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + const hash = createHash("sha256").update(`${provider.name}\n${readBaseUrl(provider) ?? ""}`).digest("hex").slice(0, 10); + return `provider-${normalized || "provider"}-${hash}`; +} + +function sanitizeProviderHeaderId(value: string | undefined): string | undefined { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || undefined; +} + function providerCredentialInternalName( provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, credential: ProviderCredentialConfig ): string { - return `${providerCapabilityInternalName(provider.name, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; + return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; } function parseProviderCredentialInternalName(value: string | undefined): { credentialSlug: string; - providerName: string; + providerId: string; protocol: GatewayProviderProtocol; } | undefined { const marker = "::cred:"; @@ -2444,8 +2504,8 @@ function parseProviderCredentialInternalName(value: string | undefined): { return undefined; } const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2)); - const providerName = baseName.slice(0, protocolSeparator).trim(); - return protocol && providerName ? { credentialSlug, providerName, protocol } : undefined; + const providerId = baseName.slice(0, protocolSeparator).trim(); + return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined; } function providerCredentialSlug(value: string | undefined): string { @@ -2550,7 +2610,7 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u return normalizeProviderProtocol(providerName); } const capability = normalizedProviderCapabilities(provider).find((item) => - providerCapabilityInternalName(provider.name, item.type).toLowerCase() === providerName.toLowerCase() + providerCapabilityNameMatches(provider, item.type, providerName) ); if (capability) { return capability.type; @@ -2558,9 +2618,27 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); } +function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined { + const providerSelector = + headers.get("x-gateway-target-provider-name")?.trim() || + headers.get("x-gateway-target-provider")?.trim(); + const headerProvider = providerSelector && config + ? findProviderByPublicOrInternalName(config, providerSelector) + : undefined; + if (headerProvider) { + return headerProvider.name; + } + + const routeProvider = parseProviderModelSelector(fallbackModel)?.provider; + const modelProvider = routeProvider && config + ? findProviderByPublicOrInternalName(config, routeProvider) + : undefined; + return modelProvider?.name; +} + function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean { const normalizedName = name.trim().toLowerCase(); - return [provider.name, provider.provider] + return [provider.id, provider.name, provider.provider] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) .some((value) => value.trim().toLowerCase() === normalizedName); } @@ -2862,7 +2940,7 @@ function shouldServeGatewayRequest(config: AppConfig, request: IncomingMessage): function applyCors(response: ServerResponse, config?: AppConfig): void { const origin = config ? endpoint(config.gateway.host, config.gateway.port) : "*"; response.setHeader("Access-Control-Allow-Origin", origin); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Last-Event-ID, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); } @@ -2873,7 +2951,7 @@ function authorize(request: IncomingMessage, response: ServerResponse, config: A return { ok: true }; } - const token = readAuthToken(request.headers); + const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); const apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; if (apiKey) { if (isApiKeyExpired(apiKey)) { @@ -3920,6 +3998,14 @@ function readAuthToken(headers: IncomingHttpHeaders): string | undefined { return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; } +function readRemoteControlQueryAuthToken(request: IncomingMessage): string | undefined { + const url = new URL(request.url || "/", "http://127.0.0.1"); + if (url.pathname !== ccrRemoteControlPathPrefix && !url.pathname.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + return undefined; + } + return url.searchParams.get("api_key")?.trim() || url.searchParams.get("key")?.trim() || undefined; +} + function forwardHeaders(headers: IncomingHttpHeaders): Record { const forwarded: Record = {}; for (const [key, value] of Object.entries(headers)) { diff --git a/src/shared/app.ts b/src/shared/app.ts index f201679..fd29035 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -77,6 +77,7 @@ export type GatewayProviderConfig = { extraBody?: unknown; extraHeaders?: unknown; icon?: string; + id?: string; models: string[]; name: string; provider?: string; From 8d306c562fb9fa77f922c837d8d516aab656f9a4 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 29 Jun 2026 10:28:58 +0800 Subject: [PATCH 003/101] Add CLI web service support and install docs --- README.md | 22 + README_zh.md | 22 + build/build.mjs | 3 +- build/dev.mjs | 28 +- build/esbuild.config.mjs | 58 +- package-lock.json | 3 + package.json | 32 +- src/main/claude-app-gateway-service.ts | 25 +- src/main/cli.ts | 571 +++++++++++-- src/main/electron-node-shim.ts | 14 + src/main/profile-launch-core.ts | 2 +- src/main/web-client-bridge.ts | 112 +++ src/main/web-management-server.ts | 1014 ++++++++++++++++++++++++ src/server/mcp/network-capture-mcp.ts | 9 +- src/server/proxy/service.ts | 21 +- 15 files changed, 1837 insertions(+), 99 deletions(-) create mode 100644 src/main/electron-node-shim.ts create mode 100644 src/main/web-client-bridge.ts create mode 100644 src/main/web-management-server.ts diff --git a/README.md b/README.md index e53da3b..c123f22 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,28 @@ CCR runs on your machine, keeps provider configuration in your local config dire ## Download And Install +### npm CLI + +Install the CLI package when you want to run CCR without the desktop tray or `ccr://` protocol integration: + +```bash +npm install -g claude-code-router +ccr start +``` + +Common commands: + +```bash +ccr start # start the background CCR service and web management UI +ccr stop # stop the background CCR service +ccr cli # launch the saved profile as a CLI +ccr app # launch the saved profile as an app +``` + +The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr start --host --port ` to change it. + +### Desktop App + 1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). 2. Download the package for your platform: - macOS: `Claude Code Router_.dmg` or `.zip` diff --git a/README_zh.md b/README_zh.md index dfe6b3a..45f98cf 100644 --- a/README_zh.md +++ b/README_zh.md @@ -38,6 +38,28 @@ CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默 ## 下载和安装 +### npm CLI + +如果你只需要 CLI 版本,不需要桌面 Tray 或 `ccr://` 协议集成,可以安装 npm 包: + +```bash +npm install -g claude-code-router +ccr start +``` + +常用命令: + +```bash +ccr start # 后台启动 CCR 服务和 Web 管理端 +ccr stop # 停止后台 CCR 服务 +ccr cli # 以 CLI 方式启动保存的 profile +ccr app # 以 App 方式启动保存的 profile +``` + +Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host --port ` 修改监听地址。 + +### 桌面应用 + 1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 2. 按系统下载对应安装包: - macOS:`Claude Code Router_.dmg` 或 `.zip` diff --git a/build/build.mjs b/build/build.mjs index e0bc9d4..a07412b 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,4 +1,4 @@ -import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs"; +import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs"; const mode = process.argv.includes("--dev") ? "development" : "production"; @@ -15,6 +15,7 @@ await Promise.all([ buildBrowserRenderer({ mode }), buildRenderer({ mode }), buildTrayRenderer({ mode }), + buildWebClientBridge({ mode }), buildStyles({ minify: mode === "production" }) ]); diff --git a/build/dev.mjs b/build/dev.mjs index 0511592..a7cf8fd 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -16,9 +16,11 @@ import { copyRendererHtml, copyTrayRendererHtml, createBrowserRendererBuildOptions, + createCliBuildOptions, createMainBuildOptions, createRendererBuildOptions, createTrayRendererBuildOptions, + createWebClientBridgeBuildOptions, cssInput, cssOutput, appAssetsInput, @@ -38,9 +40,11 @@ const restartDelayMs = 160; const ignoredSignatureEntries = new Set([".DS_Store"]); const ready = { browser: false, + cli: false, main: false, renderer: false, - tray: false + tray: false, + webBridge: false }; function logDev(message) { @@ -163,11 +167,11 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on } function markReady(name, reason = `${name} esbuild completed`) { - if (name === "browser" || name === "main" || name === "renderer" || name === "tray") { + if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") { ready[name] = true; } logDev(`build ready: ${reason}; ${readyState()}`); - if (ready.browser && ready.main && ready.renderer && ready.tray) { + if (ready.browser && ready.cli && ready.main && ready.renderer && ready.tray && ready.webBridge) { scheduleRestart(reason); } } @@ -274,6 +278,13 @@ const mainContext = await esbuild.context( }) ); +const cliContext = await esbuild.context( + createCliBuildOptions({ + mode: "development", + plugins: [watchPlugin("cli", (name) => markReady(name))] + }) +); + const rendererContext = await esbuild.context( createRendererBuildOptions({ mode: "development", @@ -310,7 +321,14 @@ const browserRendererContext = await esbuild.context( }) ); -await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]); +const webClientBridgeContext = await esbuild.context( + createWebClientBridgeBuildOptions({ + mode: "development", + plugins: [watchPlugin("webBridge", (name) => markReady(name))] + }) +); + +await Promise.all([mainContext.watch(), cliContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch(), webClientBridgeContext.watch()]); logDev("watchers are active"); async function shutdown() { @@ -328,7 +346,7 @@ async function shutdown() { trayHtmlWatcher.close(); appAssetsWatcher.close(); modelCatalogWatcher.close(); - await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]); + await Promise.all([mainContext.dispose(), cliContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose(), webClientBridgeContext.dispose()]); process.exit(0); } diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 3507935..3283c23 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -26,6 +26,7 @@ export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "i export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html"); export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); +export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); const nodeExternals = [ "electron", @@ -110,7 +111,6 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { entryPoints: [ path.join(projectRoot, "src", "main", "main.ts"), path.join(projectRoot, "src", "main", "browser-preload.ts"), - path.join(projectRoot, "src", "main", "cli.ts"), path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), path.join(projectRoot, "src", "main", "preload.ts") ], @@ -127,6 +127,25 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { }; } +export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints: [path.join(projectRoot, "src", "main", "cli.ts")], + external: nodeExternals.filter((moduleName) => moduleName !== "electron"), + format: "cjs", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outdir: mainOutDir, + platform: "node", + plugins: [electronNodeShimPlugin(), ...plugins], + sourcemap: mode !== "production", + target: "node22" + }; +} + export function createRendererBuildOptions({ mode = "production", plugins = [] } = {}) { return { absWorkingDir: projectRoot, @@ -175,6 +194,23 @@ export function createBrowserRendererBuildOptions({ mode = "production", plugins }; } +export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryPoints: [path.join(projectRoot, "src", "main", "web-client-bridge.ts")], + format: "iife", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outfile: webClientBridgeOutput, + platform: "browser", + plugins, + sourcemap: mode !== "production", + target: "chrome120" + }; +} + export function watchPlugin(name, onEnd) { return { name: `${name}-watch`, @@ -189,7 +225,10 @@ export function watchPlugin(name, onEnd) { } export async function buildMain(options = {}) { - await esbuild.build(createMainBuildOptions(options)); + await Promise.all([ + esbuild.build(createMainBuildOptions(options)), + esbuild.build(createCliBuildOptions(options)) + ]); } export async function buildRenderer(options = {}) { @@ -204,6 +243,10 @@ export async function buildBrowserRenderer(options = {}) { await esbuild.build(createBrowserRendererBuildOptions(options)); } +export async function buildWebClientBridge(options = {}) { + await esbuild.build(createWebClientBridgeBuildOptions(options)); +} + export async function buildStyles({ minify = false } = {}) { ensureDist(); const args = ["-i", cssInput, "-o", cssOutput]; @@ -249,6 +292,17 @@ function rendererAliasPlugin() { }; } +function electronNodeShimPlugin() { + return { + name: "electron-node-shim", + setup(build) { + build.onResolve({ filter: /^electron$/ }, () => { + return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") }; + }); + } + }; +} + function resolveRendererImport(importPath) { const basePath = path.resolve(rendererRoot, importPath); const candidates = [ diff --git a/package-lock.json b/package-lock.json index a741305..f3783f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,9 @@ "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@babel/runtime": { diff --git a/package.json b/package.json index 1a070ff..8cc0416 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,39 @@ { "name": "claude-code-router", - "private": true, "version": "3.0.2", "license": "MIT", - "description": "Desktop scaffold for Claude Code Router.", + "description": "Local Claude Code Router gateway with CLI and web management UI.", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/musistudio/claude-code-router.git" + }, + "bugs": { + "url": "https://github.com/musistudio/claude-code-router/issues" + }, + "homepage": "https://github.com/musistudio/claude-code-router#readme", + "keywords": [ + "claude-code", + "codex", + "llm", + "gateway", + "router" + ], "main": "dist/main/main.js", "bin": { "ccr": "dist/main/cli.js" }, + "files": [ + "dist", + "LICENSE", + "README.md", + "README_zh.md" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, "scripts": { "dev": "node build/dev.mjs", "build": "npm run build:assets && electron-builder", @@ -16,6 +42,8 @@ "build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never", "build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never", "build:app:win": "npm run build:assets && electron-builder --win", + "prepack": "npm run build:assets", + "prepublishOnly": "npm run typecheck", "preview": "npm run build:assets && electron .", "typecheck": "tsc --noEmit", "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index c9494a8..66da1af 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -1,4 +1,4 @@ -import { app } from "electron"; +import * as electron from "electron"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -236,13 +236,30 @@ function getClaudeAppGatewayPaths(dataDir = getClaudeApp3pDataDir()): ClaudeAppG function getClaudeApp3pDataDir(): string { if (process.platform === "darwin") { - return path.join(app.getPath("home"), "Library", "Application Support", "Claude-3p"); + return path.join(appPath("home"), "Library", "Application Support", "Claude-3p"); } if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA || path.join(app.getPath("appData"), "..", "Local"); + const localAppData = process.env.LOCALAPPDATA || path.join(appPath("appData"), "..", "Local"); return path.join(localAppData, "Claude-3p"); } - return path.join(app.getPath("appData") || os.homedir(), "Claude-3p"); + return path.join(appPath("appData") || os.homedir(), "Claude-3p"); +} + +function appPath(name: "appData" | "home"): string { + const electronApp = electronAppOrUndefined(); + if (electronApp) { + return electronApp.getPath(name); + } + if (name === "home") { + return os.homedir(); + } + return process.env.APPDATA || + process.env.LOCALAPPDATA || + (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), ".config")); +} + +function electronAppOrUndefined(): Electron.App | undefined { + return typeof electron.app?.getPath === "function" ? electron.app : undefined; } function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void { diff --git a/src/main/cli.ts b/src/main/cli.ts index 9507874..3d745ca 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -1,38 +1,100 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import os from "node:os"; +import { spawn, spawnSync } from "node:child_process"; +import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; -import type { AppConfig, ProfileOpenSurface } from "../shared/app"; +import type { ProfileOpenSurface } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch"; -import { createBetterSqliteDatabase } from "./sqlite-native"; +import { loadAppConfig } from "./config"; +import { CONFIGDIR } from "./constants"; import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core"; +import { startWebManagementServer } from "./web-management-server"; -type CliOptions = { +type ProfileCliOptions = { agentArgs: string[]; + command: "profile"; help: boolean; profileRef: string; surface?: ProfileOpenSurface; }; +type WebCliOptions = { + command: "start" | "web"; + daemonChild: boolean; + help: boolean; + host?: string; + open: boolean; + port?: number; + startGateway: boolean; +}; + +type StopCliOptions = { + command: "stop"; + help: boolean; +}; + +type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions; + +type ServiceState = { + host?: string; + pid: number; + startedAt: string; + startGateway: boolean; + url: string; +}; + +const serviceStateFileName = "service.json"; +const serviceStartTimeoutMs = 30_000; +const serviceStopTimeoutMs = 10_000; + async function main(): Promise { - const options = parseArgs(process.argv.slice(2)); - if (options.help || !options.profileRef) { - printHelp(options.help ? 0 : 2); + const delegatedExitCode = delegateManagedDesktopCliToExternalCli(); + if (delegatedExitCode !== undefined) { + process.exitCode = delegatedExitCode; return; } - const configFile = process.env.CCR_CONFIG_FILE?.trim() || defaultConfigFile(); - const configDir = path.dirname(configFile); - const config = readConfig(configFile, process.env.CCR_CONFIG_DB_FILE?.trim() || defaultConfigDbFile(configFile)); - const profile = findProfileForOpen(config, options.profileRef); - const surface = options.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); + const options = parseArgs(process.argv.slice(2)); + if (options.command === "start") { + if (options.help) { + printStartHelp(0); + return; + } + await startService(options); + return; + } + if (options.command === "stop") { + if (options.help) { + printStopHelp(0); + return; + } + await stopService(); + return; + } + if (options.command === "web") { + if (options.help) { + printWebHelp(0); + return; + } + await runWebServer(options); + return; + } + + const profileOptions = options as ProfileCliOptions; + if (profileOptions.help || !profileOptions.profileRef) { + printHelp(profileOptions.help ? 0 : 2); + return; + } + + const configDir = CONFIGDIR; + const config = await loadAppConfig(); + const profile = findProfileForOpen(config, profileOptions.profileRef); + const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); const resolvedSurface = resolveProfileOpenSurface(profile, surface); - if (profile.agent === "zcode" && options.agentArgs.length > 0) { + if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); } - if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && options.agentArgs.length === 0) { + if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) { if (profile.agent === "zcode") { const launch = launchZcodeAppProfile(configDir, profile, config); const spawnError = await waitForImmediateSpawnError(launch.child, 500); @@ -51,7 +113,7 @@ async function main(): Promise { return; } - const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, options.agentArgs); + const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs); if (path.isAbsolute(plan.command) && !existsSync(plan.command)) { throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`); @@ -75,8 +137,19 @@ async function main(): Promise { } function parseArgs(args: string[]): CliOptions { - const options: CliOptions = { + if (args[0] === "start") { + return parseWebArgs(args.slice(1), "start"); + } + if (args[0] === "stop") { + return parseStopArgs(args.slice(1)); + } + if (args[0] === "serve" || args[0] === "web") { + return parseWebArgs(args.slice(1), "web"); + } + + const options: ProfileCliOptions = { agentArgs: [], + command: "profile", help: false, profileRef: "" }; @@ -98,6 +171,10 @@ function parseArgs(args: string[]): CliOptions { options.surface = "cli"; continue; } + if (options.profileRef && !options.surface && (arg === "cli" || arg === "app")) { + options.surface = arg; + continue; + } if (!options.profileRef) { options.profileRef = arg; continue; @@ -107,85 +184,429 @@ function parseArgs(args: string[]): CliOptions { return options; } -function readConfig(jsonFile: string, dbFile: string): AppConfig { - const sqliteConfig = readSqliteConfig(dbFile); - if (sqliteConfig) { - return normalizeCliConfig(sqliteConfig, dbFile); +function parseStopArgs(args: string[]): StopCliOptions { + const options: StopCliOptions = { + command: "stop", + help: false + }; + for (const arg of args) { + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + throw new Error(`Unknown stop option: ${arg}`); } - if (!existsSync(jsonFile)) { - throw new Error(`CCR config was not found: ${dbFile}`); - } - const parsed = JSON.parse(readFileSync(jsonFile, "utf8")) as Partial; - return normalizeCliConfig(parsed, jsonFile); + return options; } -function readSqliteConfig(file: string): Partial | undefined { - if (!existsSync(file)) { - return undefined; - } - let database: ReturnType | undefined; - try { - database = createBetterSqliteDatabase(file); - const row = database.prepare("SELECT value_json FROM app_config WHERE key = ? LIMIT 1").get("default") as { value_json?: unknown } | undefined; - return typeof row?.value_json === "string" - ? JSON.parse(row.value_json) as Partial - : undefined; - } catch { - return undefined; - } finally { - database?.close(); +function parseWebArgs(args: string[], command: WebCliOptions["command"]): WebCliOptions { + const options: WebCliOptions = { + command, + daemonChild: false, + help: false, + open: false, + startGateway: true + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--open") { + options.open = true; + continue; + } + if (arg === "--no-open") { + options.open = false; + continue; + } + if (arg === "--gateway") { + options.startGateway = true; + continue; + } + if (arg === "--no-gateway") { + options.startGateway = false; + continue; + } + if (arg === "--daemon-child") { + options.daemonChild = true; + continue; + } + if (arg === "--host") { + index += 1; + options.host = requiredArg(args[index], "--host"); + continue; + } + if (arg.startsWith("--host=")) { + options.host = requiredArg(arg.slice("--host=".length), "--host"); + continue; + } + if (arg === "--port") { + index += 1; + options.port = parsePort(requiredArg(args[index], "--port")); + continue; + } + if (arg.startsWith("--port=")) { + options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port")); + continue; + } + throw new Error(`Unknown web option: ${arg}`); } + return options; } -function normalizeCliConfig(parsed: Partial, source: string): AppConfig { - if (!parsed.profile || !Array.isArray(parsed.profile.profiles)) { - throw new Error(`CCR config has no profiles: ${source}`); +async function startService(options: WebCliOptions): Promise { + const current = readServiceState(); + if (current && isProcessRunning(current.pid)) { + process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`); + return; } - return { - ...parsed, - profile: { - ...parsed.profile, - profiles: parsed.profile.profiles - } as AppConfig["profile"] - } as AppConfig; -} + clearServiceState(); -function defaultConfigDbFile(configFile: string): string { - return path.join(path.dirname(configFile), "config.sqlite"); -} - -function defaultConfigFile(): string { - return path.join(defaultConfigDir(), "config.json"); -} - -function defaultConfigDir(): string { - if (process.platform === "win32") { - return path.join( - process.env.APPDATA || - process.env.LOCALAPPDATA || - (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")), - "Claude Code Router" - ); + const childArgs = [ + currentCliScript(), + "serve", + "--daemon-child", + ...(options.host ? ["--host", options.host] : []), + ...(options.port ? ["--port", String(options.port)] : []), + ...(options.open ? ["--open"] : ["--no-open"]), + ...(options.startGateway ? [] : ["--no-gateway"]) + ]; + const child = spawn(process.execPath, childArgs, { + detached: true, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: undefined + }, + stdio: "ignore", + windowsHide: true + }); + const spawnError = await waitForImmediateSpawnError(child, 1000); + if (spawnError) { + throw new Error(`Failed to start CCR service: ${spawnError}`); } - return path.join(os.homedir(), ".claude-code-router"); + child.unref(); + + const state = await waitForServiceState(child.pid, serviceStartTimeoutMs); + if (!state) { + throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`); + } + process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); +} + +async function runWebServer(options: WebCliOptions): Promise { + const runtime = await startWebManagementServer({ + host: options.host, + open: options.open, + port: options.port, + startGateway: options.startGateway + }); + if (options.daemonChild) { + writeServiceState({ + host: options.host, + pid: process.pid, + startedAt: new Date().toISOString(), + startGateway: options.startGateway, + url: runtime.url + }); + } + process.stdout.write(`CCR web management is running at ${runtime.url}\n`); + + let closing = false; + const shutdown = (signal: NodeJS.Signals) => { + if (closing) { + return; + } + closing = true; + void runtime.close().finally(() => { + if (options.daemonChild) { + clearServiceState(process.pid); + } + process.exit(signal === "SIGINT" ? 130 : 143); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + await new Promise(() => undefined); +} + +async function stopService(): Promise { + const state = readServiceState(); + if (!state) { + process.stdout.write("CCR service is not running.\n"); + return; + } + if (!isProcessRunning(state.pid)) { + clearServiceState(state.pid); + process.stdout.write("CCR service is not running.\n"); + return; + } + process.kill(state.pid, "SIGTERM"); + const stopped = await waitForProcessExit(state.pid, serviceStopTimeoutMs); + if (!stopped && isProcessRunning(state.pid)) { + throw new Error(`CCR service pid ${state.pid} did not stop within ${serviceStopTimeoutMs}ms.`); + } + clearServiceState(state.pid); + process.stdout.write("CCR service stopped.\n"); } function printHelp(exitCode: number): void { const output = [ "Usage:", - " ccr [--cli|--app] [-- ]", + " ccr start [--host ] [--port ] [--open] [--no-gateway]", + " ccr stop", + " ccr [-- ]", "", "Examples:", - " ccr Codex", - " ccr default-codex -- --model gpt-5-codex", - " ccr default-codex --app", - " ccr ZCode" + " ccr start", + " ccr stop", + " ccr Codex cli", + " ccr default-codex cli -- --model gpt-5-codex", + " ccr default-codex app" ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; stream.write(`${output}\n`); process.exitCode = exitCode; } +function printStartHelp(exitCode: number): void { + const output = [ + "Usage:", + " ccr start [--host ] [--port ] [--open] [--no-gateway]", + "", + "Options:", + " --host Management server host. Defaults to 127.0.0.1.", + " --port Management server port. Defaults to 3458.", + " --open Open the management page in the default browser.", + " --no-open Do not open the management page.", + " --no-gateway Start only the web management server." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printStopHelp(exitCode: number): void { + const output = [ + "Usage:", + " ccr stop", + "", + "Stops the background CCR service started by `ccr start`." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printWebHelp(exitCode: number): void { + const output = [ + "Usage:", + " ccr serve [--host ] [--port ] [--open] [--no-gateway]", + "", + "Options:", + " --host Management server host. Defaults to 127.0.0.1.", + " --port Management server port. Defaults to 3458.", + " --open Open the management page in the default browser.", + " --no-gateway Start only the web management server." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function readServiceState(): ServiceState | undefined { + const file = serviceStateFile(); + if (!existsSync(file)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial; + const pid = Number(parsed.pid); + if (!Number.isInteger(pid) || pid <= 0 || typeof parsed.url !== "string") { + return undefined; + } + return { + host: parsed.host, + pid, + startedAt: parsed.startedAt || "", + startGateway: parsed.startGateway !== false, + url: parsed.url + }; + } catch { + return undefined; + } +} + +function writeServiceState(state: ServiceState): void { + const file = serviceStateFile(); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +function clearServiceState(pid?: number): void { + const state = readServiceState(); + if (pid !== undefined && state && state.pid !== pid) { + return; + } + try { + unlinkSync(serviceStateFile()); + } catch { + // Stale state cleanup is best effort. + } +} + +function serviceStateFile(): string { + return path.join(CONFIGDIR, serviceStateFileName); +} + +function currentCliScript(): string { + return __filename; +} + +function delegateManagedDesktopCliToExternalCli(): number | undefined { + if (!isManagedDesktopCliRuntime()) { + return undefined; + } + if (process.env.CCR_MANAGED_CLI_NO_DELEGATE === "1" || process.env.CCR_MANAGED_CLI_DELEGATED === "1") { + return undefined; + } + + const externalCcr = findExternalCcrCommand(); + if (!externalCcr) { + return undefined; + } + + const launch = profileLaunchSpawnCommand({ + args: process.argv.slice(2), + command: externalCcr + }); + const result = spawnSync(launch.command, launch.args, { + env: { + ...process.env, + CCR_MANAGED_CLI_DELEGATED: "1" + }, + stdio: "inherit", + windowsVerbatimArguments: !!launch.windowsVerbatimArguments + }); + if (result.error) { + return undefined; + } + if (typeof result.status === "number") { + return result.status; + } + return result.signal === "SIGINT" ? 130 : 1; +} + +function isManagedDesktopCliRuntime(): boolean { + const script = process.argv[1] || __filename; + return samePath(path.resolve(script), path.join(CONFIGDIR, "bin", "ccr-cli.js")); +} + +function findExternalCcrCommand(): string | undefined { + const pathKey = process.platform === "win32" + ? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path" + : "PATH"; + const pathValue = process.env[pathKey] || ""; + const managedBinDir = path.resolve(CONFIGDIR, "bin"); + const names = process.platform === "win32" + ? ["ccr.cmd", "ccr.exe", "ccr.bat", "ccr"] + : ["ccr"]; + + for (const rawSegment of pathValue.split(path.delimiter)) { + const dir = path.resolve(rawSegment || "."); + if (samePath(dir, managedBinDir)) { + continue; + } + for (const name of names) { + const candidate = path.join(dir, name); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return undefined; +} + +function isExecutableFile(file: string): boolean { + try { + const stats = statSync(file); + if (!stats.isFile() && !stats.isSymbolicLink()) { + return false; + } + if (process.platform === "win32") { + return true; + } + accessSync(file, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.normalize(left); + const normalizedRight = path.normalize(right); + return process.platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const state = readServiceState(); + if (state && (!pid || state.pid === pid) && isProcessRunning(state.pid)) { + return state; + } + await delay(150); + } + return undefined; +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; + } + await delay(150); + } + return !isProcessRunning(pid); +} + +function isProcessRunning(pid: number | undefined): boolean { + if (!pid || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + return code === "EPERM"; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function requiredArg(value: string | undefined, option: string): string { + if (!value?.trim()) { + throw new Error(`${option} requires a value.`); + } + return value.trim(); +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; +} + function waitForChild(child: ReturnType): Promise { return new Promise((resolve) => { child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1))); diff --git a/src/main/electron-node-shim.ts b/src/main/electron-node-shim.ts new file mode 100644 index 0000000..212ed6a --- /dev/null +++ b/src/main/electron-node-shim.ts @@ -0,0 +1,14 @@ +export const app = undefined; +export const BrowserWindow = undefined; +export const clipboard = undefined; +export const contextBridge = undefined; +export const dialog = undefined; +export const ipcMain = undefined; +export const ipcRenderer = undefined; +export const Menu = undefined; +export const nativeImage = undefined; +export const screen = undefined; +export const session = undefined; +export const shell = undefined; +export const Tray = undefined; +export const WebContentsView = undefined; diff --git a/src/main/profile-launch-core.ts b/src/main/profile-launch-core.ts index b2d2139..5f50af2 100644 --- a/src/main/profile-launch-core.ts +++ b/src/main/profile-launch-core.ts @@ -76,7 +76,7 @@ export function profileOpenCommand( profileRef = profile.name?.trim() || profile.id ): string { const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote; - return [quote(command), quote(profileRef), ...(surface === "app" ? ["--app"] : [])].join(" "); + return [quote(command), quote(profileRef), surface].join(" "); } export function buildProfileLaunchPlan( diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts new file mode 100644 index 0000000..f720d17 --- /dev/null +++ b/src/main/web-client-bridge.ts @@ -0,0 +1,112 @@ +const rpcEndpoint = "/api/ccr/rpc"; + +type RpcResponse = + | { ok: true; value: unknown } + | { error: { message: string; stack?: string }; ok: false }; + +async function rpc(method: string, args: unknown[] = []): Promise { + const response = await fetch(rpcEndpoint, { + body: JSON.stringify({ args, method }), + headers: { + "content-type": "application/json" + }, + method: "POST" + }); + let payload: RpcResponse | undefined; + try { + payload = await response.json() as RpcResponse; + } catch { + payload = undefined; + } + if (!response.ok || !payload?.ok) { + const message = payload && !payload.ok + ? payload.error.message + : `CCR web API failed with HTTP ${response.status}`; + throw new Error(message); + } + return payload.value; +} + +function noopSubscription(): () => void { + return () => undefined; +} + +async function selectPluginDirectory(): Promise { + const directory = window.prompt("Plugin directory path"); + if (!directory?.trim()) { + return undefined; + } + return rpc("selectPluginDirectory", [directory.trim()]); +} + +window.ccr = { + applyClaudeAppGateway: (config) => rpc("applyClaudeAppGateway", [config]) as ReturnType["applyClaudeAppGateway"]>, + applyProfile: () => rpc("applyProfile") as ReturnType["applyProfile"]>, + cancelBotGatewayQrLogin: (request) => rpc("cancelBotGatewayQrLogin", [request]) as ReturnType["cancelBotGatewayQrLogin"]>, + checkProviderConnectivity: (request) => rpc("checkProviderConnectivity", [request]) as ReturnType["checkProviderConnectivity"]>, + clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType["clearProxyNetworkCaptures"]>, + closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType["closeBotGatewayQrWindow"]>, + closeTray: () => Promise.resolve(), + detectProviderIcon: (request) => rpc("detectProviderIcon", [request]) as ReturnType["detectProviderIcon"]>, + exportData: () => rpc("exportData") as ReturnType["exportData"]>, + fetchProviderManifest: (request) => rpc("fetchProviderManifest", [request]) as ReturnType["fetchProviderManifest"]>, + getAgentAnalysis: (filter) => rpc("getAgentAnalysis", [filter]) as ReturnType["getAgentAnalysis"]>, + getAgentTracePayload: (request) => rpc("getAgentTracePayload", [request]) as ReturnType["getAgentTracePayload"]>, + getAppInfo: () => rpc("getAppInfo") as ReturnType["getAppInfo"]>, + getConfig: () => rpc("getConfig") as ReturnType["getConfig"]>, + getGatewayStatus: () => rpc("getGatewayStatus") as ReturnType["getGatewayStatus"]>, + getLocalAgentProviderCandidates: () => rpc("getLocalAgentProviderCandidates") as ReturnType["getLocalAgentProviderCandidates"]>, + getOnboardingFinished: () => rpc("getOnboardingFinished") as ReturnType["getOnboardingFinished"]>, + getPendingProviderDeepLinks: () => Promise.resolve([]), + getPluginMarketplace: () => rpc("getPluginMarketplace") as ReturnType["getPluginMarketplace"]>, + getProfileOpenCommand: (request) => rpc("getProfileOpenCommand", [request]) as ReturnType["getProfileOpenCommand"]>, + getProfileRuntimeStatus: () => rpc("getProfileRuntimeStatus") as ReturnType["getProfileRuntimeStatus"]>, + getProviderAccountSnapshots: (provider, options) => rpc("getProviderAccountSnapshots", [provider, options]) as ReturnType["getProviderAccountSnapshots"]>, + getProviderCatalogModels: (request) => rpc("getProviderCatalogModels", [request]) as ReturnType["getProviderCatalogModels"]>, + getProviderPresets: () => rpc("getProviderPresets") as ReturnType["getProviderPresets"]>, + getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType["getProxyCertificateStatus"]>, + getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType["getProxyNetworkCaptures"]>, + getProxyStatus: () => rpc("getProxyStatus") as ReturnType["getProxyStatus"]>, + getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType["getRequestLogs"]>, + getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType["getUpdateStatus"]>, + getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType["getUsageStats"]>, + importLocalAgentProvider: (request) => rpc("importLocalAgentProvider", [request]) as ReturnType["importLocalAgentProvider"]>, + installProxyCertificate: () => rpc("installProxyCertificate") as ReturnType["installProxyCertificate"]>, + listMcpServerTools: (serverName) => rpc("listMcpServerTools", [serverName]) as ReturnType["listMcpServerTools"]>, + onBeforeQuit: noopSubscription, + onOpenSettingsRequest: noopSubscription, + onOpenUpdateRequest: noopSubscription, + onProviderDeepLink: noopSubscription, + onUpdateStatusChanged: noopSubscription, + openBotGatewayQrWindow: (request) => rpc("openBotGatewayQrWindow", [request]) as ReturnType["openBotGatewayQrWindow"]>, + openBuiltInBrowser: () => rpc("openBuiltInBrowser") as ReturnType["openBuiltInBrowser"]>, + openExternal: (url) => { + window.open(url, "_blank", "noopener,noreferrer"); + return Promise.resolve(); + }, + openProfile: (request) => rpc("openProfile", [request]) as ReturnType["openProfile"]>, + probeProvider: (request) => rpc("probeProvider", [request]) as ReturnType["probeProvider"]>, + probeProviderCandidates: (request) => rpc("probeProviderCandidates", [request]) as ReturnType["probeProviderCandidates"]>, + quitApp: () => rpc("quitApp") as ReturnType["quitApp"]>, + restartGateway: () => rpc("restartGateway") as ReturnType["restartGateway"]>, + restartProxy: () => rpc("restartProxy") as ReturnType["restartProxy"]>, + revealProxyCertificate: () => rpc("revealProxyCertificate") as ReturnType["revealProxyCertificate"]>, + saveApiKeys: (apiKeys) => rpc("saveApiKeys", [apiKeys]) as ReturnType["saveApiKeys"]>, + saveConfig: (config, options) => rpc("saveConfig", [config, options]) as ReturnType["saveConfig"]>, + scanBotHandoffBluetoothTargets: () => rpc("scanBotHandoffBluetoothTargets") as ReturnType["scanBotHandoffBluetoothTargets"]>, + scanBotHandoffWifiTargets: () => rpc("scanBotHandoffWifiTargets") as ReturnType["scanBotHandoffWifiTargets"]>, + selectPluginDirectory: () => selectPluginDirectory() as ReturnType["selectPluginDirectory"]>, + setOnboardingFinished: () => rpc("setOnboardingFinished") as ReturnType["setOnboardingFinished"]>, + setProxyNetworkCaptureEnabled: (enabled) => rpc("setProxyNetworkCaptureEnabled", [enabled]) as ReturnType["setProxyNetworkCaptureEnabled"]>, + setTrayDetailOpen: () => Promise.resolve(), + showMainWindow: () => Promise.resolve(), + startBotGatewayQrLogin: (request) => rpc("startBotGatewayQrLogin", [request]) as ReturnType["startBotGatewayQrLogin"]>, + startGateway: () => rpc("startGateway") as ReturnType["startGateway"]>, + stopGateway: () => rpc("stopGateway") as ReturnType["stopGateway"]>, + stopProfile: (request) => rpc("stopProfile", [request]) as ReturnType["stopProfile"]>, + testProviderAccountConnector: (request) => rpc("testProviderAccountConnector", [request]) as ReturnType["testProviderAccountConnector"]>, + updateCheck: () => rpc("updateCheck") as ReturnType["updateCheck"]>, + updateDownload: () => rpc("updateDownload") as ReturnType["updateDownload"]>, + updateInstall: () => rpc("updateInstall") as ReturnType["updateInstall"]>, + waitBotGatewayQrLogin: (request) => rpc("waitBotGatewayQrLogin", [request]) as ReturnType["waitBotGatewayQrLogin"]> +}; diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts new file mode 100644 index 0000000..c22697f --- /dev/null +++ b/src/main/web-management-server.ts @@ -0,0 +1,1014 @@ +import { spawn } from "node:child_process"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import packageJson from "../../package.json"; +import { loadPersistedAppSetting, replacePersistedAppSetting } from "./app-config-store"; +import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service"; +import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service"; +import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config"; +import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants"; +import { detectProviderIcon } from "./provider-icons"; +import { fetchProviderManifest } from "./provider-manifest-service"; +import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "./local-agent-provider-service"; +import { getProviderCatalogModels } from "./provider-model-catalog"; +import { getProviderPresets } from "./presets"; +import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe"; +import { applyProfileConfig } from "./profile-service"; +import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "./profile-launch-service"; +import { ensureProxyCertificateAuthority } from "../server/proxy/certificates"; +import { proxyService } from "../server/proxy/service"; +import { listMcpServerTools } from "../server/mcp/tool-discovery"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store"; +import { getUsageStats } from "./usage-store"; +import { gatewayService } from "../server/gateway/service"; +import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service"; +import type { + AgentAnalysisFilter, + AgentAnalysisTracePayloadRequest, + ApiKeyConfig, + AppConfig, + AppDataExportResult, + AppInfo, + AppSaveConfigOptions, + AppUpdateStatus, + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginWaitRequest, + BotGatewayQrWindowOpenRequest, + GatewayPluginAppConfig, + GatewayProviderConnectivityCheckRequest, + GatewayProviderProbeCandidatesRequest, + GatewayProviderProbeRequest, + GatewayStatus, + LocalAgentProviderImportRequest, + PluginDependency, + PluginDirectorySelection, + PluginMarketplaceEntry, + ProfileApplyResult, + ProfileOpenRequest, + ProviderAccountSnapshotRequestOptions, + ProviderAccountTestRequest, + ProviderCatalogModelsRequest, + ProviderIconDetectionRequest, + ProviderManifestFetchRequest, + RequestLogListFilter, + UsageStatsFilter, + UsageStatsRange +} from "../shared/app"; + +export type WebManagementServerOptions = { + host?: string; + open?: boolean; + port?: number; + startGateway?: boolean; +}; + +export type WebManagementServerRuntime = { + close: () => Promise; + server: Server; + url: string; +}; + +type RpcRequest = { + args?: unknown[]; + method?: string; +}; + +type RpcHandler = (...args: unknown[]) => Promise | unknown; + +const defaultWebHost = "127.0.0.1"; +const defaultWebPort = 3458; +const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; +const maxRpcBodyBytes = 8 * 1024 * 1024; +const staticRoot = path.resolve(__dirname, "..", "renderer"); +const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html"); +const rendererAssetsRoot = path.join(staticRoot, "assets"); +const webBridgeScriptTag = ' '; + +const pluginMarketplace: PluginMarketplaceEntry[] = [ + { + capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"], + dependencies: [], + description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.", + id: "claude-design", + modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"), + name: "Claude Design" + }, + { + capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"], + dependencies: [], + description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.", + id: "cursor-proxy", + modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"), + name: "Cursor Proxy" + } +]; + +export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise { + const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost; + const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort; + const server = createServer((request, response) => { + void handleRequest(request, response).catch((error) => { + sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); + }); + }); + + const listenedPort = await listenWithFallback(server, requestedPort, host); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : listenedPort; + const url = `http://${formatListenHost(host)}:${port}/`; + + if (options.startGateway !== false) { + await startConfiguredServices("web startup"); + } + if (options.open) { + await openSystemExternal(url).catch((error) => { + console.warn(`[web] Failed to open ${url}: ${formatError(error)}`); + }); + } + + return { + close: async () => { + await closeServer(server); + await stopConfiguredServices(); + }, + server, + url + }; +} + +async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + const url = requestUrl(request); + if (url.pathname === "/api/ccr/rpc") { + await handleRpcRequest(request, response); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + sendText(response, 405, "Method not allowed"); + return; + } + if (url.pathname === "/" || url.pathname === "/pages/home/index.html") { + sendHomeHtml(response, request.method === "HEAD"); + return; + } + if (url.pathname.startsWith("/assets/")) { + sendStaticFile(response, rendererAssetsRoot, decodeURIComponent(url.pathname.slice("/assets/".length)), request.method === "HEAD"); + return; + } + sendText(response, 404, "Not found"); +} + +async function handleRpcRequest(request: IncomingMessage, response: ServerResponse): Promise { + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false }); + return; + } + + let payload: RpcRequest; + try { + payload = JSON.parse((await readRequestBody(request, maxRpcBodyBytes)).toString("utf8")) as RpcRequest; + } catch (error) { + sendJson(response, 400, { error: { message: `Invalid JSON: ${formatError(error)}` }, ok: false }); + return; + } + + const method = typeof payload.method === "string" ? payload.method.trim() : ""; + const handler = rpcHandlers[method]; + if (!method || !handler) { + sendJson(response, 404, { error: { message: `Unknown CCR web RPC method: ${method || "(empty)"}` }, ok: false }); + return; + } + + try { + const value = await handler(...(Array.isArray(payload.args) ? payload.args : [])); + sendJson(response, 200, { ok: true, value }); + } catch (error) { + sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); + } +} + +const unsupportedUpdateStatus: AppUpdateStatus = { + canCheck: false, + canDownload: false, + canInstall: false, + currentVersion: packageJson.version, + lastError: "Updates are only available in the desktop app.", + state: "idle", + supported: false +}; + +const rpcHandlers: Record = { + applyClaudeAppGateway: async (config?: unknown) => { + const previousConfig = await loadAppConfig(); + const baseConfig = config ? await saveAppConfig(config as AppConfig) : previousConfig; + const synced = await syncClaudeAppGatewayConfig(baseConfig); + const savedConfig = synced.config; + let runtimeStatus = gatewayService.getStatus(); + if (synced.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { + runtimeStatus = await gatewayService.start(savedConfig); + } else { + gatewayService.updateConfig(savedConfig); + } + if (config || synced.configChanged) { + invalidateProviderAccountSnapshotCache(); + } + const gatewayDetail = runtimeStatus.state === "running" + ? "CCR gateway is running." + : `CCR gateway did not start: ${runtimeStatus.lastError || "unknown error"}`; + const apiKeyDetail = synced.result.apiKeyGenerated ? "Generated a Claude App API key." : "Reused an existing CCR API key."; + return { + ...synced.result, + message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}` + }; + }, + applyProfile: async () => applyProfileConfig(await loadAppConfig()), + cancelBotGatewayQrLogin: (request) => cancelBotGatewayQrLogin(request as BotGatewayQrLoginCancelRequest), + checkProviderConnectivity: (request) => checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest), + clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(), + closeBotGatewayQrWindow: (_request) => ({ closed: false }), + detectProviderIcon: (request) => detectProviderIcon(request as ProviderIconDetectionRequest), + exportData: () => exportAppData(), + fetchProviderManifest: (request) => fetchProviderManifest(request as ProviderManifestFetchRequest), + getAgentAnalysis: (filter) => getAgentAnalysis(filter as AgentAnalysisFilter | undefined), + getAgentTracePayload: (request) => getAgentTracePayload(request as AgentAnalysisTracePayloadRequest), + getAppInfo: () => getCliAppInfo(), + getConfig: () => loadAppConfig(), + getGatewayStatus: () => gatewayService.getStatus(), + getLocalAgentProviderCandidates: () => getLocalAgentProviderCandidates(), + getOnboardingFinished: async () => Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)), + getPluginMarketplace: () => pluginMarketplace, + getProfileOpenCommand: async (request) => getProfileOpenCommand(await loadAppConfig(), request as ProfileOpenRequest), + getProfileRuntimeStatus: () => getProfileRuntimeStatus(), + getProviderAccountSnapshots: (provider, options) => getProviderAccountSnapshots(provider as string | undefined, options as ProviderAccountSnapshotRequestOptions | undefined), + getProviderCatalogModels: (request) => getProviderCatalogModels(request as ProviderCatalogModelsRequest), + getProviderPresets: () => getProviderPresets(), + getProxyCertificateStatus: () => proxyService.getCertificateStatus(), + getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(), + getProxyStatus: () => proxyService.getStatus(), + getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined), + getUpdateStatus: () => unsupportedUpdateStatus, + getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined), + importLocalAgentProvider: (request) => importLocalAgentProvider(request as LocalAgentProviderImportRequest), + installProxyCertificate: () => proxyService.installCertificate(), + listMcpServerTools: async (serverName) => { + const name = typeof serverName === "string" ? serverName.trim() : ""; + if (!name) { + throw new Error("MCP server name is required."); + } + const config = await loadAppConfig(); + const server = config.agent.mcpServers.find((candidate) => candidate.name === name); + if (!server) { + throw new Error("MCP server must be saved before tool discovery."); + } + return listMcpServerTools(server); + }, + openBotGatewayQrWindow: async (request) => { + const qrRequest = request as BotGatewayQrWindowOpenRequest; + await openSystemExternal(qrRequest.url); + return { + message: "Opened the QR login URL in your default browser.", + observed: false, + opened: true + }; + }, + openBuiltInBrowser: async () => { + const config = await loadAppConfig(); + const appUrl = firstConfiguredBrowserAppUrl(config) || "about:blank"; + if (appUrl === "about:blank") { + throw new Error("No browser app is configured."); + } + await openSystemExternal(appUrl); + }, + openProfile: async (request) => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + if (status.state !== "running") { + throw new Error(status.lastError || "CCR gateway did not start."); + } + logProfileApplyResult(await applyProfileConfig(config)); + return openProfileFromCcr(config, request as ProfileOpenRequest); + }, + probeProvider: (request) => probeGatewayProvider(request as GatewayProviderProbeRequest), + probeProviderCandidates: (request) => probeGatewayProviderCandidates(request as GatewayProviderProbeCandidatesRequest), + quitApp: async () => { + setTimeout(() => process.kill(process.pid, "SIGTERM"), 50).unref(); + }, + restartGateway: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return status; + }, + restartProxy: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return proxyService.getStatus(); + }, + revealProxyCertificate: async () => { + ensureProxyCertificateAuthority(); + await revealFile(PROXY_CA_CERT_FILE); + }, + saveApiKeys: async (apiKeys) => { + const savedConfig = await saveApiKeysConfig(apiKeys as ApiKeyConfig[]); + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); + const nextConfig = syncedClaudeAppConfig.config; + gatewayService.updateConfig(nextConfig); + logProfileApplyResult(await applyProfileConfig(nextConfig)); + invalidateProviderAccountSnapshotCache(); + return nextConfig; + }, + saveConfig: async (config, options) => { + const previousConfig = await loadAppConfig(); + const nextInput = config as AppConfig; + if (nextInput.proxy.enabled) { + const certificateStatus = await proxyService.getCertificateStatus(); + if (!certificateStatus.trusted) { + throw new Error(certificateStatus.message); + } + } + let savedConfig = await saveAppConfig(nextInput); + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); + savedConfig = syncedClaudeAppConfig.config; + let runtimeStatus = gatewayService.getStatus(); + if (syncedClaudeAppConfig.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig)) { + runtimeStatus = await gatewayService.start(savedConfig); + } else { + gatewayService.updateConfig(savedConfig); + } + if ((options as AppSaveConfigOptions | undefined)?.applyProfile !== false) { + await applyProfileIfServiceRunning(savedConfig, runtimeStatus); + } + invalidateProviderAccountSnapshotCache(); + return savedConfig; + }, + scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(), + scanBotHandoffWifiTargets: () => scanBotHandoffWifiTargets(), + selectPluginDirectory: (directory) => inspectPluginDirectory(readRequiredString(directory, "Plugin directory path is required.")), + setOnboardingFinished: async () => { + await replacePersistedAppSetting(onboardingFinishedAtSettingKey, new Date().toISOString()); + return true; + }, + setProxyNetworkCaptureEnabled: (enabled) => proxyService.setNetworkCaptureEnabled(Boolean(enabled)), + startBotGatewayQrLogin: (request) => startBotGatewayQrLogin(request as BotGatewayQrLoginStartRequest), + startGateway: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return status; + }, + stopGateway: () => gatewayService.stop(), + stopProfile: async (request) => stopProfileFromCcr(await loadAppConfig(), request as ProfileOpenRequest), + testProviderAccountConnector: (request) => testProviderAccountConnector(request as ProviderAccountTestRequest), + updateCheck: () => unsupportedUpdateStatus, + updateDownload: () => unsupportedUpdateStatus, + updateInstall: () => { + throw new Error("Updates are only available in the desktop app."); + }, + waitBotGatewayQrLogin: (request) => waitBotGatewayQrLogin(request as BotGatewayQrLoginWaitRequest) +}; + +async function startConfiguredServices(reason: string): Promise { + try { + let config = await loadAppConfig(); + try { + config = (await syncClaudeAppGatewayConfig(config)).config; + } catch (error) { + console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); + } + const status = await gatewayService.start(config); + if (status.state === "error") { + console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); + } + if (status.state === "running") { + const profileResult = await applyProfileConfig(config); + logProfileApplyResult(profileResult); + } + if (config.proxy.enabled && config.proxy.systemProxy) { + const proxyStatus = await proxyService.ensureSystemProxyActive(); + if (proxyStatus.systemProxy.state !== "active") { + const details = proxyStatus.systemProxy.lastError ? `: ${proxyStatus.systemProxy.lastError}` : ""; + console.error(`Proxy mode is enabled, but system proxy is ${proxyStatus.systemProxy.state} during ${reason}${details}`); + } + } + } catch (error) { + console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); + } +} + +async function stopConfiguredServices(): Promise { + await gatewayService.stop({ proxyRestoreTimeoutMs: 30_000 }).catch((error) => { + console.error(`Failed to stop gateway: ${formatError(error)}`); + }); + try { + restoreClaudeAppGatewayConfig(); + } catch (error) { + console.error(`Failed to restore Claude App gateway config: ${formatError(error)}`); + } +} + +async function applyProfileIfServiceRunning(config: AppConfig, status: GatewayStatus): Promise { + if (status.state !== "running") { + return; + } + logProfileApplyResult(await applyProfileConfig(config)); +} + +function logProfileApplyResult(result: ProfileApplyResult): void { + for (const client of result.clients) { + if (!client.ok) { + console.warn(`[profile:${client.client}] ${client.message}`); + } + } +} + +function shouldRestartForRuntimeChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean { + return ( + previousConfig.gateway.enabled !== nextConfig.gateway.enabled || + previousConfig.gateway.host !== nextConfig.gateway.host || + previousConfig.gateway.port !== nextConfig.gateway.port || + previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost || + previousConfig.gateway.corePort !== nextConfig.gateway.corePort || + previousConfig.proxy.enabled !== nextConfig.proxy.enabled || + previousConfig.proxy.host !== nextConfig.proxy.host || + previousConfig.proxy.mode !== nextConfig.proxy.mode || + previousConfig.proxy.port !== nextConfig.proxy.port || + previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy || + JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) || + JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) || + JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) || + JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) || + JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) || + JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles) + ); +} + +function getCliAppInfo(): AppInfo { + return { + appConfigDbFile: APP_CONFIG_DB_FILE, + apiKeysDbFile: API_KEYS_DB_FILE, + configDir: CONFIGDIR, + configFile: CONFIG_FILE, + dataDir: DATADIR, + gatewayConfigFile: GATEWAY_CONFIG_FILE, + name: APP_NAME, + platform: process.platform, + requestLogsDbFile: REQUEST_LOGS_DB_FILE, + usageDbFile: USAGE_DB_FILE, + version: packageJson.version + }; +} + +function sendHomeHtml(response: ServerResponse, headOnly: boolean): void { + if (!existsSync(homeHtmlFile)) { + sendText(response, 500, "CCR renderer assets were not found. Run npm run build:assets first."); + return; + } + let html = readFileSync(homeHtmlFile, "utf8"); + if (!html.includes("web-client-bridge.js")) { + html = html.replace(' ', `${webBridgeScriptTag}\n `); + } + sendBuffer(response, 200, Buffer.from(html, "utf8"), "text/html; charset=utf-8", headOnly); +} + +function sendStaticFile(response: ServerResponse, root: string, relativePath: string, headOnly: boolean): void { + const normalizedRelativePath = relativePath.replace(/^[/\\]+/, ""); + const file = path.resolve(root, normalizedRelativePath); + const resolvedRoot = path.resolve(root); + if (!file.startsWith(`${resolvedRoot}${path.sep}`) && file !== resolvedRoot) { + sendText(response, 403, "Forbidden"); + return; + } + if (!isFile(file)) { + sendText(response, 404, "Not found"); + return; + } + sendBuffer(response, 200, readFileSync(file), contentTypeForFile(file), headOnly); +} + +function sendBuffer(response: ServerResponse, status: number, body: Buffer, contentType: string, headOnly: boolean): void { + response.writeHead(status, { + "cache-control": "no-store", + "content-length": body.length, + "content-type": contentType + }); + if (headOnly) { + response.end(); + return; + } + response.end(body); +} + +function sendJson(response: ServerResponse, status: number, payload: unknown): void { + const body = Buffer.from(JSON.stringify(payload), "utf8"); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": body.length, + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function sendText(response: ServerResponse, status: number, text: string): void { + sendBuffer(response, status, Buffer.from(`${text}\n`, "utf8"), "text/plain; charset=utf-8", false); +} + +function requestUrl(request: IncomingMessage): URL { + return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + request.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes.`)); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.once("end", () => resolve(Buffer.concat(chunks))); + request.once("error", reject); + }); +} + +async function listenWithFallback(server: Server, port: number, host: string): Promise { + let candidate = port; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await listen(server, candidate, host); + return candidate; + } catch (error) { + if (!isAddressInUseError(error) || candidate >= 65535) { + throw error; + } + candidate += 1; + } + } + throw new Error(`No available CCR web management port found starting at ${port}.`); +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +async function exportAppData(): Promise { + const exportedAt = new Date().toISOString(); + const exportDir = defaultExportDir(); + mkdirSync(exportDir, { recursive: true }); + const file = path.join(exportDir, `claude-code-router-data-${fileSafeTimestamp(exportedAt)}.json`); + assertExportTargetIsNotInternalDataFile(file); + const payload = { + app: { + name: APP_NAME, + platform: process.platform, + version: packageJson.version + }, + appState: { + onboardingFinished: Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)) + }, + config: await loadAppConfig(), + exportedAt, + files: readDataExportFiles(), + kind: "claude-code-router-data-export", + version: 1 + }; + + writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + return { + canceled: false, + exportedAt, + file + }; +} + +function defaultExportDir(): string { + const downloads = path.join(os.homedir(), "Downloads"); + return existsSync(downloads) ? downloads : CONFIGDIR; +} + +function readDataExportFiles(): Array<{ base64: string; name: string; path: string; sizeBytes: number }> { + const files: Array<{ base64: string; name: string; path: string; sizeBytes: number }> = []; + for (const file of dataExportCandidateFiles()) { + try { + if (!isFile(file)) { + continue; + } + const stat = statSync(file); + files.push({ + base64: readFileSync(file).toString("base64"), + name: path.basename(file), + path: file, + sizeBytes: stat.size + }); + } catch (error) { + console.warn(`[export] Failed to include ${file}: ${formatError(error)}`); + } + } + return files; +} + +function dataExportCandidateFiles(): string[] { + return uniqueStrings([ + ...sqliteDataFiles(APP_CONFIG_DB_FILE), + ...sqliteDataFiles(API_KEYS_DB_FILE), + ...sqliteDataFiles(REQUEST_LOGS_DB_FILE), + ...sqliteDataFiles(USAGE_DB_FILE) + ]); +} + +function sqliteDataFiles(file: string): string[] { + return [file, `${file}-wal`, `${file}-shm`]; +} + +function assertExportTargetIsNotInternalDataFile(file: string): void { + const target = path.resolve(file); + const reserved = new Set([ + CONFIG_FILE, + LEGACY_CONFIG_FILE, + APP_CONFIG_DB_FILE, + API_KEYS_DB_FILE, + REQUEST_LOGS_DB_FILE, + USAGE_DB_FILE, + ...dataExportCandidateFiles() + ].map((item) => path.resolve(item))); + if (reserved.has(target)) { + throw new Error("Choose a different export path. Internal CCR data files cannot be overwritten."); + } +} + +function inspectPluginDirectory(directory: string): PluginDirectorySelection { + const manifest = readFirstJson([ + path.join(directory, "plugin.json"), + path.join(directory, "ccr-plugin.json"), + path.join(directory, ".ccr-plugin", "plugin.json"), + path.join(directory, ".codex-plugin", "plugin.json") + ]); + const packageJsonManifest = readFirstJson([path.join(directory, "package.json")]); + const moduleValue = + readString(manifest?.module) || + readString(manifest?.main) || + readString(manifest?.path) || + readString(readRecord(packageJsonManifest?.ccr)?.module) || + readString(readRecord(packageJsonManifest?.ccrPlugin)?.module) || + readString(packageJsonManifest?.main); + const id = + pluginIdValue(readString(manifest?.id) || readString(manifest?.key) || readString(packageJsonManifest?.name)) || + pluginIdValue(path.basename(directory)) || + "plugin"; + const name = readString(manifest?.name) || readString(packageJsonManifest?.displayName) || readString(packageJsonManifest?.name); + const apps = readPluginApps(manifest, packageJsonManifest); + return { + ...(apps.length ? { apps } : {}), + dependencies: readPluginDependencies(directory, manifest, packageJsonManifest), + directory, + id, + modulePath: resolvePluginDirectoryModule(directory, moduleValue), + ...(name ? { name } : {}) + }; +} + +function readPluginApps( + manifest: Record | undefined, + packageJsonManifest: Record | undefined +): GatewayPluginAppConfig[] { + const values = [ + manifest?.apps, + readRecord(manifest?.ccr)?.apps, + readRecord(manifest?.ccrPlugin)?.apps, + readRecord(packageJsonManifest?.ccr)?.apps, + readRecord(packageJsonManifest?.ccrPlugin)?.apps + ]; + const apps = values.flatMap(parsePluginApps); + const byId = new Map(); + for (const app of apps) { + const key = app.id || `${app.name}:${app.url}`; + if (!byId.has(key)) { + byId.set(key, app); + } + } + return [...byId.values()]; +} + +function parsePluginApps(value: unknown): GatewayPluginAppConfig[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(parsePluginAppItem).filter((item): item is GatewayPluginAppConfig => Boolean(item)); +} + +function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const name = readString(record.name) || readString(record.title); + const url = readString(record.url) || readString(record.href) || readString(record.target); + if (!name || !url) { + return undefined; + } + const id = pluginIdValue(readString(record.id) || name); + const description = readString(record.description); + const icon = readString(record.icon); + return { + ...(description ? { description } : {}), + ...(icon ? { icon } : {}), + ...(id ? { id } : {}), + name, + url + }; +} + +function readPluginDependencies( + directory: string, + manifest: Record | undefined, + packageJsonManifest: Record | undefined +): PluginDependency[] { + const values = [ + manifest?.dependencies, + manifest?.pluginDependencies, + readRecord(manifest?.ccr)?.dependencies, + readRecord(manifest?.ccrPlugin)?.dependencies, + readRecord(packageJsonManifest?.ccr)?.dependencies, + readRecord(packageJsonManifest?.ccrPlugin)?.dependencies + ]; + const dependencies = values.flatMap((value) => parsePluginDependencies(value, directory)); + const byId = new Map(); + for (const dependency of dependencies) { + if (dependency.id && !byId.has(dependency.id)) { + byId.set(dependency.id, dependency); + } + } + return [...byId.values()]; +} + +function parsePluginDependencies(value: unknown, directory: string): PluginDependency[] { + if (Array.isArray(value)) { + return value.map((item) => parsePluginDependencyItem(item, directory)).filter((item): item is PluginDependency => Boolean(item)); + } + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.entries(value as Record) + .map(([id, item]) => parsePluginDependencyEntry(id, item, directory)) + .filter((item): item is PluginDependency => Boolean(item)); + } + return []; +} + +function parsePluginDependencyEntry(idValue: string, value: unknown, directory: string): PluginDependency | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) { + return parsePluginDependencyItem({ id: idValue, ...(value as Record) }, directory); + } + + const id = pluginIdValue(idValue); + if (!id) { + return undefined; + } + const specifier = readString(value); + const modulePath = specifier && looksLikeDependencyModulePath(specifier) ? resolveDependencyModulePath(directory, specifier) : undefined; + return { + id, + ...(modulePath ? { modulePath } : {}) + }; +} + +function parsePluginDependencyItem(value: unknown, directory: string): PluginDependency | undefined { + if (typeof value === "string") { + const id = pluginIdValue(value); + return id ? { id } : undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const id = pluginIdValue(readString(record.id) || readString(record.key) || readString(record.name)); + if (!id) { + return undefined; + } + const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath); + const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined; + const name = readString(record.name); + return { + id, + ...(modulePath ? { modulePath } : {}), + ...(name ? { name } : {}) + }; +} + +function resolveDependencyModulePath(directory: string, value: string): string { + if (value === "~" || value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return path.isAbsolute(value) ? value : path.join(directory, value); +} + +function looksLikeDependencyModulePath(value: string): boolean { + return value.startsWith(".") || value.startsWith("/") || value.startsWith("~"); +} + +function resolvePluginDirectoryModule(directory: string, moduleValue: string | undefined): string { + if (moduleValue) { + return path.isAbsolute(moduleValue) ? moduleValue : path.join(directory, moduleValue); + } + + for (const filename of ["index.cjs", "index.mjs", "index.js", "plugin.cjs", "plugin.mjs", "plugin.js"]) { + const candidate = path.join(directory, filename); + if (isFile(candidate)) { + return candidate; + } + } + + return directory; +} + +function readFirstJson(files: string[]): Record | undefined { + for (const file of files) { + if (!isFile(file)) { + continue; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Ignore invalid plugin metadata and fall back to directory inference. + } + } + return undefined; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function firstConfiguredBrowserAppUrl(config: AppConfig): string | undefined { + for (const plugin of config.plugins) { + if (plugin.enabled === false) { + continue; + } + const app = plugin.apps?.find((candidate) => readString(candidate.url)); + if (app?.url) { + return app.url; + } + } + return undefined; +} + +async function revealFile(file: string): Promise { + if (process.platform === "darwin") { + await execDetached("/usr/bin/open", ["-R", file]); + return; + } + if (process.platform === "win32") { + await execDetached("explorer.exe", ["/select,", file]); + return; + } + await openSystemExternal(pathToFileURL(path.dirname(file)).toString()); +} + +function openSystemExternal(target: string): Promise { + if (!target || target === "about:blank") { + return Promise.resolve(); + } + if (process.platform === "darwin") { + return execDetached("/usr/bin/open", [target]); + } + if (process.platform === "win32") { + return execDetached("cmd.exe", ["/d", "/s", "/c", "start", "", target]); + } + return execDetached("xdg-open", [target]); +} + +function execDetached(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true + }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +function contentTypeForFile(file: string): string { + switch (path.extname(file).toLowerCase()) { + case ".css": + return "text/css; charset=utf-8"; + case ".gif": + return "image/gif"; + case ".html": + return "text/html; charset=utf-8"; + case ".ico": + return "image/x-icon"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +function readRequiredString(value: unknown, message: string): string { + const text = readString(value); + if (!text) { + throw new Error(message); + } + return text; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readEnvString(key: string): string | undefined { + return readString(process.env[key]); +} + +function readEnvPort(key: string): number | undefined { + const value = Number(process.env[key]); + return Number.isInteger(value) && value > 0 && value < 65536 ? value : undefined; +} + +function pluginIdValue(value: string | undefined): string { + return value?.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || ""; +} + +function isFile(file: string): boolean { + try { + return existsSync(file) && statSync(file).isFile(); + } catch { + return false; + } +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + +function fileSafeTimestamp(value: string): string { + return value.replace(/[:.]/g, "-"); +} + +function formatListenHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function isAddressInUseError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EADDRINUSE"; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/server/mcp/network-capture-mcp.ts b/src/server/mcp/network-capture-mcp.ts index bbc3601..21759ce 100644 --- a/src/server/mcp/network-capture-mcp.ts +++ b/src/server/mcp/network-capture-mcp.ts @@ -1,4 +1,5 @@ -import { app } from "electron"; +import * as electron from "electron"; +import packageJson from "../../../package.json"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ProxyNetworkExchange } from "../../shared/app"; import { proxyService } from "../proxy/service"; @@ -151,7 +152,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise { if (!proxyService.isNetworkCaptureEnabled()) { throw new Error("Network capture MCP is disabled."); diff --git a/src/server/proxy/service.ts b/src/server/proxy/service.ts index 2af626f..eb8f031 100644 --- a/src/server/proxy/service.ts +++ b/src/server/proxy/service.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { BrowserWindow, dialog, shell } from "electron"; +import * as electron from "electron"; import { chmodSync, writeFileSync } from "node:fs"; import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import https from "node:https"; @@ -398,7 +398,7 @@ class ProxyService { const installerFile = await openMacosTerminalCertificateInstaller(); terminalMessage = ` Opened Terminal installer: ${installerFile}`; } catch (terminalError) { - shell.showItemInFolder(PROXY_CA_CERT_FILE); + electron.shell?.showItemInFolder?.(PROXY_CA_CERT_FILE); terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`; } const status = await this.getCertificateStatus(); @@ -1470,7 +1470,10 @@ function execFilePromise(file: string, args: string[]): Promise { } async function requestMacosCertificateInstallPermission(): Promise { - const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (!electron.dialog || !electron.BrowserWindow) { + return true; + } + const window = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0]; const options = { buttons: ["Continue", "Cancel"], cancelId: 1, @@ -1481,7 +1484,7 @@ async function requestMacosCertificateInstallPermission(): Promise { noLink: true, type: "warning" as const }; - const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options); + const result = window ? await electron.dialog.showMessageBox(window, options) : await electron.dialog.showMessageBox(options); return result.response === 0; } @@ -1552,10 +1555,14 @@ async function openMacosTerminalCertificateInstaller(): Promise { const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`); writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8"); chmodSync(installerFile, 0o700); - const errorMessage = await shell.openPath(installerFile); - if (errorMessage) { - throw new Error(errorMessage); + if (electron.shell?.openPath) { + const errorMessage = await electron.shell.openPath(installerFile); + if (errorMessage) { + throw new Error(errorMessage); + } + return installerFile; } + await execFilePromise("/usr/bin/open", [installerFile]); return installerFile; } From 1534059fd6becc8880f6f43b16018d2837e3a5bf Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 29 Jun 2026 16:45:47 +0800 Subject: [PATCH 004/101] Refactor router config and provider handling --- .github/workflows/release.yml | 106 ++++++- README.md | 3 +- README_zh.md | 3 +- build/esbuild.config.mjs | 14 +- build/merge-macos-update-metadata.mjs | 78 +++++ src/main/app-paths.ts | 65 ++++ src/main/claude-app-gateway-service.ts | 44 ++- src/main/cli.ts | 29 +- src/main/codex-cli-middleware-runtime.ts | 47 ++- src/main/constants.ts | 28 +- src/main/electron-node-shim.ts | 14 - src/main/main-app.ts | 246 +++++++++++++++ src/main/main.ts | 219 +------------ src/main/profile-launch-service.ts | 374 +++++++++++++++++++++-- src/main/profile-service.ts | 9 + src/server/gateway/service.ts | 174 +++++++++-- src/server/mcp/network-capture-mcp.ts | 3 +- src/server/proxy/service.ts | 66 ++-- 18 files changed, 1148 insertions(+), 374 deletions(-) create mode 100644 build/merge-macos-update-metadata.mjs create mode 100644 src/main/app-paths.ts delete mode 100644 src/main/electron-node-shim.ts create mode 100644 src/main/main-app.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f2cbbd..409612d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,24 @@ concurrency: jobs: macos: - name: macOS - runs-on: macos-14 + name: macOS (${{ matrix.display_name }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + arch_flag: --arm64 + runner: macos-14 + display_name: Apple Silicon + release_label: Apple-Silicon + artifact_name: macos-apple-silicon-arm64 + - arch: x64 + arch_flag: --x64 + runner: macos-15-intel + display_name: Intel + release_label: Intel + artifact_name: macos-intel-x64 steps: - name: Check out repository uses: actions/checkout@v4 @@ -39,16 +55,92 @@ jobs: exit 1 fi - - name: Build and publish ad-hoc macOS artifacts + - name: Build ad-hoc macOS artifacts + run: | + npm run build:assets + npx electron-builder \ + --config build/electron-builder.local.cjs \ + --mac ${{ matrix.arch_flag }} \ + --publish never \ + -c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}' + + - name: Upload macOS release files + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: | + release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.* + release-local/latest-mac.yml + if-no-files-found: error + + publish-macos: + name: Publish macOS + needs: macos + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install metadata merge dependencies + run: npm ci --ignore-scripts + + - name: Download macOS release files + uses: actions/download-artifact@v4 + with: + pattern: macos-* + path: macos-release-artifacts + + - name: Merge macOS update metadata + run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml + + - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm run build:assets - npx electron-builder --config build/electron-builder.local.cjs --mac --publish always + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \ + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes + + - name: Upload macOS release assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + shopt -s nullglob + assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*) + if (( ${#assets[@]} == 0 )); then + echo "No macOS release assets were found." + exit 1 + fi + + upload_args=() + for asset in "${assets[@]}"; do + name="$(basename "$asset")" + case "$name" in + *Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;; + *Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;; + *Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;; + *Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;; + *Intel-x64.dmg) label="macOS Intel DMG (x64)" ;; + *Intel-x64.zip) label="macOS Intel ZIP (x64)" ;; + *Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;; + *Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;; + *) label="$name" ;; + esac + upload_args+=("${asset}#${label}") + done + upload_args+=("latest-mac.yml#macOS update metadata") + + gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber windows: name: Windows - needs: macos + needs: publish-macos runs-on: windows-latest steps: - name: Check out repository @@ -82,7 +174,7 @@ jobs: linux: name: Linux - needs: macos + needs: publish-macos runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/README.md b/README.md index c123f22..1e4bde0 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st 1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). 2. Download the package for your platform: - - macOS: `Claude Code Router_.dmg` or `.zip` + - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` + - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip` - Windows: `Claude Code Router_.exe` - Linux: `Claude Code Router_.AppImage` 3. Install and launch **Claude Code Router**. diff --git a/README_zh.md b/README_zh.md index 45f98cf..7c94376 100644 --- a/README_zh.md +++ b/README_zh.md @@ -62,7 +62,8 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host 1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 2. 按系统下载对应安装包: - - macOS:`Claude Code Router_.dmg` 或 `.zip` + - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` + - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip` - Windows:`Claude Code Router_.exe` - Linux:`Claude Code Router_.AppImage` 3. 安装并启动 **Claude Code Router**。 diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 3283c23..9fc975f 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -140,7 +140,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} minify: mode === "production", outdir: mainOutDir, platform: "node", - plugins: [electronNodeShimPlugin(), ...plugins], + plugins: [forbidCliElectronPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -292,12 +292,18 @@ function rendererAliasPlugin() { }; } -function electronNodeShimPlugin() { +function forbidCliElectronPlugin() { return { - name: "electron-node-shim", + name: "forbid-cli-electron", setup(build) { build.onResolve({ filter: /^electron$/ }, () => { - return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") }; + return { + errors: [ + { + text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary." + } + ] + }; }); } }; diff --git a/build/merge-macos-update-metadata.mjs b/build/merge-macos-update-metadata.mjs new file mode 100644 index 0000000..d1e2da0 --- /dev/null +++ b/build/merge-macos-update-metadata.mjs @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const [outputFile, ...inputFiles] = process.argv.slice(2); + +if (!outputFile || inputFiles.length < 2) { + console.error("Usage: node build/merge-macos-update-metadata.mjs [...]"); + process.exit(1); +} + +const updateInfos = inputFiles.map((file) => { + const info = yaml.load(readFileSync(file, "utf8")); + if (!info || typeof info !== "object") { + throw new Error(`${file} is not a valid update metadata object`); + } + if (!Array.isArray(info.files) || info.files.length === 0) { + throw new Error(`${file} does not contain any update files`); + } + return { file, info }; +}); + +const version = updateInfos[0].info.version; +if (!version || updateInfos.some(({ info }) => info.version !== version)) { + throw new Error("All macOS update metadata files must have the same version"); +} + +const files = uniqueByUrl( + updateInfos + .flatMap(({ info }) => info.files) + .filter((file) => file && typeof file.url === "string") + .sort(compareMacUpdateFile) +); + +const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip")); +if (!defaultZip?.sha512) { + throw new Error("Merged macOS update metadata must include at least one ZIP with sha512"); +} + +const releaseDate = updateInfos + .map(({ info }) => info.releaseDate) + .filter(Boolean) + .sort() + .at(-1); + +const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info; +const mergedInfo = { + ...baseInfo, + version, + files, + path: defaultZip.url, + sha512: defaultZip.sha512, + ...(releaseDate ? { releaseDate } : {}) +}; + +writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8"); +console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`); + +function uniqueByUrl(files) { + const seen = new Set(); + return files.filter((file) => { + if (seen.has(file.url)) { + return false; + } + seen.add(file.url); + return true; + }); +} + +function compareMacUpdateFile(left, right) { + return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url); +} + +function fileRank(file) { + const isZip = file.url.endsWith(".zip") ? 0 : 10; + const isAppleSilicon = file.url.includes("arm64") ? 0 : 1; + return isZip + isAppleSilicon; +} diff --git a/src/main/app-paths.ts b/src/main/app-paths.ts new file mode 100644 index 0000000..1b0a299 --- /dev/null +++ b/src/main/app-paths.ts @@ -0,0 +1,65 @@ +import os from "node:os"; +import path from "node:path"; + +export const APP_NAME = "Claude Code Router"; +export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); + +const homeDirEnv = "CCR_INTERNAL_HOME_DIR"; +const appDataDirEnv = "CCR_INTERNAL_APP_DATA_DIR"; +const userDataDirEnv = "CCR_INTERNAL_USER_DATA_DIR"; + +type RuntimePathName = "appData" | "home" | "userData"; + +export type RuntimeAppPaths = Partial>; + +export function setRuntimeAppPaths(paths: RuntimeAppPaths): void { + setPathEnv(homeDirEnv, paths.home); + setPathEnv(appDataDirEnv, paths.appData); + setPathEnv(userDataDirEnv, paths.userData); +} + +export function resolveRuntimeAppPath(name: RuntimePathName): string { + const configured = readConfiguredPath(name); + if (configured) { + return configured; + } + if (name === "home") { + return os.homedir(); + } + if (name === "appData") { + return fallbackAppDataDir(); + } + return fallbackUserDataDir(); +} + +function readConfiguredPath(name: RuntimePathName): string | undefined { + const key = name === "home" + ? homeDirEnv + : name === "appData" + ? appDataDirEnv + : userDataDirEnv; + const value = process.env[key]?.trim(); + return value || undefined; +} + +function setPathEnv(key: string, value: string | undefined): void { + if (value?.trim()) { + process.env[key] = value; + } +} + +function fallbackAppDataDir(): string { + if (process.platform === "win32") { + return process.env.APPDATA || + process.env.LOCALAPPDATA || + (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")); + } + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function fallbackUserDataDir(): string { + if (process.platform === "win32") { + return path.join(fallbackAppDataDir(), APP_NAME); + } + return path.join(LEGACY_CONFIGDIR, "app-data"); +} diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 66da1af..3a56652 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -1,8 +1,8 @@ -import * as electron from "electron"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { resolveRuntimeAppPath } from "./app-paths"; import { saveAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; import { @@ -174,6 +174,33 @@ export function restoreClaudeAppGatewayConfig(): void { rmSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, { force: true }); } +export function readClaudeAppGatewayApiKeyCandidates(): string[] { + return uniqueStrings([ + readClaudeAppGatewayApiKey(getClaudeAppGatewayPaths().configLibraryFile) + ]); +} + +function readClaudeAppGatewayApiKey(file: string): string { + const config = readJsonRecord(file); + return typeof config?.inferenceGatewayApiKey === "string" + ? config.inferenceGatewayApiKey.trim() + : ""; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + function ensureClaudeAppGatewayState(config: AppConfig): ClaudeAppApplyState { const currentApiKey = findReusableApiKey(config); const gatewayEnabledConfig = config.gateway.enabled @@ -246,20 +273,7 @@ function getClaudeApp3pDataDir(): string { } function appPath(name: "appData" | "home"): string { - const electronApp = electronAppOrUndefined(); - if (electronApp) { - return electronApp.getPath(name); - } - if (name === "home") { - return os.homedir(); - } - return process.env.APPDATA || - process.env.LOCALAPPDATA || - (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), ".config")); -} - -function electronAppOrUndefined(): Electron.App | undefined { - return typeof electron.app?.getPath === "function" ? electron.app : undefined; + return resolveRuntimeAppPath(name); } function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void { diff --git a/src/main/cli.ts b/src/main/cli.ts index 3d745ca..225b667 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -7,6 +7,8 @@ import { botGatewayProfileEnv } from "./bot-gateway-env"; import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch"; import { loadAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; +import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service"; +import { ensureProfileGateway } from "./profile-launch-service"; import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core"; import { startWebManagementServer } from "./web-management-server"; @@ -88,9 +90,19 @@ async function main(): Promise { const configDir = CONFIGDIR; const config = await loadAppConfig(); + await applyProfileConfig(config); const profile = findProfileForOpen(config, profileOptions.profileRef); const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); const resolvedSurface = resolveProfileOpenSurface(profile, surface); + const launchConfig = resolvedSurface === "cli" + ? await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { reuseExisting: true, startIfMissing: false }) + : config; + if (resolvedSurface === "cli") { + const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); + if (!runtimeResult.ok) { + throw new Error(runtimeResult.message); + } + } if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); } @@ -122,7 +134,7 @@ async function main(): Promise { const childEnv = { ...process.env, ...plan.env, - ...botGatewayProfileEnv(config, profile, resolvedSurface) + ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface) }; delete childEnv.ELECTRON_RUN_AS_NODE; @@ -275,10 +287,7 @@ async function startService(options: WebCliOptions): Promise { ]; const child = spawn(process.execPath, childArgs, { detached: true, - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: undefined - }, + env: serviceChildEnv(), stdio: "ignore", windowsHide: true }); @@ -295,6 +304,16 @@ async function startService(options: WebCliOptions): Promise { process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); } +function serviceChildEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + if (process.versions.electron) { + env.ELECTRON_RUN_AS_NODE = "1"; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + return env; +} + async function runWebServer(options: WebCliOptions): Promise { const runtime = await startWebManagementServer({ host: options.host, diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index fcd0a11..0813c76 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -58,6 +58,7 @@ async function main() { async function runClaudeCodeCliWrapper(args) { const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); log("claude_code_wrapper_start", { realCli, args }); + const captureStdout = shouldCaptureClaudeCodeCliStdout(args); const remoteSync = createRemoteSyncClient({ args, cwd: process.cwd(), @@ -67,7 +68,7 @@ async function runClaudeCodeCliWrapper(args) { const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN"); const child = childProcess.spawn(realCli, args, { env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), - stdio: [injectRemoteStdin ? "pipe" : "inherit", "pipe", "inherit"] + stdio: [injectRemoteStdin ? "pipe" : "inherit", captureStdout ? "pipe" : "inherit", "inherit"] }); if (injectRemoteStdin && child.stdin) { process.stdin.pipe(child.stdin); @@ -88,18 +89,20 @@ async function runClaudeCodeCliWrapper(args) { remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" }); }); let pending = ""; - child.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - pending += chunk.toString("utf8"); - const lines = pending.split(/\r?\n/g); - pending = lines.pop() || ""; - for (const line of lines) { - botBridge().handleClaudeCliLine(line); - remoteSync.postEvent("claude.stdout", { line }, { text: line }); - } - }); + if (captureStdout && child.stdout) { + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/g); + pending = lines.pop() || ""; + for (const line of lines) { + botBridge().handleClaudeCliLine(line); + remoteSync.postEvent("claude.stdout", { line }, { text: line }); + } + }); + } const code = await waitForChild(child); - if (pending.trim()) { + if (captureStdout && pending.trim()) { botBridge().handleClaudeCliLine(pending); remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending }); } @@ -109,6 +112,26 @@ async function runClaudeCodeCliWrapper(args) { process.exitCode = code; } +function shouldCaptureClaudeCodeCliStdout(args) { + if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) { + return true; + } + if (botGatewayCliCaptureEnabled()) { + return true; + } + return claudeCodeArgsUsePrintMode(args); +} + +function botGatewayCliCaptureEnabled() { + const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED"); + const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none"); + return enabled && platform !== "none"; +} + +function claudeCodeArgsUsePrintMode(args) { + return args.some((arg) => arg === "--print" || arg === "-p"); +} + function defaultCodexArgs() { return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli" ? [] diff --git a/src/main/constants.ts b/src/main/constants.ts index ba05680..a7b0bf8 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,35 +1,17 @@ -import * as electron from "electron"; -import os from "node:os"; import path from "node:path"; +import { APP_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath } from "./app-paths"; + export { IPC_CHANNELS } from "../shared/ipc-channels"; - -export const APP_NAME = "Claude Code Router"; - -const electronApp = typeof electron.app?.getPath === "function" ? electron.app : undefined; -export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); export const LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIGDIR, "config.json"); -function appPath(name: "appData" | "home" | "userData"): string { - if (electronApp) { - return electronApp.getPath(name); - } - if (name === "home") { - return os.homedir(); - } - if (name === "appData") { - return process.env.APPDATA || - process.env.LOCALAPPDATA || - (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")); - } - return path.join(LEGACY_CONFIGDIR, "app-data"); -} +export { APP_NAME, LEGACY_CONFIGDIR }; export const CONFIGDIR = process.platform === "win32" - ? path.join(appPath("appData"), APP_NAME) + ? path.join(resolveRuntimeAppPath("appData"), APP_NAME) : LEGACY_CONFIGDIR; export const CONFIG_FILE = path.join(CONFIGDIR, "config.json"); export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished"); -export const DATADIR = appPath("userData"); +export const DATADIR = resolveRuntimeAppPath("userData"); export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite"); export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite"); export const CERTDIR = path.join(DATADIR, "certs"); diff --git a/src/main/electron-node-shim.ts b/src/main/electron-node-shim.ts deleted file mode 100644 index 212ed6a..0000000 --- a/src/main/electron-node-shim.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const app = undefined; -export const BrowserWindow = undefined; -export const clipboard = undefined; -export const contextBridge = undefined; -export const dialog = undefined; -export const ipcMain = undefined; -export const ipcRenderer = undefined; -export const Menu = undefined; -export const nativeImage = undefined; -export const screen = undefined; -export const session = undefined; -export const shell = undefined; -export const Tray = undefined; -export const WebContentsView = undefined; diff --git a/src/main/main-app.ts b/src/main/main-app.ts new file mode 100644 index 0000000..72f63d5 --- /dev/null +++ b/src/main/main-app.ts @@ -0,0 +1,246 @@ +import { app, BrowserWindow, dialog, shell } from "electron"; +import { setupApplicationMenu } from "./app-menu"; +import { loadAppConfig } from "./config"; +import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { deepLinkService } from "./deep-link"; +import { gatewayService } from "../server/gateway/service"; +import "./ipc"; +import { applyProfileConfig } from "./profile-service"; +import { ensureCcrCliLauncher } from "./profile-launch-service"; +import { proxyService } from "../server/proxy/service"; +import trayController from "./tray-controller"; +import { appUpdateService } from "./update-service"; +import windowsManager from "./windows"; + +const gotTheLock = app.requestSingleInstanceLock(); +const quitProxyRestoreTimeoutMs = 30_000; +let quitPrepared = false; +let stoppingForQuit = false; +let ensureProxyModePromise: Promise | undefined; +let startServicesPromise: Promise | undefined; +let stopForQuitPromise: Promise | undefined; + +if (!gotTheLock) { + app.quit(); +} else { + startPrimaryInstance(); +} + +function startPrimaryInstance(): void { + deepLinkService.register(); + deepLinkService.handleArgv(process.argv); + + app.on("second-instance", (_event, argv) => { + windowsManager.showMainWindow(); + deepLinkService.handleArgv(argv); + queueEnsureConfiguredProxyModeActive("second-instance"); + }); + + app.whenReady().then(() => { + configureProxyDesktopIntegration(); + try { + ensureCcrCliLauncher(); + } catch (error) { + console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`); + } + setupApplicationMenu(); + windowsManager.createMainWindow(); + trayController.start(); + appUpdateService.start(); + appUpdateService.setInstallPreparation(prepareForUpdateInstall); + void startConfiguredServices("startup"); + + app.on("activate", () => { + windowsManager.showMainWindow(); + queueEnsureConfiguredProxyModeActive("activate"); + }); + }); + + app.on("before-quit", (event) => { + if (quitPrepared || appUpdateService.isInstallingUpdate()) { + return; + } + event.preventDefault(); + prepareAndQuit(); + }); + + app.on("will-quit", (event) => { + if (quitPrepared || appUpdateService.isInstallingUpdate()) { + return; + } + event.preventDefault(); + prepareAndQuit(); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + process.once("SIGINT", () => handleTerminationSignal("SIGINT")); + process.once("SIGTERM", () => handleTerminationSignal("SIGTERM")); +} + +function configureProxyDesktopIntegration(): void { + proxyService.setDesktopIntegration({ + requestMacosCertificateInstallPermission, + revealFile: async (file) => { + shell.showItemInFolder(file); + } + }); +} + +async function requestMacosCertificateInstallPermission(): Promise { + const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const options = { + buttons: ["Continue", "Cancel"], + cancelId: 1, + defaultId: 0, + detail: + "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", + message: "Install CCR Proxy CA into the macOS System keychain?", + noLink: true, + type: "warning" as const + }; + const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options); + return result.response === 0; +} + +function prepareAndQuit(): void { + if (stoppingForQuit) { + return; + } + + stoppingForQuit = true; + void stopServicesForQuit().finally(() => { + quitPrepared = true; + app.quit(); + }); +} + +async function prepareForUpdateInstall(): Promise { + if (!stoppingForQuit) { + stoppingForQuit = true; + } + await stopServicesForQuit(); + quitPrepared = true; +} + +function handleTerminationSignal(signal: NodeJS.Signals): void { + if (stoppingForQuit) { + return; + } + + stoppingForQuit = true; + void stopServicesForQuit().finally(() => { + quitPrepared = true; + app.exit(signal === "SIGINT" ? 130 : 143); + }); +} + +function stopServicesForQuit(): Promise { + if (!stopForQuitPromise) { + stopForQuitPromise = gatewayService + .stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs }) + .then(() => undefined) + .catch((error) => { + console.error(`Failed to stop services before quit: ${formatError(error)}`); + }) + .finally(() => { + try { + restoreClaudeAppGatewayConfig(); + } catch (error) { + console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`); + } + trayController.destroy(); + }); + } + return stopForQuitPromise; +} + +function startConfiguredServices(reason: string): Promise { + if (!startServicesPromise) { + startServicesPromise = loadAppConfig() + .then(async (config) => { + try { + config = (await syncClaudeAppGatewayConfig(config)).config; + } catch (error) { + console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); + } + const status = await gatewayService.start(config); + if (status.state === "error") { + console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); + } + if (status.state === "running") { + const profileResult = await applyProfileConfig(config); + for (const client of profileResult.clients) { + if (!client.ok) { + console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`); + } + } + } + if (config.proxy.enabled && config.proxy.systemProxy) { + const proxyStatus = await proxyService.ensureSystemProxyActive(); + logProxySystemProxyIssue(reason, proxyStatus); + } + }) + .catch((error) => { + console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); + }) + .finally(() => { + trayController.refreshUsageTitle(); + startServicesPromise = undefined; + }); + } + return startServicesPromise; +} + +function queueEnsureConfiguredProxyModeActive(reason: string): void { + void (startServicesPromise ?? Promise.resolve()) + .then(() => ensureConfiguredProxyModeActive(reason)) + .catch((error) => { + console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`); + }); +} + +function ensureConfiguredProxyModeActive(reason: string): Promise { + if (stoppingForQuit) { + return Promise.resolve(); + } + + if (!ensureProxyModePromise) { + ensureProxyModePromise = loadAppConfig() + .then(async (config) => { + if (!config.proxy.enabled || !config.proxy.systemProxy) { + return; + } + + const proxyStatus = proxyService.getStatus(); + if (proxyStatus.state !== "running") { + await startConfiguredServices(reason); + return; + } + + const ensuredStatus = await proxyService.ensureSystemProxyActive(); + logProxySystemProxyIssue(reason, ensuredStatus); + }) + .finally(() => { + ensureProxyModePromise = undefined; + }); + } + return ensureProxyModePromise; +} + +function logProxySystemProxyIssue(reason: string, status: ReturnType): void { + if (status.systemProxy.state === "active") { + return; + } + + const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : ""; + console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/main.ts b/src/main/main.ts index 0e96bce..44f233b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,214 +1,13 @@ 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"; -import { gatewayService } from "../server/gateway/service"; -import "./ipc"; -import { applyProfileConfig } from "./profile-service"; -import { proxyService } from "../server/proxy/service"; -import trayController from "./tray-controller"; -import { appUpdateService } from "./update-service"; -import windowsManager from "./windows"; +import { setRuntimeAppPaths } from "./app-paths"; -const gotTheLock = app.requestSingleInstanceLock(); -const quitProxyRestoreTimeoutMs = 30_000; -let quitPrepared = false; -let stoppingForQuit = false; -let ensureProxyModePromise: Promise | undefined; -let startServicesPromise: Promise | undefined; -let stopForQuitPromise: Promise | undefined; +setRuntimeAppPaths({ + appData: app.getPath("appData"), + home: app.getPath("home"), + userData: app.getPath("userData") +}); -if (!gotTheLock) { +void import("./main-app.js").catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); app.quit(); -} else { - startPrimaryInstance(); -} - -function startPrimaryInstance(): void { - deepLinkService.register(); - deepLinkService.handleArgv(process.argv); - - app.on("second-instance", (_event, argv) => { - windowsManager.showMainWindow(); - deepLinkService.handleArgv(argv); - queueEnsureConfiguredProxyModeActive("second-instance"); - }); - - app.whenReady().then(() => { - setupApplicationMenu(); - windowsManager.createMainWindow(); - trayController.start(); - appUpdateService.start(); - appUpdateService.setInstallPreparation(prepareForUpdateInstall); - void startConfiguredServices("startup"); - - app.on("activate", () => { - windowsManager.showMainWindow(); - queueEnsureConfiguredProxyModeActive("activate"); - }); - }); - - app.on("before-quit", (event) => { - if (quitPrepared || appUpdateService.isInstallingUpdate()) { - return; - } - event.preventDefault(); - prepareAndQuit(); - }); - - app.on("will-quit", (event) => { - if (quitPrepared || appUpdateService.isInstallingUpdate()) { - return; - } - event.preventDefault(); - prepareAndQuit(); - }); - - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - process.once("SIGINT", () => handleTerminationSignal("SIGINT")); - process.once("SIGTERM", () => handleTerminationSignal("SIGTERM")); -} - -function prepareAndQuit(): void { - if (stoppingForQuit) { - return; - } - - stoppingForQuit = true; - void stopServicesForQuit().finally(() => { - quitPrepared = true; - app.quit(); - }); -} - -async function prepareForUpdateInstall(): Promise { - if (!stoppingForQuit) { - stoppingForQuit = true; - } - await stopServicesForQuit(); - quitPrepared = true; -} - -function handleTerminationSignal(signal: NodeJS.Signals): void { - if (stoppingForQuit) { - return; - } - - stoppingForQuit = true; - void stopServicesForQuit().finally(() => { - quitPrepared = true; - app.exit(signal === "SIGINT" ? 130 : 143); - }); -} - -function stopServicesForQuit(): Promise { - if (!stopForQuitPromise) { - stopForQuitPromise = gatewayService - .stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs }) - .then(() => undefined) - .catch((error) => { - console.error(`Failed to stop services before quit: ${formatError(error)}`); - }) - .finally(() => { - try { - restoreClaudeAppGatewayConfig(); - } catch (error) { - console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`); - } - trayController.destroy(); - }); - } - return stopForQuitPromise; -} - -function startConfiguredServices(reason: string): Promise { - if (!startServicesPromise) { - startServicesPromise = loadAppConfig() - .then(async (config) => { - try { - config = (await syncClaudeAppGatewayConfig(config)).config; - } catch (error) { - console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); - } - const status = await gatewayService.start(config); - if (status.state === "error") { - console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); - } - if (status.state === "running") { - const profileResult = await applyProfileConfig(config); - for (const client of profileResult.clients) { - if (!client.ok) { - console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`); - } - } - } - if (config.proxy.enabled && config.proxy.systemProxy) { - const proxyStatus = await proxyService.ensureSystemProxyActive(); - logProxySystemProxyIssue(reason, proxyStatus); - } - }) - .catch((error) => { - console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); - }) - .finally(() => { - trayController.refreshUsageTitle(); - startServicesPromise = undefined; - }); - } - return startServicesPromise; -} - -function queueEnsureConfiguredProxyModeActive(reason: string): void { - void (startServicesPromise ?? Promise.resolve()) - .then(() => ensureConfiguredProxyModeActive(reason)) - .catch((error) => { - console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`); - }); -} - -function ensureConfiguredProxyModeActive(reason: string): Promise { - if (stoppingForQuit) { - return Promise.resolve(); - } - - if (!ensureProxyModePromise) { - ensureProxyModePromise = loadAppConfig() - .then(async (config) => { - if (!config.proxy.enabled || !config.proxy.systemProxy) { - return; - } - - const proxyStatus = proxyService.getStatus(); - if (proxyStatus.state !== "running") { - await startConfiguredServices(reason); - return; - } - - const ensuredStatus = await proxyService.ensureSystemProxyActive(); - logProxySystemProxyIssue(reason, ensuredStatus); - }) - .finally(() => { - ensureProxyModePromise = undefined; - }); - } - return ensureProxyModePromise; -} - -function logProxySystemProxyIssue(reason: string, status: ReturnType): void { - if (status.systemProxy.state === "active") { - return; - } - - const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : ""; - console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`); -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} +}); diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index e1965e8..ed18cdf 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -1,10 +1,10 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch"; import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; @@ -156,7 +156,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType, - appName: string + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {} ): Promise { const profileGatewayConfig = profileGatewayConfigFor(config, profile); - await ensureGatewayConfigRunning(profileGatewayConfig, appName); - return profileGatewayConfig; + const result = await ensureGatewayConfigRunning(profileGatewayConfig, profile, appName, options, config); + return result.acceptedApiKey && result.acceptedApiKey !== result.config.APIKEY + ? profileGatewayConfigWithToken(result.config, profile, result.acceptedApiKey) + : result.config; } -async function ensureGatewayConfigRunning(config: AppConfig, appName: string): Promise { - const startedStatus = await gatewayService.start(config); - if (startedStatus.state !== "running") { - throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +type EnsureGatewayConfigRunningResult = { + acceptedApiKey?: string; + config: AppConfig; +}; + +async function ensureGatewayConfigRunning( + config: AppConfig, + profile: ReturnType, + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}, + candidateConfig: AppConfig = config +): Promise { + const startIfMissing = options.startIfMissing !== false; + if (options.reuseExisting) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + if (existingGateway.state === "unavailable") { + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + } else { + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } } + + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + + const startedStatus = await gatewayService.start(config); + if (startedStatus.state === "running") { + return { config }; + } + + if (options.reuseExisting && isAddressInUseError(startedStatus.lastError)) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } + + throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +} + +type ExistingProfileGatewayProbe = + | { endpoint: string; reason?: string; state: "unavailable" } + | { endpoint: string; status?: number; state: "not-ccr" } + | { endpoint: string; message?: string; status: number; state: "unauthorized" } + | { endpoint: string; status: number; state: "unusable" } + | { apiKey?: string; endpoint: string; state: "usable" }; + +type ExistingGatewayHttpProbe = { + payload?: unknown; + reason?: string; + status?: number; +}; + +async function probeExistingProfileGateway( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig = config +): Promise { + const endpoint = profileGatewayEndpoint(config); + const root = await fetchExistingGateway(endpoint, "/"); + if (root.status === undefined) { + return { endpoint, reason: root.reason, state: "unavailable" }; + } + + let ccrGateway = isCcrGatewayRoot(root.payload); + if (!ccrGateway) { + const health = await fetchExistingGateway(endpoint, "/health"); + ccrGateway = isCcrGatewayHealth(health.payload); + } + if (!ccrGateway) { + return { endpoint, status: root.status, state: "not-ccr" }; + } + + let lastUnauthorized: ExistingGatewayHttpProbe | undefined; + for (const apiKey of existingGatewayApiKeyCandidates(config, profile, candidateConfig)) { + const headers: Record = { + "user-agent": "Claude Code" + }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + const models = await fetchExistingGateway(endpoint, "/v1/models", { headers }); + if (models.status === 200) { + return { apiKey, endpoint, state: "usable" }; + } + if (models.status === 401 || models.status === 403) { + lastUnauthorized = models; + continue; + } + return { endpoint, status: models.status ?? 0, state: "unusable" }; + } + + if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) { + return { + endpoint, + message: readGatewayErrorMessage(lastUnauthorized.payload), + status: lastUnauthorized.status, + state: "unauthorized" + }; + } + return { endpoint, status: 0, state: "unusable" }; +} + +async function fetchExistingGateway( + endpoint: string, + pathname: string, + init: RequestInit = {} +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1200); + try { + const response = await fetch(new URL(pathname, endpoint).toString(), { + ...init, + signal: controller.signal + }); + return { + payload: await readResponseJson(response), + status: response.status + }; + } catch (error) { + return { reason: formatError(error) }; + } finally { + clearTimeout(timeout); + } +} + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function isCcrGatewayRoot(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return value.name === "claude-code-router" || value.plugin === "claude-code-router"; +} + +function isCcrGatewayHealth(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string"; +} + +function readGatewayErrorMessage(value: unknown): string | undefined { + if (!isRecord(value) || !isRecord(value.error)) { + return undefined; + } + return typeof value.error.message === "string" ? value.error.message : undefined; +} + +function existingGatewayApiKeyCandidates( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig +): Array { + const values = [ + config.APIKEY, + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []), + candidateConfig.APIKEY, + ...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []), + ...readClaudeAppGatewayApiKeyCandidates(), + ...readClaudeCodeApiKeyHelperCandidates(profile) + ]; + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const key = value?.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push(key); + } + return result.length > 0 ? result : [undefined]; +} + +function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType): string[] { + const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile)); + const files = [ + file, + ...readBackupFiles(file) + ]; + return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken)); +} + +function claudeCodeApiKeyHelperFilename(profile: ReturnType): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-api-key-${slug}.cmd` + : `ccr-claude-code-api-key-${slug}`; +} + +function readBackupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .reverse() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function readClaudeCodeApiKeyHelperToken(file: string): string { + if (!existsSync(file)) { + return ""; + } + try { + const content = readFileSync(file, "utf8"); + for (const line of content.split(/\r?\n/g)) { + const token = parseClaudeCodeApiKeyHelperLine(line); + if (token) { + return token; + } + } + } catch { + return ""; + } + return ""; +} + +function parseClaudeCodeApiKeyHelperLine(line: string): string { + const trimmed = line.trim(); + const shellPrefix = "printf '%s\\n' "; + if (trimmed.startsWith(shellPrefix)) { + return unquoteShellValue(trimmed.slice(shellPrefix.length).trim()); + } + if (/^echo\s+/i.test(trimmed)) { + return trimmed.replace(/^echo\s+/i, "").trim().replace(/^"|"$/g, ""); + } + return ""; +} + +function unquoteShellValue(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/'\\''/g, "'"); + } + return value.replace(/^"|"$/g, ""); +} + +function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appName: string): string { + if (probe.state === "unauthorized") { + const details = probe.message ? ` ${probe.message}` : ""; + return `CCR gateway is already running at ${probe.endpoint}, but it does not accept the API key for ${appName}.${details} Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "unusable") { + return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "not-ccr") { + return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`; + } + if (probe.state === "unavailable") { + return `CCR gateway is not reachable at ${probe.endpoint}${probe.reason ? `: ${probe.reason}` : ""}.`; + } + return `CCR gateway is already running at ${probe.endpoint}.`; +} + +function isAddressInUseError(message: string | undefined): boolean { + return /\bEADDRINUSE\b/i.test(message || ""); +} + +function profileGatewayEndpoint(config: AppConfig): string { + const host = probeGatewayHost(config.gateway.host); + return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; +} + +function probeGatewayHost(host: string): string { + if (!host || host === "0.0.0.0") { + return "127.0.0.1"; + } + if (host === "::") { + return "::1"; + } + return host; +} + +function formatEndpointHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } function profileGatewayConfigFor(config: AppConfig, profile: ReturnType): AppConfig { @@ -201,6 +496,10 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType, token: string): AppConfig { return { ...config, APIKEY: token, @@ -779,7 +1078,7 @@ function commandProfileRef(config: AppConfig, profile: ReturnType/dev/null 2>&1; then", - ` exec node ${shQuote(runtimeFile)} "$@"`, - "fi", `ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"` ].join("\n") + "\n"; } function windowsCcrLauncher(runtimeFile: string): string { + const nodePath = bundledNodePath(); return [ "@echo off", "setlocal", `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, + `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, + "if defined NODE_PATH (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%;%NODE_PATH%\"", + ") else (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"", + ")", "if defined CCR_NODE_BIN (", ' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*', " exit /b %ERRORLEVEL%", ")", - "where node >nul 2>nul", - "if %ERRORLEVEL%==0 (", - ' node "%CCR_CLI_RUNTIME%" %*', - " exit /b %ERRORLEVEL%", - ")", "set \"ELECTRON_RUN_AS_NODE=1\"", `${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`, "exit /b %ERRORLEVEL%" ].join("\r\n") + "\r\n"; } +function bundledNodePath(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "..", "..", "node_modules"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app", "node_modules") + ] + : []), + path.join(process.cwd(), "node_modules") + ]; + return uniqueStrings(candidates).join(path.delimiter); +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + function writeFileIfChanged(file: string, content: string): void { if (existsSync(file) && readFileSync(file, "utf8") === content) { return; diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index f0df909..3a4521b 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -44,6 +44,15 @@ export async function applyProfileConfig(config: AppConfig): Promise candidate.internalName), credentialIds: selection.credentials.map((candidate) => candidate.credentialId), credentialProtocol: target.protocol, @@ -1938,6 +1942,22 @@ function prepareUpstreamCredentialAttempt(input: { }; } +function normalizeConfiguredProviderModelBody( + body: Buffer | undefined, + config: AppConfig +): { body: Buffer; model: string } | undefined { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const selector = resolveConfiguredProviderModelSelector(model, config); + if (!parsedBody || !selector || selector.model === model) { + return undefined; + } + return { + body: serializeJsonBodyWithModel(parsedBody, selector.model), + model: selector.model + }; +} + function resolveProviderCredentialRoutingTarget( config: AppConfig, headers: Record, @@ -1951,14 +1971,14 @@ function resolveProviderCredentialRoutingTarget( const parsedBody = parseJsonObjectSafe(body); const bodyModel = stringValue(parsedBody?.model); - const parsedModel = parseProviderModelSelector(bodyModel); - if (parsedModel) { - const provider = findProviderByPublicOrInternalName(config, parsedModel.provider); + const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config); + if (modelSelector) { + const provider = modelSelector.provider; const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; if (provider && providerProtocol && activeProviderCredentials(provider).length > 0) { return { - body: parsedBody ? serializeJsonBodyWithModel(parsedBody, parsedModel.model) : body, - model: parsedModel.model, + body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, + model: modelSelector.model, provider, protocol: providerProtocol }; @@ -2001,6 +2021,44 @@ function parseProviderModelSelector(value: string | undefined): { model: string; return provider && model ? { model, provider } : undefined; } +function resolveConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + let current = normalizeRouteSelector(value); + if (!current) { + return undefined; + } + + let selectedProvider: GatewayProviderConfig | undefined; + for (let depth = 0; depth < 4; depth += 1) { + const parsed = parseProviderModelSelector(current); + if (!parsed) { + break; + } + + const provider = findProviderByPublicOrInternalName(config, parsed.provider); + if (!provider) { + break; + } + + selectedProvider = provider; + current = parsed.model; + + const nested = parseProviderModelSelector(current); + if (!nested) { + return current ? { model: current, provider } : undefined; + } + + const nestedProvider = findProviderByPublicOrInternalName(config, nested.provider); + if (!nestedProvider || providerRuntimeId(nestedProvider) !== providerRuntimeId(provider)) { + return current ? { model: current, provider } : undefined; + } + } + + return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined; +} + function firstTargetProviderHeader(headers: Record): string | undefined { const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; if (provider?.trim()) { @@ -2707,6 +2765,15 @@ function normalizeBindHost(host: string): string { return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); } +function isLoopbackBindHost(host: string): boolean { + const normalized = normalizeBindHost(host).replace(/\.$/, ""); + return normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); +} + function isWildcardBindHost(host: string): boolean { return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; } @@ -2945,14 +3012,26 @@ function applyCors(response: ServerResponse, config?: AppConfig): void { response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); } -function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): ApiKeyAuthorizationResult { - const apiKeys = configuredApiKeys(config); +async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { + let apiKeys = await configuredApiKeys(config); if (apiKeys.length === 0) { - return { ok: true }; + if (isLoopbackBindHost(config.gateway.host)) { + return { ok: true }; + } + sendJson(response, 403, { + error: { + message: "Configure at least one CCR API key before listening on a non-loopback gateway host." + } + }); + return { ok: false }; } const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); - const apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + if (!apiKey && token) { + apiKeys = await configuredApiKeys(config, { refresh: true }); + apiKey = apiKeys.find((item) => item.key === token); + } if (apiKey) { if (isApiKeyExpired(apiKey)) { sendJson(response, 401, { error: { message: "API key is expired." } }); @@ -2965,8 +3044,10 @@ function authorize(request: IncomingMessage, response: ServerResponse, config: A return { ok: false }; } -function configuredApiKeys(config: AppConfig): ApiKeyConfig[] { +async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise { + const persistedApiKeys = await loadPersistedApiKeysCached(options); const values = [ + ...persistedApiKeys, ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : []) ]; @@ -2983,6 +3064,24 @@ function configuredApiKeys(config: AppConfig): ApiKeyConfig[] { return result; } +async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { + const now = Date.now(); + if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { + return persistedApiKeyCache.values; + } + try { + const values = await loadPersistedApiKeys(); + persistedApiKeyCache = { + loadedAt: now, + values + }; + return values; + } catch (error) { + console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`); + return []; + } +} + function isApiKeyExpired(apiKey: ApiKeyConfig): boolean { if (!apiKey.expiresAt) { return false; @@ -3009,7 +3108,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming }); for (const check of checks) { - const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart); + const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now); if (counter.value + check.rule.requested > check.rule.limit) { sendJson(response, 429, { error: { @@ -3030,7 +3129,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming } for (const check of checks) { - readApiKeyWindowCounter(check.counterKey, check.windowStart).value += check.rule.requested; + readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested; } return true; } @@ -3076,7 +3175,7 @@ function providerCredentialLimitState( let utilization = 0; for (const rule of rules) { const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart); + const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now); blocked = blocked || counter.value + rule.requested > rule.limit; utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); } @@ -3141,7 +3240,7 @@ function incrementProviderCredentialCounters( const now = Date.now(); for (const rule of rules) { const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart).value += rule.requested; + readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested; } } @@ -3202,16 +3301,29 @@ function addApiKeyLimitRule( }); } -function readApiKeyWindowCounter(key: string, windowStart: number): ApiKeyWindowCounter { +function readApiKeyWindowCounter(key: string, windowStart: number, windowMs: number, now = Date.now()): ApiKeyWindowCounter { + pruneExpiredApiKeyLimitCounters(now); const existing = apiKeyLimitCounters.get(key); if (existing && existing.windowStart === windowStart) { return existing; } - const fresh = { value: 0, windowStart }; + const fresh = { + expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows, + value: 0, + windowStart + }; apiKeyLimitCounters.set(key, fresh); return fresh; } +function pruneExpiredApiKeyLimitCounters(now: number): void { + for (const [key, counter] of apiKeyLimitCounters) { + if (counter.expiresAt <= now) { + apiKeyLimitCounters.delete(key); + } + } +} + function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage { return estimateLimitUsage(request.method ?? "GET", requestBody); } diff --git a/src/server/mcp/network-capture-mcp.ts b/src/server/mcp/network-capture-mcp.ts index 21759ce..2c6e0b7 100644 --- a/src/server/mcp/network-capture-mcp.ts +++ b/src/server/mcp/network-capture-mcp.ts @@ -1,4 +1,3 @@ -import * as electron from "electron"; import packageJson from "../../../package.json"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ProxyNetworkExchange } from "../../shared/app"; @@ -170,7 +169,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise { diff --git a/src/server/proxy/service.ts b/src/server/proxy/service.ts index eb8f031..4db90ff 100644 --- a/src/server/proxy/service.ts +++ b/src/server/proxy/service.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import * as electron from "electron"; import { chmodSync, writeFileSync } from "node:fs"; import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import https from "node:https"; @@ -9,6 +8,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import tls from "node:tls"; +import { pathToFileURL } from "node:url"; import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib"; import type { AppConfig, @@ -85,6 +85,11 @@ type ActiveProxyNetworkCapture = { setResponse: (statusCode: number, headers: CapturedHeaders | IncomingHttpHeaders) => void; }; +export type ProxyDesktopIntegration = { + requestMacosCertificateInstallPermission?: () => Promise; + revealFile?: (file: string) => Promise; +}; + const requestHopByHopHeaders = new Set([ "connection", "keep-alive", @@ -119,6 +124,7 @@ class ProxyService { private authority?: CertificateAuthority; private attachedServer?: AttachedServer; private config?: AppConfig; + private desktopIntegration: ProxyDesktopIntegration = {}; private networkCaptureEnabled = false; private networkCaptures: ProxyNetworkCaptureRecord[] = []; private secureServers = new Map>(); @@ -134,6 +140,10 @@ class ProxyService { }; private upstreamProxy?: UpstreamProxyConfig; + setDesktopIntegration(integration: ProxyDesktopIntegration): void { + this.desktopIntegration = integration; + } + async start(config: AppConfig): Promise { await this.stop(); this.config = config; @@ -375,7 +385,7 @@ class ProxyService { async installCertificate(): Promise { ensureProxyCertificateAuthority(); if (process.platform === "darwin") { - const approved = await requestMacosCertificateInstallPermission(); + const approved = await this.requestMacosCertificateInstallPermission(); if (!approved) { const status = await this.getCertificateStatus(); return { @@ -398,7 +408,7 @@ class ProxyService { const installerFile = await openMacosTerminalCertificateInstaller(); terminalMessage = ` Opened Terminal installer: ${installerFile}`; } catch (terminalError) { - electron.shell?.showItemInFolder?.(PROXY_CA_CERT_FILE); + await this.revealFile(PROXY_CA_CERT_FILE).catch(() => undefined); terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`; } const status = await this.getCertificateStatus(); @@ -455,6 +465,18 @@ class ProxyService { }; } + private async requestMacosCertificateInstallPermission(): Promise { + return await (this.desktopIntegration.requestMacosCertificateInstallPermission?.() ?? Promise.resolve(true)); + } + + private async revealFile(file: string): Promise { + if (this.desktopIntegration.revealFile) { + await this.desktopIntegration.revealFile(file); + return; + } + await revealFileWithSystemCommand(file); + } + async getCertificateStatus(): Promise { const base = { caCertFile: proxyCaCertFile(), @@ -1469,25 +1491,6 @@ function execFilePromise(file: string, args: string[]): Promise { }); } -async function requestMacosCertificateInstallPermission(): Promise { - if (!electron.dialog || !electron.BrowserWindow) { - return true; - } - const window = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0]; - const options = { - buttons: ["Continue", "Cancel"], - cancelId: 1, - defaultId: 0, - detail: - "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", - message: "Install CCR Proxy CA into the macOS System keychain?", - noLink: true, - type: "warning" as const - }; - const result = window ? await electron.dialog.showMessageBox(window, options) : await electron.dialog.showMessageBox(options); - return result.response === 0; -} - function execFileText(file: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(file, args, { maxBuffer: 8 * 1024 * 1024, windowsHide: process.platform === "win32" }, (error, stdout, stderr) => { @@ -1555,17 +1558,22 @@ async function openMacosTerminalCertificateInstaller(): Promise { const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`); writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8"); chmodSync(installerFile, 0o700); - if (electron.shell?.openPath) { - const errorMessage = await electron.shell.openPath(installerFile); - if (errorMessage) { - throw new Error(errorMessage); - } - return installerFile; - } await execFilePromise("/usr/bin/open", [installerFile]); return installerFile; } +async function revealFileWithSystemCommand(file: string): Promise { + if (process.platform === "darwin") { + await execFilePromise("/usr/bin/open", ["-R", file]); + return; + } + if (process.platform === "win32") { + await execFilePromise("explorer.exe", ["/select,", file]); + return; + } + await execFilePromise("xdg-open", [pathToFileURL(path.dirname(file)).toString()]); +} + function macosTerminalCertificateInstallScript(): string { return [ "#!/bin/zsh", From c583f696664e486df47ead72a2376929297995cb Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 29 Jun 2026 20:24:27 +0800 Subject: [PATCH 005/101] Refactor router configuration and provider handling --- README.md | 2 +- README_zh.md | 2 +- build/esbuild.config.mjs | 62 +++- .../en/configuration/provider-deeplink.md | 30 +- .../zh/configuration/provider-deeplink.md | 30 +- docs/src/styles/global.css | 6 + package-lock.json | 8 +- package.json | 2 +- src/main/claude-app-gateway-service.ts | 28 +- src/main/cli.ts | 3 +- src/main/presets/index.ts | 2 + src/main/presets/kimi-coding/index.ts | 39 +++ src/main/presets/moonshot/index.ts | 6 +- src/main/profile-launch-service.ts | 4 +- src/main/profile-service.ts | 319 +++++++++++++++++- src/main/provider-account-service.ts | 251 +++++++++++++- src/main/provider-model-catalog.ts | 48 +++ src/main/provider-probe.ts | 60 +++- src/main/zcode-profile-config.ts | 18 + src/renderer/pages/home/App.tsx | 259 ++++++-------- .../pages/home/components/dashboard.tsx | 6 +- src/renderer/pages/home/components/layout.tsx | 46 +-- .../pages/home/components/providers.tsx | 220 +++++++++--- src/renderer/pages/home/components/update.tsx | 6 +- src/renderer/pages/home/shared/external.tsx | 10 +- src/renderer/pages/home/shared/i18n.tsx | 5 + src/renderer/pages/home/shared/options.ts | 1 + src/renderer/pages/home/shared/profiles.ts | 2 +- src/renderer/pages/home/shared/providers.ts | 98 +++++- src/renderer/pages/home/shared/types.ts | 1 + src/server/gateway/service.ts | 130 ++++++- src/server/mcp/fusion-tool-fallback-mcp.ts | 235 +++++++++++++ src/server/mcp/fusion-vision-mcp.ts | 5 +- src/shared/app.ts | 85 +++++ src/shared/i18n.ts | 1 + src/shared/provider-preset-utils.ts | 7 + src/shared/provider-presets.ts | 2 + 37 files changed, 1738 insertions(+), 301 deletions(-) create mode 100644 src/main/presets/kimi-coding/index.ts create mode 100644 src/server/mcp/fusion-tool-fallback-mcp.ts diff --git a/README.md b/README.md index 1e4bde0..941b7ad 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ CCR runs on your machine, keeps provider configuration in your local config dire - Use one local endpoint for multiple agent tools instead of configuring every client separately. - Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Mistral, Z.AI, Bailian, and custom providers. +- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. - Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. - Manage everything from a desktop UI instead of editing JSON by hand. - Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks. diff --git a/README_zh.md b/README_zh.md index 7c94376..b450f38 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,7 +19,7 @@ CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默 - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 - 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Mistral、Z.AI、百炼以及自定义 Provider。 +- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 - 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 - 使用桌面 UI 管理配置,减少手写 JSON。 - 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。 diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 9fc975f..9bdaba7 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -27,6 +27,15 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); +const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +const lightweightMcpBundleMaxBytes = 128 * 1024; +const forbiddenLightweightMcpInputs = [ + { prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" }, + { prefix: "src/renderer/", reason: "renderer modules do not belong in stdio MCP subprocesses" }, + { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" }, + { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" } +]; +const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]); const nodeExternals = [ "electron", @@ -112,12 +121,14 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { path.join(projectRoot, "src", "main", "main.ts"), path.join(projectRoot, "src", "main", "browser-preload.ts"), path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), + path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"), path.join(projectRoot, "src", "main", "preload.ts") ], external: nodeExternals, format: "cjs", legalComments: "none", logLevel: "info", + metafile: true, minify: mode === "production", outdir: mainOutDir, platform: "node", @@ -225,10 +236,11 @@ export function watchPlugin(name, onEnd) { } export async function buildMain(options = {}) { - await Promise.all([ + const [mainBuildResult] = await Promise.all([ esbuild.build(createMainBuildOptions(options)), esbuild.build(createCliBuildOptions(options)) ]); + validateLightweightMcpBundles(mainBuildResult.metafile); } export async function buildRenderer(options = {}) { @@ -309,6 +321,54 @@ function forbidCliElectronPlugin() { }; } +function validateLightweightMcpBundles(metafile) { + if (!metafile) { + return; + } + + const outputsByName = new Map( + Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }]) + ); + + for (const bundleName of lightweightMcpBundleNames) { + const entry = outputsByName.get(bundleName); + if (!entry) { + continue; + } + + const violations = []; + if (entry.output.bytes > lightweightMcpBundleMaxBytes) { + violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`); + } + + for (const inputPath of Object.keys(entry.output.inputs ?? {})) { + const normalizedInput = normalizeBuildPath(inputPath); + for (const rule of forbiddenLightweightMcpInputs) { + if (normalizedInput.startsWith(rule.prefix)) { + violations.push(`${normalizedInput} (${rule.reason})`); + } + } + } + + for (const imported of entry.output.imports ?? []) { + if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) { + violations.push(`${imported.path} (external native/runtime dependency is not allowed)`); + } + } + + if (violations.length > 0) { + throw new Error([ + `Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`, + ...violations.map((violation) => `- ${violation}`) + ].join("\n")); + } + } +} + +function normalizeBuildPath(value) { + return value.split(path.sep).join("/"); +} + function resolveRendererImport(importPath) { const basePath = path.resolve(rendererRoot, importPath); const candidates = [ diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index 449133c..f4a3818 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -10,55 +10,59 @@ lead: Use ccr://provider links to hand provider configuration to CCR, or open th The buttons below open the CCR desktop app's provider import confirmation dialog. Preset buttons do not include API keys; custom provider links may include one. Always confirm the provider name, Base URL, protocol, and models before importing a key.

- + OpenAIResponses / Chat Completions - + AnthropicAnthropic Messages - + Google GeminiGemini Generate Content - + OpenRouterOpenAI compatible gateway - + DeepSeekChat Completions - + Zhipu CodingChina Coding Plan - + Zhipu GeneralChina General Endpoint - + Z.ai CodingGlobal Coding Plan - + Z.ai GeneralGlobal General Endpoint - + MistralChat Completions - + Moonshot KimiChat Completions - + + + Kimi CodeCoding Plan + + Alibaba BailianDashScope compatible - + SiliconFlowChat Completions diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index b0e2f37..d51e045 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -10,55 +10,59 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。
- + OpenAI 官方Responses / Chat Completions - + Anthropic 官方Anthropic Messages - + 谷歌 GeminiGemini Generate Content - + OpenRouter 路由OpenAI compatible gateway - + DeepSeek 深度求索Chat Completions - + 智谱 Coding中国 Coding 计划 - + 智谱通用中国通用端点 - + 智谱国际 Coding全球 Coding 计划 - + 智谱国际通用全球通用端点 - + Mistral 官方Chat Completions - + 月之暗面 KimiChat Completions - + + + Kimi CodeCoding Plan + + 阿里百炼DashScope 兼容 - + 硅基流动Chat Completions diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index ab21f4e..ff83d59 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1322,6 +1322,12 @@ h1 { --provider-brand-3: #e4ddff; } +.doc-markdown a.provider-import-button.provider-kimi-coding { + --provider-brand: #111237; + --provider-brand-2: #5c6bff; + --provider-brand-3: #dfe4ff; +} + .doc-markdown a.provider-import-button.provider-bailian { --provider-brand: #5a2300; --provider-brand-2: #ff6a00; diff --git a/package-lock.json b/package-lock.json index f3783f2..3208688 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.2", "license": "MIT", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.2", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", @@ -2066,9 +2066,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.1.tgz", - "integrity": "sha512-o752COZV+AV1NiaO9TO7myr3SoeIHL5Cb9ZpPi74vPm1Eo3gP0oSxOzd48WbTQFN5ZP0YPmsMCzEBjqkyTNa9g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.2.tgz", + "integrity": "sha512-K0UiXwyC1i6dpPtQWDndJ1SgwI+zmfGpwqFUWhnVico4dxCt9eph5d9mt0RHcEduZOv4MRBdWDHyHMpxDgSUCw==", "license": "MIT", "dependencies": { "diff": "^8.0.3", diff --git a/package.json b/package.json index 8cc0416..ab86d4f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.2", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 3a56652..89d1e35 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -10,7 +10,7 @@ import { type ClaudeAppGatewayInferenceModel, type ClaudeAppGatewayModelRouteOptions } from "../shared/claude-app-gateway"; -import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app"; +import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ClaudeAppGatewayApplyResult } from "../shared/app"; import { findModelCatalogEntry } from "../server/gateway/model-catalog"; const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311"; @@ -77,6 +77,14 @@ export type ClaudeAppGatewaySyncResult = { }; export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise { + if (!hasAvailableGatewayModels(config)) { + return { + config, + configChanged: false, + result: skippedClaudeAppGatewayResult(config) + }; + } + const applied = applyClaudeAppGatewayConfig(config); if (applied.config === config) { return { @@ -93,7 +101,25 @@ export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise { const configDir = CONFIGDIR; const config = await loadAppConfig(); + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, profileOptions.profileRef); const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); diff --git a/src/main/presets/index.ts b/src/main/presets/index.ts index 424f941..2c48c59 100644 --- a/src/main/presets/index.ts +++ b/src/main/presets/index.ts @@ -2,6 +2,7 @@ import { anthropicProviderPreset } from "./anthropic"; import { bailianProviderPreset } from "./bailian"; import { deepSeekProviderPreset } from "./deepseek"; import { geminiProviderPreset } from "./gemini"; +import { kimiCodingProviderPreset } from "./kimi-coding"; import { mistralProviderPreset } from "./mistral"; import { moonshotProviderPreset } from "./moonshot"; import { openaiProviderPreset } from "./openai"; @@ -28,6 +29,7 @@ export const providerPresets: ProviderPreset[] = [ geminiProviderPreset, openRouterProviderPreset, deepSeekProviderPreset, + kimiCodingProviderPreset, zhipuCnCodingProviderPreset, zhipuCnGeneralProviderPreset, zaiGlobalCodingProviderPreset, diff --git a/src/main/presets/kimi-coding/index.ts b/src/main/presets/kimi-coding/index.ts new file mode 100644 index 0000000..9ff6dd8 --- /dev/null +++ b/src/main/presets/kimi-coding/index.ts @@ -0,0 +1,39 @@ +import type { ProviderAccountConfig } from "../../../shared/app"; +import type { ProviderPreset } from "../../../shared/provider-presets"; + +const kimiCodingProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.kimi.com/coding/v1/usages", + mapping: { + meters: [] + }, + parser: "kimi-code-usages", + type: "http-json" + } + ], + enabled: true +}; + +export const kimiCodingProviderPreset: ProviderPreset = { + account: kimiCodingProviderAccountConfig, + aliases: ["kimi code", "kimi coding", "kimi coding plan", "kimi-for-coding"], + defaultModelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + defaultModels: ["kimi-for-coding"], + endpoints: [ + { + baseUrl: "https://api.kimi.com/coding/v1", + protocols: ["openai_chat_completions"] + }, + { + baseUrl: "https://api.kimi.com/coding/", + protocols: ["anthropic_messages"] + } + ], + id: "kimi-coding", + name: "Kimi Code - Coding Plan", + websiteUrl: "https://www.kimi.com/code/docs/" +}; diff --git a/src/main/presets/moonshot/index.ts b/src/main/presets/moonshot/index.ts index ea1e75a..c7c9125 100644 --- a/src/main/presets/moonshot/index.ts +++ b/src/main/presets/moonshot/index.ts @@ -5,7 +5,7 @@ const moonshotProviderAccountConfig: ProviderAccountConfig = { connectors: [ { auth: "provider-api-key", - endpoint: "https://api.moonshot.cn/v1/users/me/balance", + endpoint: "https://api.moonshot.ai/v1/users/me/balance", mapping: { meters: [ { @@ -42,6 +42,10 @@ export const moonshotProviderPreset: ProviderPreset = { aliases: ["kimi", "moonshot"], defaultModels: ["moonshot-v1-8k"], endpoints: [ + { + baseUrl: "https://api.moonshot.ai/v1", + protocols: ["openai_chat_completions"] + }, { baseUrl: "https://api.moonshot.cn/v1", protocols: ["openai_chat_completions"] diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index ed18cdf..16b2b48 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app"; +import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandResult, type ProfileOpenRequest, type ProfileOpenResult, type ProfileRuntimeEntry, type ProfileRuntimeStatus, type ProfileStopResult } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; @@ -41,6 +41,7 @@ type RunningProfileApp = ProfileRuntimeEntry & { process.once("exit", () => stopClaudeAppBotWorker()); export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); @@ -54,6 +55,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO } export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 3a4521b..32087bd 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -1,8 +1,8 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app"; +import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app"; import { replacePersistedApiKeys } from "./api-key-store"; import { botGatewayProfileEnv } from "./bot-gateway-env"; import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; @@ -15,6 +15,8 @@ const managedRootStart = "# BEGIN CCR managed profile"; const managedRootEnd = "# END CCR managed profile"; const managedProviderStart = "# BEGIN CCR managed Codex provider"; const managedProviderEnd = "# END CCR managed Codex provider"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; const fallbackClientToken = "ccr-local"; const privateDirMode = 0o700; const privateExecutableMode = 0o700; @@ -24,13 +26,28 @@ const publicExecutableMode = 0o755; export async function applyProfileConfig(config: AppConfig): Promise { const appliedAt = new Date().toISOString(); const profiles = profileEntries(config); - const profileApiKeys = await ensureProfileApiKeys(config, profiles); const result: ProfileApplyResult = { appliedAt, clients: [], enabled: profiles.some((profile) => profile.enabled) }; + if (!result.enabled) { + result.clients = profiles.map(disabledProfileStatus); + return result; + } + + if (!hasAvailableGatewayModels(config)) { + result.clients = profiles.map((profile) => + profile.enabled + ? unavailableModelStatus(profile, profilePath(profile)) + : disabledProfileStatus(profile) + ); + return result; + } + + const profileApiKeys = await ensureProfileApiKeys(config, profiles); + for (const profile of profiles) { const token = profileApiKeys.get(profile.id) ?? fallbackClientToken; result.clients.push( @@ -56,7 +73,7 @@ export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileCon function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const settingsFile = resolveClaudeCodeSettingsFile(profile); if (!profile.enabled) { - return disabledStatus("claude-code", settingsFile, "Claude Code profile is disabled."); + return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); } try { @@ -117,7 +134,12 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const clientName = codexCompatibleClientName(profile.agent); const configFile = resolveCodexConfigFile(profile); if (!profile.enabled) { - return disabledStatus(profile.agent, configFile, `${clientName} profile is disabled.`); + return restoreDisabledGlobalProfile( + profile, + configFile, + `${clientName} profile is disabled.`, + (content) => isManagedCodexConfigContent(content, sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router") + ); } try { @@ -187,7 +209,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str function applyZcodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const configFile = resolveZcodeConfigFile(profile); if (!profile.enabled) { - return disabledStatus("zcode", configFile, "ZCode profile is disabled."); + return restoreDisabledZcodeProfile(profile, configFile); } try { @@ -942,6 +964,7 @@ function writeFileWithBackup( chmodFileIfRequested(file, options.mode); return { changed: false }; } + ensureOriginalSnapshot(file, previous, options.mode); const backupFile = previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -952,6 +975,200 @@ function writeFileWithBackup( return { backupFile, changed: true }; } +type RestoreFileResult = { + backupFile?: string; + changed: boolean; + file: string; + missingBackup: boolean; + restored: boolean; +}; + +function restoreDisabledGlobalProfile( + profile: ProfileConfig, + file: string, + disabledMessage: string, + isManagedContent: (content: string) => boolean +): ProfileClientApplyStatus { + if (!isGlobalProfile(profile)) { + return disabledStatus(profile.agent, file, disabledMessage); + } + + const restoreResult = restoreGlobalConfigFile(file, { isManagedContent, mode: privateFileMode }); + return disabledRestoreStatus(profile.agent, file, disabledMessage, restoreResult, profile.name || profile.id || profile.agent); +} + +function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus { + if (profile.agent === "claude-code") { + return restoreDisabledGlobalProfile(profile, resolveClaudeCodeSettingsFile(profile), "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); + } + if (profile.agent === "zcode") { + return restoreDisabledZcodeProfile(profile, resolveZcodeConfigFile(profile)); + } + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + return restoreDisabledGlobalProfile( + profile, + resolveCodexConfigFile(profile), + "Codex profile is disabled.", + (content) => isManagedCodexConfigContent(content, providerId) + ); +} + +function restoreDisabledZcodeProfile(profile: ProfileConfig, configFile: string): ProfileClientApplyStatus { + const disabledMessage = "ZCode profile is disabled."; + if (!isGlobalProfile(profile)) { + return disabledStatus("zcode", configFile, disabledMessage); + } + + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + const storageRoot = zcodeHomeFromConfigFile(configFile); + const files = [ + configFile, + path.join(storageRoot, "v2", "config.json"), + path.join(storageRoot, "v2", "bots-model-cache.v2.json") + ]; + const results = files.map((file) => + restoreGlobalConfigFile(file, { + isManagedContent: (content) => isManagedZcodeConfigContent(content, providerId), + mode: privateFileMode + }) + ); + const changed = results.some((result) => result.changed); + const restored = results.some((result) => result.restored); + const missingBackup = results.some((result) => result.missingBackup); + return { + backupFile: results.find((result) => result.backupFile)?.backupFile, + client: "zcode", + enabled: false, + message: missingBackup + ? `${disabledMessage} No original ZCode config backup was found for ${profile.name || profile.id || "this profile"}.` + : restored + ? changed + ? "ZCode config was restored from the CCR backup because the global profile is disabled." + : "ZCode config already matches the CCR backup; profile is disabled." + : disabledMessage, + ok: !missingBackup, + path: resolveUserPath(configFile) + }; +} + +function disabledRestoreStatus( + client: ProfileClientKind, + file: string, + disabledMessage: string, + restoreResult: RestoreFileResult, + profileName: string +): ProfileClientApplyStatus { + return { + backupFile: restoreResult.backupFile, + client, + enabled: false, + message: restoreResult.missingBackup + ? `${disabledMessage} No original ${codexCompatibleClientName(client)} config backup was found for ${profileName}.` + : restoreResult.restored + ? restoreResult.changed + ? `${codexCompatibleClientName(client)} config was restored from the CCR backup because the global profile is disabled.` + : `${codexCompatibleClientName(client)} config already matches the CCR backup; profile is disabled.` + : disabledMessage, + ok: !restoreResult.missingBackup, + path: resolveUserPath(file) + }; +} + +function restoreGlobalConfigFile( + file: string, + options: { + isManagedContent: (content: string) => boolean; + mode?: number; + } +): RestoreFileResult { + const current = existsSync(file) ? readFileSync(file, "utf8") : undefined; + const currentManaged = current !== undefined && options.isManagedContent(current); + if (current !== undefined && !currentManaged) { + return { changed: false, file, missingBackup: false, restored: false }; + } + + if (existsSync(originalMissingFilePath(file))) { + if (currentManaged) { + const backupFile = backupCurrentConfigFile(file, options.mode); + rmSync(file, { force: true }); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; + } + return { changed: false, file, missingBackup: false, restored: current === undefined }; + } + + const snapshot = originalSnapshotCandidate(file, options.isManagedContent); + if (!snapshot) { + return { + changed: false, + file, + missingBackup: Boolean(currentManaged), + restored: false + }; + } + + if (current === snapshot.content) { + chmodFileIfRequested(file, options.mode); + return { changed: false, file, missingBackup: false, restored: true }; + } + + const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; +} + +function originalSnapshotCandidate( + file: string, + isManagedContent: (content: string) => boolean +): { content: string; file: string } | undefined { + for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) { + if (!existsSync(candidate)) { + continue; + } + const content = readFileSync(candidate, "utf8"); + if (!isManagedContent(content)) { + return { content, file: candidate }; + } + } + return undefined; +} + +function backupCurrentConfigFile(file: string, mode: number | undefined): string { + const backupFile = backupFilePath(file); + copyFileSync(file, backupFile); + chmodFileIfRequested(backupFile, mode); + return backupFile; +} + +function backupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function ensureOriginalSnapshot(file: string, previous: string | undefined, mode: number | undefined): void { + const originalBackup = originalBackupFilePath(file); + const originalMissing = originalMissingFilePath(file); + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + chmodFileIfRequested(originalMissing, mode); + return; + } + copyFileSync(file, originalBackup); + chmodFileIfRequested(originalBackup, mode); +} + function chmodFileIfRequested(file: string, mode: number | undefined): void { if (mode === undefined || process.platform === "win32") { return; @@ -968,6 +1185,14 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function originalBackupFilePath(file: string): string { + return `${file}${originalBackupSuffix}`; +} + +function originalMissingFilePath(file: string): string { + return `${file}${originalMissingSuffix}`; +} + function disabledStatus(client: ProfileClientKind, file: string, message: string): ProfileClientApplyStatus { return { client, @@ -978,6 +1203,81 @@ function disabledStatus(client: ProfileClientKind, file: string, message: string }; } +function unavailableModelStatus(profile: ProfileConfig, file: string): ProfileClientApplyStatus { + return { + client: profile.agent, + enabled: true, + message: NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, + ok: false, + path: resolveUserPath(file) + }; +} + +function disabledProfileMessage(profile: ProfileConfig): string { + if (profile.agent === "claude-code") { + return "Claude Code profile is disabled."; + } + return `${codexCompatibleClientName(profile.agent)} profile is disabled.`; +} + +function isGlobalProfile(profile: ProfileConfig): boolean { + return normalizeProfileScope(profile.scope) === "global"; +} + +function isManagedClaudeCodeSettingsContent(content: string): boolean { + const settings = parseJsonContent(content); + if (!settings) { + return false; + } + const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : ""; + if (apiKeyHelper.includes("ccr-claude-code-api-key-")) { + return true; + } + const env = isRecord(settings.env) ? settings.env : {}; + return typeof env.ANTHROPIC_BASE_URL === "string" && + typeof env.ANTHROPIC_API_BASE_URL === "string" && + typeof env.CLAUDE_AGENT_API_BASE_URL === "string"; +} + +function isManagedCodexConfigContent(content: string, providerId: string): boolean { + if (content.includes(managedRootStart) || content.includes(managedProviderStart)) { + return true; + } + const escapedProvider = escapeRegExp(providerId); + return new RegExp(`^\\s*\\[model_providers\\.(?:${escapedProvider}|${escapeRegExp(tomlQuotedKey(providerId))})\\]`, "m").test(content); +} + +function isManagedZcodeConfigContent(content: string, providerId: string): boolean { + const config = parseJsonContent(content); + if (!config) { + return false; + } + if (isRecord(config.provider) && hasOwn(config.provider, providerId)) { + return true; + } + if (isRecord(config.model) && typeof config.model.main === "string" && config.model.main.startsWith(`${providerId}/`)) { + return true; + } + for (const key of ["defaultModel", "lastUsed", "lastUsedModel"]) { + const modelRef = config[key]; + if (isRecord(modelRef) && modelRef.providerId === providerId) { + return true; + } + } + return Array.isArray(config.providers) && config.providers.some((provider) => + isRecord(provider) && provider.id === providerId + ); +} + +function parseJsonContent(content: string): Record | undefined { + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + function gatewayEndpoint(config: AppConfig): string { const host = config.gateway.host === "0.0.0.0" ? "127.0.0.1" : config.gateway.host || "127.0.0.1"; const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; @@ -1040,6 +1340,9 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli } function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { + if (agent === "claude-code") { + return "Claude Code"; + } return agent === "zcode" ? "ZCode" : "Codex"; } @@ -1131,6 +1434,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/main/provider-account-service.ts b/src/main/provider-account-service.ts index 52b5478..68ba07e 100644 --- a/src/main/provider-account-service.ts +++ b/src/main/provider-account-service.ts @@ -82,9 +82,13 @@ export async function getProviderAccountSnapshots( }); const snapshots = await Promise.all( - providers.flatMap((provider) => - providerAccountTargets(provider).map((target) => resolveProviderAccountSnapshot(config, target, options)) - ) + providers.flatMap((provider) => { + const targets = providerAccountTargets(provider); + if (targets.length > 0) { + return targets.map((target) => resolveProviderAccountSnapshot(config, target, options)); + } + return providerAccountUnavailableSnapshots(provider).map((snapshot) => Promise.resolve(snapshot)); + }) ); return snapshots.filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); } @@ -125,6 +129,17 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR type: "http-json" }; const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + meters, + message: meters.length === 0 ? "No usage data available." : undefined, + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + const meters = connector.mapping.meters .map((meter) => mappedMeterFromPayload(meter, payload)) .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); @@ -298,6 +313,50 @@ function providerAccountTargets(provider: GatewayProviderConfig): ProviderAccoun return providerAccount && providerApiKey(provider) ? [{ account: providerAccount, provider }] : []; } +function providerAccountUnavailableSnapshots(provider: GatewayProviderConfig): ProviderAccountSnapshot[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length > 0) { + return credentials + .map((credential) => providerAccountUnavailableSnapshot(provider, credential.account ?? provider.account, credential)) + .filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); + } + + const snapshot = providerAccountUnavailableSnapshot(provider, provider.account); + return snapshot ? [snapshot] : []; +} + +function providerAccountUnavailableSnapshot( + provider: GatewayProviderConfig, + account: ProviderAccountConfig | undefined, + credential?: ProviderCredentialConfig +): ProviderAccountSnapshot | undefined { + const providerName = provider.name.trim(); + if (!providerName || !account?.enabled || !providerAccountConnectorsAreDefaultStandard(account.connectors ?? [])) { + return undefined; + } + if (effectiveProviderAccountConfig(provider, account)) { + return undefined; + } + + const message = "No supported account usage endpoint is available for this provider. Configure an HTTP JSON connector or disable account balance."; + return { + credentialId: credential ? providerCredentialRuntimeId(provider, credential) : undefined, + credentialLabel: credential?.name ?? credential?.label ?? credential?.id, + errors: [ + { + message, + source: "unsupported" + } + ], + message, + meters: [], + provider: providerName, + source: "unsupported", + status: "unsupported", + updatedAt: new Date().toISOString() + }; +} + function effectiveProviderAccount(provider: GatewayProviderConfig): ProviderAccountConfig | undefined { return effectiveProviderAccountConfig(provider, provider.account); } @@ -447,6 +506,16 @@ async function resolveHttpJsonConnector( ...(connector.headers ?? {}), ...(request.headers ?? {}) }, connector.method, connector.body); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + errors: [], + message: meters.length === 0 ? "No usage data available." : undefined, + meters, + source: "http-json" + }; + } + const meters = connector.mapping.meters .map((meter) => mappedMeterFromPayload(meter, payload)) .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); @@ -620,6 +689,172 @@ function normalizeRemoteSnapshot( }; } +function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] { + if (!isRecord(payload)) { + return []; + } + + const meters: ProviderAccountMeter[] = []; + const usage = isRecord(payload.usage) ? kimiCodeUsageMeter(payload.usage, "weekly_quota", "Weekly quota") : undefined; + if (usage) { + meters.push(usage); + } + + if (Array.isArray(payload.limits)) { + const seenIds = new Set(meters.map((meter) => meter.id)); + payload.limits.forEach((item, index) => { + if (!isRecord(item)) { + return; + } + const detail = isRecord(item.detail) ? item.detail : item; + const window = isRecord(item.window) ? item.window : {}; + const label = kimiCodeUsageLimitLabel(item, detail, window, index); + const meter = kimiCodeUsageMeter(detail, uniqueKimiCodeUsageMeterId(kimiCodeUsageMeterId(item, detail, label, index), seenIds), label, item); + if (meter) { + seenIds.add(meter.id); + meters.push(meter); + } + }); + } + + return meters; +} + +function kimiCodeUsageMeter( + data: Record, + id: string, + defaultLabel: string, + fallbackData?: Record +): ProviderAccountMeter | undefined { + const limit = normalizeNumber(data.limit); + let used = normalizeNumber(data.used); + let remaining = normalizeNumber(data.remaining); + if (used === undefined && remaining !== undefined && limit !== undefined) { + used = limit - remaining; + } + if (remaining === undefined && used !== undefined && limit !== undefined) { + remaining = limit - used; + } + if (limit === undefined && used === undefined && remaining === undefined) { + return undefined; + } + + const label = readString(data.name) || readString(data.title) || defaultLabel; + const resetAt = kimiCodeUsageResetAt(data) ?? (fallbackData ? kimiCodeUsageResetAt(fallbackData) : undefined); + if (limit !== undefined && limit > 0) { + const remainingRatio = remaining !== undefined + ? Math.max(0, Math.min(1, remaining / limit)) + : used !== undefined + ? Math.max(0, Math.min(1, (limit - used) / limit)) + : undefined; + const remainingPercent = remainingRatio === undefined ? undefined : remainingRatio * 100; + return { + id, + kind: "quota", + label, + limit: 100, + remaining: remainingPercent, + resetAt, + source: "http-json", + unit: "%", + used: remainingPercent === undefined ? undefined : 100 - remainingPercent + }; + } + + return { + id, + kind: "quota", + label, + limit, + remaining, + resetAt, + source: "http-json", + unit: "quota", + used + }; +} + +function kimiCodeUsageLimitLabel( + item: Record, + detail: Record, + window: Record, + index: number +): string { + const named = readString(item.name) || readString(detail.name) || readString(item.title) || readString(detail.title) || readString(item.scope) || readString(detail.scope); + if (named) { + return named; + } + + const duration = normalizeNumber(window.duration) ?? normalizeNumber(item.duration) ?? normalizeNumber(detail.duration); + const timeUnit = (readString(window.timeUnit) || readString(item.timeUnit) || readString(detail.timeUnit) || "").toUpperCase(); + if (duration && duration > 0) { + if (timeUnit.includes("MINUTE")) { + return duration >= 60 && duration % 60 === 0 ? `${duration / 60}h quota` : `${duration}m quota`; + } + if (timeUnit.includes("HOUR")) { + return `${duration}h quota`; + } + if (timeUnit.includes("DAY")) { + return `${duration}d quota`; + } + return `${duration}s quota`; + } + + return `Limit #${index + 1}`; +} + +function kimiCodeUsageMeterId( + item: Record, + detail: Record, + label: string, + index: number +): string { + const explicit = readString(item.id) || readString(detail.id) || readString(item.name) || readString(detail.name) || readString(item.scope) || readString(detail.scope); + return providerCredentialSlug(explicit || label || `limit-${index + 1}`) || `limit-${index + 1}`; +} + +function uniqueKimiCodeUsageMeterId(id: string, seenIds: Set): string { + if (!seenIds.has(id)) { + return id; + } + let index = 2; + while (seenIds.has(`${id}-${index}`)) { + index += 1; + } + return `${id}-${index}`; +} + +function kimiCodeUsageResetAt(data: Record): string | undefined { + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + const value = data[key]; + const timestamp = typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/.test(value.trim()) + ? Number(value) + : undefined; + if (timestamp !== undefined && Number.isFinite(timestamp)) { + const milliseconds = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + } + + const dateString = readString(value); + if (dateString) { + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? dateString : date.toISOString(); + } + } + + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = normalizeNumber(data[key]); + if (seconds && seconds > 0) { + return new Date(Date.now() + seconds * 1000).toISOString(); + } + } + + return undefined; +} + function normalizeRemoteErrors(value: unknown, source: ProviderAccountConnectorSource): ProviderAccountConnectorError[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -773,14 +1008,16 @@ function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProvi function localCodexAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { const codexOauth = isRecord(plugin.codexOauth) ? plugin.codexOauth : {}; const codexAuth = readCodexAuth(); + // Imported plugins contain a point-in-time access token. Prefer the live Codex + // auth file so account checks follow tokens refreshed by Codex CLI/App. const apiKey = + codexAuth?.accessToken || readString(codexOauth.accessToken) || - readString(codexOauth.access_token) || - codexAuth?.accessToken; + readString(codexOauth.access_token); const accountId = + codexAuth?.accountId || readString(codexOauth.accountId) || - readString(codexOauth.account_id) || - codexAuth?.accountId; + readString(codexOauth.account_id); const headers = { ...localProviderPluginAuthHeaders(plugin), ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index 55cea18..e9e465c 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -35,6 +35,7 @@ const presetCatalogProviderIds: Record = { bailian: ["alibaba-cn"], deepseek: ["deepseek"], gemini: ["google"], + "kimi-coding": ["kimi-for-coding"], mistral: ["mistral"], moonshot: ["moonshotai-cn"], openai: ["openai"], @@ -46,10 +47,29 @@ const presetCatalogProviderIds: Record = { "zhipu-cn-general": ["zhipuai"] }; +const presetCatalogModelOverrides: Record; models: string[]; provider?: string; providerName?: string }> = { + "kimi-coding": { + modelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + models: ["kimi-for-coding"], + provider: "kimi-for-coding", + providerName: "Kimi Code" + } +}; + let catalogIndex: CatalogIndex | undefined; export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult { const index = loadCatalogIndex(); + const modelOverride = providerCatalogModelOverride(request); + if (modelOverride) { + return { + loadedFrom: index.loadedFrom, + ...modelOverride + }; + } + const match = findBestCatalogProviderMatch(index.providers, request); if (!match) { return { @@ -67,6 +87,34 @@ export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): }; } +function providerCatalogModelOverride(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult | undefined { + const providerPresetId = request.providerPresetId?.trim() || ""; + const providerPresetOverride = presetCatalogModelOverrides[providerPresetId]; + if (providerPresetOverride) { + return { + matchedBy: "provider-id", + modelDisplayNames: providerPresetOverride.modelDisplayNames, + models: providerPresetOverride.models, + provider: providerPresetOverride.provider, + providerName: providerPresetOverride.providerName + }; + } + + const baseUrlPresetId = request.baseUrl ? findProviderPresetByBaseUrl(request.baseUrl)?.id ?? "" : ""; + const baseUrlOverride = presetCatalogModelOverrides[baseUrlPresetId]; + if (baseUrlOverride) { + return { + matchedBy: "base-url", + modelDisplayNames: baseUrlOverride.modelDisplayNames, + models: baseUrlOverride.models, + provider: baseUrlOverride.provider, + providerName: baseUrlOverride.providerName + }; + } + + return undefined; +} + function loadCatalogIndex(): CatalogIndex { if (catalogIndex) { return catalogIndex; diff --git a/src/main/provider-probe.ts b/src/main/provider-probe.ts index 9f3b5dc..48032f6 100644 --- a/src/main/provider-probe.ts +++ b/src/main/provider-probe.ts @@ -34,12 +34,14 @@ type FetchJsonResult = { type ModelProbeResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; source?: ModelSource; }; type ModelFetchResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; }; @@ -225,6 +227,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) return { capabilities: capabilitiesFromProtocolResults(protocolResults), detectedProtocol, + modelDisplayNames: modelProbe.modelDisplayNames, modelSource: modelProbe.source, models: modelProbe.models, normalizedBaseUrl: detectedProtocol @@ -402,11 +405,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "openai"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "openai"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -424,11 +427,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "anthropic"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "anthropic"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -446,7 +449,7 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }); return { baseUrl: parsed.geminiBaseUrl, - models: parseModelIds(result.payload, "gemini") + ...parseModelList(result.payload, "gemini") }; } @@ -731,17 +734,41 @@ function geminiHeaders(apiKey: string | undefined): Record { : {}; } -function parseModelIds(payload: unknown, source: ModelSource): string[] { +function parseModelList(payload: unknown, source: ModelSource): Pick { if (!isRecord(payload)) { - return []; + return { + models: [] + }; } const items = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : []; - const models = items - .map((item) => readModelId(item, source)) - .filter((item): item is string => Boolean(item)); + const models: string[] = []; + const modelDisplayNames: Record = {}; - return uniqueStrings(models); + for (const item of items) { + const model = readModelId(item, source); + if (!model) { + continue; + } + models.push(model); + + const displayName = readModelDisplayName(item); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + + const uniqueModels = uniqueStrings(models); + const uniqueDisplayNames = Object.fromEntries( + uniqueModels + .map((model) => [model, modelDisplayNames[model]] as const) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + ); + + return { + modelDisplayNames: Object.keys(uniqueDisplayNames).length > 0 ? uniqueDisplayNames : undefined, + models: uniqueModels + }; } function readModelId(value: unknown, source: ModelSource): string | undefined { @@ -771,6 +798,13 @@ function readModelId(value: unknown, source: ModelSource): string | undefined { return rawId; } +function readModelDisplayName(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return readString(value.display_name) || readString(value.displayName) || readString(value.label); +} + function stripGeminiModelPrefix(value: string): string { return value.replace(/^models\//i, ""); } diff --git a/src/main/zcode-profile-config.ts b/src/main/zcode-profile-config.ts index 073eba2..8ac037c 100644 --- a/src/main/zcode-profile-config.ts +++ b/src/main/zcode-profile-config.ts @@ -23,6 +23,8 @@ type ZcodeGatewayConfigValues = { const legacyZcodeTomlConfigFile = "~/.zcode/config.toml"; const defaultZcodeConfigFile = "~/.zcode/cli/config.json"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; export function resolveZcodeConfigFile(profile: Pick): string { const configured = profile.configFile?.trim(); @@ -227,6 +229,9 @@ function writeJsonFile(file: string, value: Record, options: { if (previous === content) { return { changed: false, file }; } + if (options.backup !== false) { + ensureOriginalSnapshot(file, previous); + } const backupFile = options.backup === false || previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -252,6 +257,19 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function ensureOriginalSnapshot(file: string, previous: string | undefined): void { + const originalBackup = `${file}${originalBackupSuffix}`; + const originalMissing = `${file}${originalMissingSuffix}`; + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + return; + } + copyFileSync(file, originalBackup); +} + function isLegacyZcodeTomlConfigFile(value: string): boolean { return value === legacyZcodeTomlConfigFile || resolveUserPath(value) === path.join(os.homedir(), ".zcode", "config.toml"); diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index de581e4..6173bd2 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -6,7 +6,7 @@ import { ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis, copyTextToClipboard, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft, - createProfileDraftFromProfile, createProviderConfigFromDeepLink, createProviderDraft, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, + createProfileDraftFromProfile, createProviderDraft, createProviderDraftFromDeepLinkPayload, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, createVirtualModelDraft, createVirtualModelDraftFromProfile, customProviderPresetId, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, @@ -17,7 +17,7 @@ import { isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, isTraySupportedPlatform, isRoutingRewriteDraftRowValid, - LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists, + LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, @@ -25,11 +25,10 @@ import { providerCredentialsFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, - providerDeepLinkDisplayIcon, - profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, + profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, resolveProviderDeepLinkIcon, RouterRule, ServerActionBusy, SettingsPageId, + ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId, routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, @@ -199,11 +198,11 @@ function App() { const [providerProbeLoading, setProviderProbeLoading] = useState(false); const [providerConnectivityProbe, setProviderConnectivityProbe] = useState(); const [providerConnectivityLoading, setProviderConnectivityLoading] = useState(false); + const [providerImportOpen, setProviderImportOpen] = useState(false); + const [providerImportPayload, setProviderImportPayload] = useState(); const [providerDeepLinkRequest, setProviderDeepLinkRequest] = useState(); const [providerDeepLinkBusy, setProviderDeepLinkBusy] = useState(false); const [providerDeepLinkError, setProviderDeepLinkError] = useState(""); - const [providerDeepLinkIconLoading, setProviderDeepLinkIconLoading] = useState(false); - const [providerDeepLinkModelsLoading, setProviderDeepLinkModelsLoading] = useState(false); const [proxyCertificateChecking, setProxyCertificateChecking] = useState(false); const [proxyEnablePending, setProxyEnablePending] = useState(false); const [providerProbeError, setProviderProbeError] = useState(""); @@ -339,7 +338,7 @@ function App() { }, []); useEffect(() => { - if (!updateDialogOpen || !window.ccr) { + if (!window.ccr) { return; } @@ -366,7 +365,7 @@ function App() { disposed = true; unsubscribe(); }; - }, [updateDialogOpen]); + }, [appInfo.version]); useEffect(() => { if (!window.ccr) { @@ -377,6 +376,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderAddOpen(false); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderEditIndex(undefined); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -386,7 +387,6 @@ function App() { setProviderDeepLinkRequest(request); setProviderDeepLinkError(""); setProviderDeepLinkBusy(false); - setProviderDeepLinkModelsLoading(false); setActiveView("providers"); }; @@ -405,89 +405,12 @@ function App() { }, []); useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkIconRequestId.current += 1; - const requestId = providerDeepLinkIconRequestId.current; - - if (!request || !payload || !providerPresetsLoaded || providerDeepLinkDisplayIcon(payload)) { - setProviderDeepLinkIconLoading(false); + const payload = providerDeepLinkRequest?.provider; + if (!payload || !configLoaded || !providerPresetsLoaded) { return; } - - setProviderDeepLinkIconLoading(true); - void resolveProviderDeepLinkIcon(payload) - .then((resolution) => { - if (providerDeepLinkIconRequestId.current !== requestId || !resolution.persistentIcon) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id) { - return current; - } - if (current.provider.icon?.trim()) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - icon: resolution.persistentIcon - } - }; - }); - }) - .finally(() => { - if (providerDeepLinkIconRequestId.current === requestId) { - setProviderDeepLinkIconLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.icon, providerPresetsLoaded]); - - useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkCatalogModelsRequestId.current += 1; - const requestId = providerDeepLinkCatalogModelsRequestId.current; - const hasApiKey = Boolean(payload?.apiKey?.trim()); - - if (!request || !payload || (!hasApiKey && payload.models.length > 0) || !providerPresetsLoaded) { - setProviderDeepLinkModelsLoading(false); - return; - } - - const modelsPromise = hasApiKey - ? probeProviderDeepLinkPayload(payload).then((probe) => mergeProviderModelLists(probe?.models ?? [])) - : resolveProviderDeepLinkCatalogModels(payload); - - setProviderDeepLinkModelsLoading(true); - void modelsPromise - .then((models) => { - if (providerDeepLinkCatalogModelsRequestId.current !== requestId || models.length === 0) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id || (!hasApiKey && current.provider.models.length > 0)) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - models - } - }; - }); - }) - .catch(() => { - // Model discovery is optional; importing performs the same resolution again. - }) - .finally(() => { - if (providerDeepLinkCatalogModelsRequestId.current === requestId) { - setProviderDeepLinkModelsLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.apiKey, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.name, providerPresetsLoaded]); + void openImportProviderDialog(payload); + }, [configLoaded, providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider, providerPresetsLoaded]); useEffect(() => { if (!window.ccr) { @@ -717,8 +640,6 @@ function App() { const onboardingProfileDraftSource = useRef(""); const providerProbeRequestId = useRef(0); const providerConnectivityRequestId = useRef(0); - const providerDeepLinkCatalogModelsRequestId = useRef(0); - const providerDeepLinkIconRequestId = useRef(0); const toastTimer = useRef(); const shouldReduceMotion = useReducedMotion(); @@ -887,6 +808,14 @@ function App() { void checkForAppUpdate(); } + function openUpdateDownloadDialog() { + setUpdateDialogOpen(true); + setUpdateActionError(""); + if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") { + void downloadAppUpdate(); + } + } + async function checkForAppUpdate() { if (updateActionBusyRef.current) { return; @@ -1097,6 +1026,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraft(draftConfig.Providers)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1117,6 +1048,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(index); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraftFromProvider(provider)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1126,6 +1059,63 @@ function App() { setProviderAddOpen(true); } + async function openImportProviderDialog(payload: ProviderDeepLinkPayload) { + if (!providerPresetsLoaded) { + return; + } + const requestId = providerProbeRequestId.current + 1; + providerProbeRequestId.current = requestId; + providerConnectivityRequestId.current += 1; + setProviderDeepLinkBusy(true); + let nextPayload = payload; + let catalogModelDisplayNames: Record | undefined; + let probe: GatewayProviderProbeResult | undefined; + if (nextPayload.models.length === 0) { + const catalogModels = await resolveProviderDeepLinkCatalogModels(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + catalogModelDisplayNames = catalogModels.modelDisplayNames; + if (catalogModels.models.length > 0) { + nextPayload = { + ...nextPayload, + models: catalogModels.models + }; + } + } + probe = await probeProviderDeepLinkPayload(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + if (nextPayload.apiKey?.trim() && probe?.models.length) { + nextPayload = { + ...nextPayload, + models: probe.models + }; + } + + const initialDraftFromPayload = createProviderDraftFromDeepLinkPayload(nextPayload, draftConfig.Providers); + const initialDraft = { + ...initialDraftFromPayload, + modelDisplayNames: mergeModelDisplayNames(initialDraftFromPayload.modelDisplayNames, catalogModelDisplayNames) + }; + setProviderEditIndex(undefined); + setProviderImportOpen(true); + setProviderImportPayload(nextPayload); + setProviderDraft(probe ? applyProviderProbeResult(initialDraft, probe) : initialDraft); + setProviderProbe(probe); + setProviderConnectivityProbe(undefined); + setProviderProbeError(""); + setProviderProbeLoading(false); + setProviderConnectivityLoading(false); + setProviderDeepLinkRequest(undefined); + setProviderDeepLinkError(""); + setProviderDeepLinkBusy(false); + setProviderAddOpen(true); + } + function updateProviderDraft(patch: Partial, resetProbe = false) { const shouldResetProtocolProbe = resetProbe && (patch.baseUrl !== undefined || patch.presetId !== undefined || patch.protocol !== undefined); const shouldResetConnectivityProbe = resetProbe || @@ -1148,6 +1138,7 @@ function App() { return { ...next, + modelDisplayNames: patch.modelDisplayNames, modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"), selectedModels: [], selectedProtocols: patch.selectedProtocols ?? current.selectedProtocols @@ -1430,6 +1421,7 @@ function App() { type: protocol }; const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol); + const wasImport = providerImportOpen; const next = buildConfigUpdate((config) => { if (providerEditIndex === undefined) { @@ -1446,7 +1438,12 @@ function App() { setConfigDraft(next); if (await persistConfig(next, setProviderProbeError)) { setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderAddOpen(false); + if (wasImport) { + showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${provider.name}`.trim()); + } if (activeView === "onboarding") { setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); } @@ -1461,10 +1458,15 @@ function App() { return; } + if (request.provider) { + await openImportProviderDialog(request.provider); + return; + } + setProviderDeepLinkBusy(true); setProviderDeepLinkError(""); try { - if (!request.provider && request.manifest) { + if (request.manifest) { if (!window.ccr?.fetchProviderManifest) { throw new Error("Request failed."); } @@ -1477,61 +1479,7 @@ function App() { return; } - let payload = request.provider; - if (!payload) { - setProviderDeepLinkBusy(false); - return; - } - const identityIssue = providerIdentitySafetyIssue({ - baseUrl: payload.baseUrl, - name: payload.name - }); - if (identityIssue) { - throw new Error(identityIssue.message); - } - const iconResolution = await resolveProviderDeepLinkIcon(payload); - if (iconResolution.persistentIcon && iconResolution.persistentIcon !== payload.icon?.trim()) { - payload = { - ...payload, - icon: iconResolution.persistentIcon - }; - } - if (payload.models.length === 0) { - const catalogModels = await resolveProviderDeepLinkCatalogModels(payload); - if (catalogModels.length > 0) { - payload = { - ...payload, - models: catalogModels - }; - } - } - const probe = await probeProviderDeepLinkPayload(payload); - if (payload.apiKey?.trim() && probe?.models.length) { - payload = { - ...payload, - models: probe.models - }; - } - let importedProviderName = payload.name?.trim() || ""; - const next = buildConfigUpdate((config) => { - const provider = createProviderConfigFromDeepLink(payload, config.Providers, probe); - importedProviderName = provider.name; - config.Providers.push(provider); - if (!config.preferredProvider) { - config.preferredProvider = provider.name; - } - return config; - }); - setConfigDraft(next); - const saved = await persistConfig(next, setProviderDeepLinkError); setProviderDeepLinkBusy(false); - if (saved) { - setProviderDeepLinkRequest(undefined); - showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${importedProviderName}`.trim()); - if (activeView === "onboarding") { - setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); - } - } } catch (error) { setProviderDeepLinkError(formatError(error)); setProviderDeepLinkBusy(false); @@ -2802,7 +2750,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} - onCheckUpdate={openUpdateDialog} + onDownloadUpdate={openUpdateDownloadDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} @@ -2811,6 +2759,7 @@ function App() { sidebarOpen={sidebarOpen} toggleGatewayService={toggleGatewayService} updateActionBusy={Boolean(updateActionBusy)} + updateStatus={updateDialogStatus} visibleNavigation={visibleNavigation} viewProps={{ apiKeys: { @@ -3044,17 +2993,16 @@ function App() { onStopApp: () => void stopProfileApp(profileOpenDialog.profile), profile: profileOpenDialog.profile } : undefined} - providerDeepLink={providerDeepLinkRequest ? { + providerDeepLink={providerDeepLinkRequest && !providerDeepLinkRequest.provider ? { busy: providerDeepLinkBusy, error: providerDeepLinkError, - iconLoading: providerDeepLinkIconLoading, onClose: () => { if (!providerDeepLinkBusy) { setProviderDeepLinkRequest(undefined); } }, onSubmit: confirmProviderDeepLinkImport, - modelsLoading: providerDeepLinkModelsLoading, + presetsLoaded: providerPresetsLoaded, request: providerDeepLinkRequest } : undefined} providerDelete={providerDeleteItem ? { @@ -3068,18 +3016,23 @@ function App() { connectivityProbe: providerConnectivityProbe, draft: providerDraft, error: providerProbeError, + importProvider: providerImportOpen ? providerImportPayload : undefined, onChange: updateProviderDraft, mode: providerEditIndex === undefined ? "add" : "edit", onClose: () => { setProviderAddOpen(false); setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); }, onCheck: checkProviderDraft, onSubmit: submitProviderDraft, probe: providerProbe, probeLoading: providerProbeLoading, providerPlugins: draftConfig.providerPlugins ?? [], - providers: draftConfig.Providers + providers: draftConfig.Providers, + submitLabel: providerImportOpen ? t("Import") : undefined, + title: providerImportOpen ? t("Import Provider") : undefined } : undefined} routingDelete={routingDeleteRule ? { onClose: () => setRoutingDeleteIndex(undefined), diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index ec214d7..ac4aec4 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -1930,7 +1930,7 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null} + {providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null} @@ -1950,7 +1950,7 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null} + {providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null}
{meter ? formatProviderAccountMeterValue(meter) : account.status}
@@ -1995,7 +1995,6 @@ function ProviderAccountSinglePanel({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null}
@@ -2039,7 +2038,6 @@ function ProviderAccountSummaryCard({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null}
diff --git a/src/renderer/pages/home/components/layout.tsx b/src/renderer/pages/home/components/layout.tsx index bbc3fea..2dfca05 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 { AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, - GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, + AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, - reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId, + reducedMotionTransition, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared"; import { ApiKeysView } from "./api-keys"; @@ -63,7 +63,7 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, - onCheckUpdate, + onDownloadUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, @@ -71,6 +71,7 @@ export function MainLayout({ sidebarOpen, toggleGatewayService, updateActionBusy, + updateStatus, viewProps, requestLogsEnabled, visibleNavigation @@ -85,7 +86,7 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; - onCheckUpdate: () => void; + onDownloadUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; @@ -93,13 +94,20 @@ export function MainLayout({ sidebarOpen: boolean; toggleGatewayService: () => void; updateActionBusy: boolean; + updateStatus: AppUpdateStatus; viewProps: MainViewProps; requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { const windowControlSafeAreaWidth = isMac ? 152 : 88; - const checkForUpdatesLabel = copy.text["Check for updates"] ?? "Check for updates"; - const checkingForUpdatesLabel = copy.text["Checking for updates"] ?? "Checking for updates"; + const showUpdateDownloadButton = + updateStatus.supported && + (updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded"); + const updateDownloadLabel = updateStatus.state === "downloaded" + ? copy.text["Update ready to install"] ?? "Update ready to install" + : updateStatus.state === "downloading" + ? copy.text["Downloading update"] ?? "Downloading update" + : copy.text["Download update"] ?? "Download update"; return ( <> @@ -124,6 +132,19 @@ export function MainLayout({ onClick={toggleGatewayService} state={gatewayStatus.state} /> + {showUpdateDownloadButton ? ( + + ) : null}
-
; @@ -349,9 +351,9 @@ function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayPro {providerAccountSnapshotCredentialLabel(snapshot)}
) : null} - {!meter ? ( + {!meter && fallbackText ? (
- {snapshot.message ?? snapshot.errors?.[0]?.message ?? snapshot.source} + {t(fallbackText)}
) : null}
@@ -422,6 +424,7 @@ export function ProviderDeepLinkDialog({ modelsLoading = false, onClose, onSubmit, + presetsLoaded = true, request }: { busy: boolean; @@ -430,6 +433,7 @@ export function ProviderDeepLinkDialog({ modelsLoading?: boolean; onClose: () => void; onSubmit: () => Promise; + presetsLoaded?: boolean; request: ProviderDeepLinkRequest; }) { const t = useAppText(); @@ -437,6 +441,7 @@ export function ProviderDeepLinkDialog({ const manifest = request.manifest; const displayName = provider ? provider.name?.trim() || inferProviderNameFromBaseUrl(provider.baseUrl) : ""; const providerPreset = provider ? resolveProviderDeepLinkPreset(provider) : undefined; + const showExternalProviderWarnings = Boolean(provider && presetsLoaded && !providerPreset); const providerIconUrl = provider ? providerDeepLinkDisplayIcon(provider) : ""; const modelPreview = provider?.models.slice(0, 8) ?? []; const actionLoading = busy || Boolean(provider && (iconLoading || modelsLoading)); @@ -456,19 +461,23 @@ export function ProviderDeepLinkDialog({ {provider ? (
-
-
- - {t("External provider link")} + {showExternalProviderWarnings ? ( +
+
+ + {t("External provider link")} +
+
+ {t("This provider link came from an external website. Review details before importing.")} +
-
- {t("This provider link came from an external website. Review details before importing.")} + ) : null} + {showExternalProviderWarnings ? ( +
+ + {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")}
-
-
- - {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} -
+ ) : null}
@@ -763,6 +772,89 @@ function ProviderPresetIcon({ className, iconUrl: explicitIconUrl, preset }: { c ); } +function ProviderImportHeader({ + draft, + provider, + preset +}: { + draft: AddProviderDraft; + provider: ProviderDeepLinkPayload; + preset?: ProviderPreset; +}) { + const t = useAppText(); + const baseUrl = draft.baseUrl.trim() || provider.baseUrl; + const displayName = draft.name.trim() || provider.name?.trim() || inferProviderNameFromBaseUrl(baseUrl); + const iconUrl = draft.icon.trim() || providerDeepLinkDisplayIcon(provider); + const platformUrl = providerImportPlatformUrl(provider, baseUrl, preset); + + function openPlatform() { + if (!platformUrl) { + return; + } + if (window.ccr?.openExternal) { + void window.ccr.openExternal(platformUrl).catch(() => undefined); + return; + } + window.open(platformUrl, "_blank", "noopener,noreferrer"); + } + + return ( +
+ +
+
{displayName}
+
{baseUrl}
+
+ {platformUrl ? ( + + ) : null} +
+ ); +} + +function providerImportPlatformUrl(provider: ProviderDeepLinkPayload, baseUrl: string, preset: ProviderPreset | undefined): string | undefined { + return normalizedHttpUrl(provider.source) ?? normalizedHttpUrl(preset?.websiteUrl) ?? providerBaseOrigin(baseUrl || provider.baseUrl); +} + +function providerBaseOrigin(value: string): string | undefined { + const url = normalizedHttpUrl(value); + if (!url) { + return undefined; + } + + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +function normalizedHttpUrl(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const url = new URL(providerUrlWithDefaultScheme(trimmed)); + if (!["http:", "https:"].includes(url.protocol)) { + return undefined; + } + return url.toString(); + } catch { + return undefined; + } +} + function providerPresetOptionMatchesQuery( option: ProviderPresetComboboxOption, query: string @@ -828,7 +920,7 @@ function LocalAgentProviderImportPanel({ }; }, [mode, providerPlugins, providers]); - if (mode !== "add" || (!loading && candidates.length === 0 && !error)) { + if (mode !== "add" || (candidates.length === 0 && !error)) { return null; } @@ -984,6 +1076,7 @@ export function AddProviderForm({ error, connectivityLoading = false, connectivityProbe, + importProvider, mode, onCheck, onChange, @@ -997,6 +1090,7 @@ export function AddProviderForm({ connectivityProbe?: GatewayProviderProbeResult; draft: AddProviderDraft; error: string; + importProvider?: ProviderDeepLinkPayload; mode: "add" | "edit"; onCheck?: () => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; @@ -1014,6 +1108,7 @@ export function AddProviderForm({ const hasModelCatalog = Boolean(probe?.models.length); const selectedPreset = findProviderPreset(draft.presetId); const customEndpoint = draft.presetId === customProviderPresetId; + const importMode = Boolean(importProvider); const showBaseUrl = customEndpoint || mode === "edit"; const detectedProtocol = probe?.detectedProtocol ?? draft.protocol; const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl; @@ -1083,6 +1178,7 @@ export function AddProviderForm({ ...createDefaultProviderAccountDraft(), baseUrl: "", icon: "", + modelDisplayNames: undefined, modelSearch: "", presetId, providerPlugins: [], @@ -1097,6 +1193,7 @@ export function AddProviderForm({ ...createDefaultProviderAccountDraft(), baseUrl: "", icon: "", + modelDisplayNames: undefined, modelSearch: "", presetId, providerPlugins: [], @@ -1114,6 +1211,7 @@ export function AddProviderForm({ ...accountDraft, baseUrl: endpoint?.baseUrl ?? "", icon: "", + modelDisplayNames: preset?.defaultModelDisplayNames, modelSearch: "", modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "", name: mode === "add" && preset && generatedName ? uniqueProviderName(providers, t(preset.name)) : draft.name, @@ -1128,19 +1226,25 @@ export function AddProviderForm({ return ( <>
- - - - + {importProvider ? ( + + ) : ( + <> + + + + + + )} onChange({ name: event.target.value })} /> @@ -1172,7 +1276,7 @@ export function AddProviderForm({ {safetyIssue.message}
) : null} - {selectedPreset && !showBaseUrl ? ( + {selectedPreset && !showBaseUrl && !importMode ? (
@@ -1184,6 +1288,7 @@ export function AddProviderForm({ {hasModelCatalog && probe ? (
onChange({ modelSearch })} onSelectedChange={(selectedModels) => onChange({ selectedModels })} @@ -1197,6 +1302,7 @@ export function AddProviderForm({
onChange({ modelsText: models.join("\n") })} placeholder={t("Model name")} value={splitLines(draft.modelsText)} @@ -1206,6 +1312,7 @@ export function AddProviderForm({ ) : ( onChange({ modelsText: models.join("\n") }, true)} placeholder={t("Model name")} value={splitLines(draft.modelsText)} @@ -1869,6 +1976,7 @@ export function AddProviderDialog({ connectivityProbe, draft, error, + importProvider, mode, onCheck, onChange, @@ -1877,13 +1985,16 @@ export function AddProviderDialog({ probe, probeLoading, providerPlugins = [], - providers + providers, + submitLabel, + title }: { canSubmit: boolean; connectivityLoading?: boolean; connectivityProbe?: GatewayProviderProbeResult; draft: AddProviderDraft; error: string; + importProvider?: ProviderDeepLinkPayload; mode: "add" | "edit"; onCheck?: (models: string[]) => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; @@ -1893,15 +2004,18 @@ export function AddProviderDialog({ probeLoading: boolean; providerPlugins?: unknown[]; providers: GatewayProviderConfig[]; + submitLabel?: string; + title?: string; }) { const t = useAppText(); const [checkConfirmOpen, setCheckConfirmOpen] = useState(false); const [checkConfirmBusy, setCheckConfirmBusy] = useState(false); const [iconDetecting, setIconDetecting] = useState(false); + const [submitting, setSubmitting] = useState(false); const [checkModelSelection, setCheckModelSelection] = useState([]); const [checkResult, setCheckResult] = useState(); const checkModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)); - const submitLoading = probeLoading || connectivityLoading || iconDetecting; + const submitLoading = probeLoading || connectivityLoading || iconDetecting || submitting; const submitDisabled = !canSubmit || submitLoading; function openCheckConfirm() { @@ -1931,22 +2045,31 @@ export function AddProviderDialog({ setCheckResult(undefined); } - function submit() { + async function submit() { if (submitDisabled) { return; } - void onSubmit(); + setSubmitting(true); + try { + const saved = await onSubmit(); + if (!saved) { + setSubmitting(false); + } + } catch (error) { + setSubmitting(false); + throw error; + } } return ( <> - !open && onClose()}> + !open && !submitting && onClose()}>
- {mode === "edit" ? t("Edit Provider") : t("Add Provider")} + {title ?? (mode === "edit" ? t("Edit Provider") : t("Add Provider"))}
-
@@ -1957,6 +2080,7 @@ export function AddProviderDialog({ connectivityProbe={connectivityProbe} draft={draft} error={error} + importProvider={importProvider} mode={mode} onCheck={onCheck ? async () => openCheckConfirm() : undefined} onChange={onChange} @@ -1969,14 +2093,14 @@ export function AddProviderDialog({ - -
@@ -2153,11 +2277,13 @@ function ProviderConnectivityResultGroup({ function ModelTagInput({ ariaLabel, + displayNames, onChange, placeholder, value }: { ariaLabel: string; + displayNames?: Record; onChange: (value: string[]) => void; placeholder: string; value: string[]; @@ -2197,7 +2323,9 @@ function ModelTagInput({
{models.map((model) => ( - {model} + + {displayNames?.[model] ?? model} + ); diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index 24e18d3..153e912 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -12,7 +12,7 @@ import { providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, - providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, + providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo, useRef, useState, X, isPlainRecord @@ -216,16 +216,17 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
{provider.models.map((model) => { const modelKey = `${itemKey}:${model}`; + const displayName = providerModelDisplayName(provider, model); return ( ); })} @@ -308,8 +309,8 @@ export function ModelsView({ config }: { config: AppConfig }) { key={row.key} >
-
- {row.model} +
+ {row.displayName ?? row.model}
@@ -521,7 +522,9 @@ export function ProviderDeepLinkDialog({
{modelPreview.map((model) => ( - {model} + + {provider.modelDisplayNames?.[model] ?? model} + ))} {provider.models.length > modelPreview.length ? ( @@ -943,6 +946,7 @@ function LocalAgentProviderImportPanel({ baseUrl: result.provider.baseUrl, credentials: [], icon: result.provider.icon ?? "", + modelDisplayNames: result.provider.modelDisplayNames, modelSearch: "", modelsText: result.provider.models.join("\n"), name: result.provider.name?.trim() || inferProviderNameFromBaseUrl(result.provider.baseUrl), @@ -2321,22 +2325,25 @@ function ModelTagInput({ /> {models.length > 0 ? (
- {models.map((model) => ( - - - {displayNames?.[model] ?? model} - - - - ))} + {models.map((model) => { + const displayName = displayNames?.[model] ?? model; + return ( + + + {displayName} + + + + ); + })}
) : null} @@ -2401,7 +2408,7 @@ function ModelMultiSelect({ checked && "border-primary bg-accent" )} key={model} - title={displayName ? `${displayName} (${model})` : model} + title={displayName ?? model} > toggleModel(model)} /> {displayName ?? model} diff --git a/src/renderer/pages/home/shared/profiles.ts b/src/renderer/pages/home/shared/profiles.ts index cc1055f..297a693 100644 --- a/src/renderer/pages/home/shared/profiles.ts +++ b/src/renderer/pages/home/shared/profiles.ts @@ -415,6 +415,7 @@ export function normalizeProfileClientModel(value: string | undefined): string { } export type ProfileModelProviderOption = { + modelDisplayNames?: Record; models: string[]; name: string; }; @@ -433,6 +434,7 @@ export function profileModelProviderOptions( const providerOptions = providers .filter((provider) => provider.name?.trim() && Array.isArray(provider.models)) .map((provider) => ({ + modelDisplayNames: profileModelDisplayNamesForModels(provider.modelDisplayNames, provider.models), models: uniqueStrings(provider.models.filter(Boolean)), name: provider.name.trim() })) @@ -485,10 +487,11 @@ export function profileModelDisplayValue( } const normalized = normalizeProfileClientModel(value); if (parsedValue.provider && parsedValue.model) { - return `${parsedValue.provider}/${parsedValue.model}`; + const provider = profileModelProviderOptions(providers, virtualModelProfiles).find((item) => item.name === parsedValue.provider); + return `${parsedValue.provider}/${profileModelOptionDisplayName(provider, parsedValue.model)}`; } const provider = profileModelProviderOptions(providers, virtualModelProfiles).find((item) => item.models.includes(normalized)); - return provider ? `${provider.name}/${normalized}` : normalized; + return provider ? `${provider.name}/${profileModelOptionDisplayName(provider, normalized)}` : normalized; } export function profileModelProviderMatchesQuery(provider: ProfileModelProviderOption, query: string): boolean { @@ -498,16 +501,36 @@ export function profileModelProviderMatchesQuery(provider: ProfileModelProviderO } return ( provider.name.toLowerCase().includes(normalizedQuery) || - provider.models.some((model) => model.toLowerCase().includes(normalizedQuery)) + provider.models.some((model) => + model.toLowerCase().includes(normalizedQuery) || + profileModelOptionDisplayName(provider, model).toLowerCase().includes(normalizedQuery) + ) ); } -export function profileModelMatchesQuery(providerName: string, model: string, query: string): boolean { +export function profileModelMatchesQuery(providerName: string, model: string, query: string, displayName?: string): boolean { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return true; } - return providerName.toLowerCase().includes(normalizedQuery) || model.toLowerCase().includes(normalizedQuery); + return providerName.toLowerCase().includes(normalizedQuery) || + model.toLowerCase().includes(normalizedQuery) || + (displayName ?? "").toLowerCase().includes(normalizedQuery); +} + +export function profileModelOptionDisplayName(provider: ProfileModelProviderOption | undefined, model: string): string { + return provider?.modelDisplayNames?.[model]?.trim() || model; +} + +function profileModelDisplayNamesForModels( + value: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const entries = Object.entries(value ?? {}) + .map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const) + .filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; } export type BotGatewayAuthInputType = "text" | "password"; diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index 97bbb77..b58d908 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -390,12 +390,23 @@ export const localAgentProviderIconUrls: Record }; export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] { - const providerModels = config.Providers.flatMap((provider) => mergeProviderModelLists(provider.models)); + const providerModels: string[] = []; + const displayNames: Record = {}; + for (const provider of config.Providers) { + for (const model of mergeProviderModelLists(provider.models)) { + providerModels.push(model); + const displayName = provider.modelDisplayNames?.[model]?.trim(); + if (displayName && !displayNames[model]) { + displayNames[model] = displayName; + } + } + } const virtualModels = (config.virtualModelProfiles ?? []) .filter(virtualModelIsCatalogVisible) .flatMap(virtualModelCatalogNames); return uniqueStrings([...providerModels, ...virtualModels]).map((model, index) => ({ + ...(displayNames[model] ? { displayName: displayNames[model] } : {}), key: `model:${index}:${model}`, model })); @@ -451,7 +462,7 @@ export function modelCatalogItemMatchesQuery(row: ModelCatalogItem, query: strin return true; } - return row.model.toLowerCase().includes(query); + return row.model.toLowerCase().includes(query) || (row.displayName ?? "").toLowerCase().includes(query); } export function createRouteModelOptions(providers: GatewayProviderConfig[]): Array<{ label: string; value: string }> { @@ -462,7 +473,7 @@ export function createRouteModelOptions(providers: GatewayProviderConfig[]): Arr return provider.models .filter(Boolean) .map((model) => ({ - label: `${provider.name}, ${model}`, + label: `${provider.name}, ${providerModelDisplayName(provider, model)}`, value: `${provider.name},${model}` })); }); @@ -867,7 +878,10 @@ export function createProviderDraftFromDeepLinkPayload( baseUrl, credentials: [], icon: payload.icon?.trim() || "", - modelDisplayNames: providerPresetModelDisplayNames(preset), + modelDisplayNames: modelDisplayNamesForModels( + mergeModelDisplayNames(providerPresetModelDisplayNames(preset), payload.modelDisplayNames), + models + ), modelSearch: "", modelsText: models.join("\n"), name: uniqueProviderName(providers, baseName), @@ -926,6 +940,7 @@ export function createProviderConfigFromDeepLink( api_key: apiKey, capabilities: capabilities.length > 0 ? capabilities : undefined, icon: payload.icon?.trim() || undefined, + modelDisplayNames: modelDisplayNamesForModels(mergeModelDisplayNames(payload.modelDisplayNames, probe?.modelDisplayNames), models), models, name, type: protocol @@ -973,7 +988,10 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig) baseUrl, credentials: (provider.credentials ?? []).map(providerCredentialDraftFromConfig), icon: provider.icon ?? "", - modelDisplayNames: providerPresetModelDisplayNames(preset), + modelDisplayNames: modelDisplayNamesForModels( + mergeModelDisplayNames(providerPresetModelDisplayNames(preset), provider.modelDisplayNames), + provider.models + ), modelSearch: "", modelsText: provider.models.join("\n"), name: provider.name, @@ -1519,10 +1537,12 @@ export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, prob return accountKeySafetyIssue.message; } + const modelDisplayNames = modelDisplayNamesForModels(draft.modelDisplayNames, models); const payload: ProviderDeepLinkPayload = { ...(account ? { account } : {}), baseUrl, ...(draft.icon.trim() ? { icon: draft.icon.trim() } : {}), + ...(modelDisplayNames ? { modelDisplayNames } : {}), models, name: providerName, protocol @@ -1870,6 +1890,25 @@ export function mergeModelDisplayNames( return Object.keys(merged).length > 0 ? merged : undefined; } +export function modelDisplayNamesForModels( + value: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const entries = Object.entries(value ?? {}) + .map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const) + .filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +export function providerModelDisplayName(provider: GatewayProviderConfig, model: string): string { + return provider.modelDisplayNames?.[model]?.trim() || model; +} + +export function providerModelDisplayTitle(provider: GatewayProviderConfig, model: string): string { + return providerModelDisplayName(provider, model); +} + export function pickRecommendedProviderModels(models: string[], protocol?: GatewayProviderProtocol): string[] { const candidates = mergeProviderModelLists(models); if (candidates.length === 0) { @@ -1965,7 +2004,8 @@ export function providerMatchesQuery(provider: GatewayProviderConfig, query: str providerBaseUrl(provider), providerCapabilitiesSummary(provider), ...(provider.capabilities ?? []).map((capability) => capability.baseUrl), - ...provider.models + ...provider.models, + ...provider.models.map((model) => providerModelDisplayName(provider, model)) ] .filter(Boolean) .some((value) => value.toLowerCase().includes(query)); diff --git a/src/renderer/pages/home/shared/types.ts b/src/renderer/pages/home/shared/types.ts index e49271a..8e1d68e 100644 --- a/src/renderer/pages/home/shared/types.ts +++ b/src/renderer/pages/home/shared/types.ts @@ -694,6 +694,7 @@ export type ExtensionListItem = { }; export type ModelCatalogItem = { + displayName?: string; key: string; model: string; }; diff --git a/src/shared/app.ts b/src/shared/app.ts index 632823d..5d2ea3a 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -78,6 +78,7 @@ export type GatewayProviderConfig = { extraHeaders?: unknown; icon?: string; id?: string; + modelDisplayNames?: Record; models: string[]; name: string; provider?: string; @@ -221,6 +222,7 @@ export type ProviderDeepLinkPayload = { apiKey?: string; baseUrl: string; icon?: string; + modelDisplayNames?: Record; models: string[]; name?: string; protocol?: GatewayProviderProtocol; @@ -250,6 +252,7 @@ export type LocalAgentProviderCandidate = { id: string; importable: boolean; kind: LocalAgentProviderKind; + modelDisplayNames?: Record; models: string[]; name: string; protocol: GatewayProviderProtocol; diff --git a/src/shared/deep-link.ts b/src/shared/deep-link.ts index df2cbef..36fba47 100644 --- a/src/shared/deep-link.ts +++ b/src/shared/deep-link.ts @@ -147,6 +147,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); + const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models); const account = readDeepLinkAccount(params, payload); const source = boundedString( firstStringParam(params, ["source"]) ?? @@ -159,6 +160,7 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa ...(apiKey ? { apiKey } : {}), baseUrl, ...(icon ? { icon } : {}), + ...(modelDisplayNames ? { modelDisplayNames } : {}), models, ...(name ? { name } : {}), ...(protocol ? { protocol } : {}), @@ -216,6 +218,7 @@ function parseProviderPayloadFields( firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); + const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models); const account = readDeepLinkAccount(params, payload); const source = boundedString( firstStringParam(params, ["source"]) ?? @@ -230,6 +233,7 @@ function parseProviderPayloadFields( ...(apiKey ? { apiKey } : {}), baseUrl, ...(icon ? { icon } : {}), + ...(modelDisplayNames ? { modelDisplayNames } : {}), models, ...(name ? { name } : {}), ...(protocol ? { protocol } : {}), @@ -515,11 +519,61 @@ function payloadModels(payload: Record | undefined): string[] { } const value = payload.models; if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === "string"); + return value + .map((item) => typeof item === "string" ? item : readPayloadModelId(item)) + .filter((item): item is string => Boolean(item)); } return typeof value === "string" ? [value] : []; } +function readDeepLinkModelDisplayNames( + params: URLSearchParams, + payload: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const displayNames: Record = {}; + const addDisplayName = (rawModel: unknown, rawDisplayName: unknown) => { + const model = typeof rawModel === "string" ? rawModel.trim() : ""; + const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : ""; + if (!model || !displayName || model === displayName || !modelIds.has(model)) { + return; + } + if (displayName.length > maxModelLength) { + throw new Error("Model display name is too long."); + } + displayNames[model] = displayName; + }; + + const explicit = parseJsonValueParam(params, payload, ["modelDisplayNames", "model_display_names"]); + if (isRecord(explicit)) { + for (const [model, displayName] of Object.entries(explicit)) { + addDisplayName(model, displayName); + } + } + + const payloadModelList = Array.isArray(payload?.models) ? payload.models : []; + for (const item of payloadModelList) { + if (!isRecord(item)) { + continue; + } + addDisplayName(readPayloadModelId(item), readPayloadModelDisplayName(item)); + } + + return Object.keys(displayNames).length > 0 ? displayNames : undefined; +} + +function readPayloadModelId(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return firstPayloadString(value, ["id", "slug", "model", "name"]); +} + +function readPayloadModelDisplayName(value: Record): string | undefined { + return firstPayloadString(value, ["display_name", "displayName", "label", "name"]); +} + function splitModelValue(value: string): string[] { return value .split(/[\n,]+/) From 589f29931991a87f288a0756c84facafdcbaf233 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 29 Jun 2026 22:49:39 +0800 Subject: [PATCH 007/101] Refine provider docs and gateway API key handling --- .../en/configuration/provider-deeplink.md | 12 +- .../zh/configuration/provider-deeplink.md | 12 +- docs/src/layouts/DocsLayout.astro | 4 +- docs/src/styles/global.css | 6 + src/main/config.ts | 41 +++-- src/main/plugins/service.ts | 45 ++++- src/main/presets/anthropic/index.ts | 3 +- src/main/presets/bailian/index.ts | 3 +- src/main/presets/deepseek/index.ts | 3 +- src/main/presets/gemini/index.ts | 3 +- src/main/presets/index.ts | 5 +- src/main/presets/kimi-coding/index.ts | 8 +- src/main/presets/mistral/index.ts | 3 +- src/main/presets/moonshot/index.ts | 72 ++++++-- src/main/presets/openai/index.ts | 3 +- src/main/presets/openrouter/index.ts | 3 +- src/main/presets/siliconflow/index.ts | 3 +- src/main/presets/zai-global-coding/index.ts | 3 +- src/main/presets/zai-global-general/index.ts | 3 +- src/main/presets/zhipu-cn-coding/index.ts | 3 +- src/main/presets/zhipu-cn-general/index.ts | 3 +- src/main/provider-model-catalog.ts | 1 + src/main/web-client-bridge.ts | 47 +++++- src/main/web-management-server.ts | 155 +++++++++++++++++- src/renderer/pages/home/App.tsx | 26 ++- .../pages/home/components/dashboard.tsx | 123 ++++++++++++-- .../pages/home/components/providers.tsx | 151 +++++++++++++++-- src/renderer/pages/home/shared/i18n.tsx | 10 +- src/renderer/pages/home/shared/options.ts | 1 + src/renderer/pages/home/shared/providers.ts | 5 +- .../gateway/claude-code-router-plugin.ts | 64 +++++++- src/server/gateway/service.ts | 5 +- src/server/proxy/certificates.ts | 30 +++- src/shared/provider-presets.ts | 1 + 34 files changed, 750 insertions(+), 110 deletions(-) diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index f4a3818..b1b9a43 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -2,12 +2,12 @@ title: One click import pageTitle: One click import eyebrow: Detailed Configuration -lead: Use ccr://provider links to hand provider configuration to CCR, or open the import confirmation page with preset provider buttons. +lead: Quickly add common model providers, review the details, and save them without filling everything in by hand. --- ## One-Click Import -The buttons below open the CCR desktop app's provider import confirmation dialog. Preset buttons do not include API keys; custom provider links may include one. Always confirm the provider name, Base URL, protocol, and models before importing a key. +Choose a provider below to get started. CCR shows what will be added before saving it; when using a custom entry point, make sure the source is one you trust.
@@ -50,9 +50,13 @@ The buttons below open the CCR desktop app's provider import confirmation dialog MistralChat Completions - + - Moonshot KimiChat Completions + Kimi API (China)China platform + + + + Kimi API (Global)Global platform diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index d51e045..bdbb48f 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -2,12 +2,12 @@ title: 一键导入供应商 pageTitle: 一键导入供应商 eyebrow: 详细配置 -lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 preset 供应商按钮快速打开导入确认页。 +lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤。 --- ## 一键导入 -下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。 +选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信。
@@ -50,9 +50,13 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 Mistral 官方Chat Completions - + - 月之暗面 KimiChat Completions + Kimi API(国内)国内平台 + + + + Kimi API(海外)海外平台 diff --git a/docs/src/layouts/DocsLayout.astro b/docs/src/layouts/DocsLayout.astro index ea5dde8..c803dc3 100644 --- a/docs/src/layouts/DocsLayout.astro +++ b/docs/src/layouts/DocsLayout.astro @@ -80,7 +80,7 @@ const resolvedNavItems = navItems.map((item, index) => { }; }); const homeHref = withBase(locale === "en" ? "/en/" : "/"); -const faviconHref = withBase("/favicon.svg"); +const faviconHref = withBase("/ccr-icon.png"); const logoSrc = withBase("/logo.png"); const sidebarIcons = { rocket: Rocket, @@ -177,7 +177,7 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation"; } })(); - + { resolvedLanguageOptions.map((option) => ( { const gatewayConfig = picked.gateway ?? {}; const corePort = gatewayConfig.corePort ?? nextPort(port); const configFileApiKeys = normalizeApiKeys(picked.APIKEYS, picked.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); - const persistedApiKeys = await loadPersistedApiKeys(); - const apiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const persistedApiKeys = (await loadPersistedApiKeys()).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const loadedApiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const apiKeys = ensureGatewayApiKeys(loadedApiKeys); const config: AppConfig = withSingleEnabledGlobalProfiles({ ...DEFAULT_CONFIG, ...picked, @@ -413,11 +415,11 @@ export async function loadAppConfig(): Promise { }, routerEndpoint: endpoint }); - const shouldMigrateApiKeys = hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; - if (shouldMigrateApiKeys) { + const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; + if (shouldPersistApiKeys) { await replacePersistedApiKeys(apiKeys); } - if (loadedRawConfig.source !== "sqlite" || shouldMigrateApiKeys) { + if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys) { await writeSanitizedConfig(config); } return config; @@ -427,10 +429,16 @@ export async function loadAppConfig(): Promise { console.warn(`[config] Failed to load API keys: ${formatError(storeError)}`); return [] as ApiKeyConfig[]; }); + const apiKeys = ensureGatewayApiKeys(persistedApiKeys.filter((apiKey) => !isDefaultSeedApiKey(apiKey))); + if (persistedApiKeys.length === 0) { + await replacePersistedApiKeys(apiKeys).catch((storeError) => { + console.warn(`[config] Failed to persist generated API key: ${formatError(storeError)}`); + }); + } return { ...DEFAULT_CONFIG, - APIKEY: persistedApiKeys[0]?.key ?? "", - APIKEYS: persistedApiKeys + APIKEY: apiKeys[0]?.key ?? "", + APIKEYS: apiKeys }; } } @@ -438,7 +446,7 @@ export async function loadAppConfig(): Promise { export async function saveAppConfig(config: AppConfig): Promise { const normalizedConfig = withSingleEnabledGlobalProfiles(config); assertProviderApiKeysAreSafe(normalizedConfig); - const apiKeys = normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(apiKeys); await writeSanitizedConfig({ ...normalizedConfig, @@ -564,7 +572,7 @@ function providerCredentialApiKey(credential: ProviderCredentialConfig): string } export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise { - const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const normalized = ensureGatewayApiKeys(normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(normalized); return loadAppConfig(); } @@ -2516,6 +2524,19 @@ function normalizeApiKeys(value: ApiKeyConfig[] | undefined, legacyKey: string | return uniqueApiKeyConfigs([...(value ?? []), ...(legacyKey ? [createApiKeyConfig(legacyKey, value?.length ?? 0)] : [])]); } +function ensureGatewayApiKeys(apiKeys: ApiKeyConfig[]): ApiKeyConfig[] { + return apiKeys.length ? apiKeys : [createGeneratedGatewayApiKey()]; +} + +function createGeneratedGatewayApiKey(): ApiKeyConfig { + return { + createdAt: new Date().toISOString(), + id: GENERATED_GATEWAY_API_KEY_ID, + key: `sk-ccr-${randomBytes(32).toString("base64url")}`, + name: "Local Gateway" + }; +} + function createApiKeyConfig(key: string, index: number): ApiKeyConfig { return { createdAt: new Date(0).toISOString(), diff --git a/src/main/plugins/service.ts b/src/main/plugins/service.ts index 16da667..83dfcdc 100644 --- a/src/main/plugins/service.ts +++ b/src/main/plugins/service.ts @@ -523,14 +523,49 @@ async function loadPluginModule(modulePath: string): Promise { } function resolvePluginModule(modulePath: string): string { - const expanded = expandHome(modulePath); + const resolved = requireFromHere.resolve(resolveLocalModulePath(modulePath, "Plugin module")); + assertJavaScriptModulePath(resolved, "Plugin module"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); if (path.isAbsolute(expanded)) { - return requireFromHere.resolve(expanded); + return expanded; } - if (expanded.startsWith(".")) { - return requireFromHere.resolve(path.resolve(CONFIGDIR, expanded)); + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); } - return requireFromHere.resolve(expanded, { paths: [CONFIGDIR, process.cwd()] }); + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); } function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin { diff --git a/src/main/presets/anthropic/index.ts b/src/main/presets/anthropic/index.ts index 1408c31..7cdc93b 100644 --- a/src/main/presets/anthropic/index.ts +++ b/src/main/presets/anthropic/index.ts @@ -14,5 +14,6 @@ export const anthropicProviderPreset: ProviderPreset = { name: "Anthropic", officialApiKeyPatterns: [ { flags: "i", source: "^sk-ant-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://www.anthropic.com/" }; diff --git a/src/main/presets/bailian/index.ts b/src/main/presets/bailian/index.ts index f9c596e..f52da99 100644 --- a/src/main/presets/bailian/index.ts +++ b/src/main/presets/bailian/index.ts @@ -10,5 +10,6 @@ export const bailianProviderPreset: ProviderPreset = { } ], id: "bailian", - name: "Alibaba Bailian" + name: "Alibaba Bailian", + websiteUrl: "https://bailian.console.aliyun.com/" }; diff --git a/src/main/presets/deepseek/index.ts b/src/main/presets/deepseek/index.ts index dfbc177..ee87dc6 100644 --- a/src/main/presets/deepseek/index.ts +++ b/src/main/presets/deepseek/index.ts @@ -47,5 +47,6 @@ export const deepSeekProviderPreset: ProviderPreset = { } ], id: "deepseek", - name: "DeepSeek" + name: "DeepSeek", + websiteUrl: "https://www.deepseek.com/" }; diff --git a/src/main/presets/gemini/index.ts b/src/main/presets/gemini/index.ts index 808a043..432d4ff 100644 --- a/src/main/presets/gemini/index.ts +++ b/src/main/presets/gemini/index.ts @@ -13,5 +13,6 @@ export const geminiProviderPreset: ProviderPreset = { name: "Google Gemini", officialApiKeyPatterns: [ { flags: "i", source: "^AIza[a-z0-9_-]{20,}$" } - ] + ], + websiteUrl: "https://gemini.google.com/" }; diff --git a/src/main/presets/index.ts b/src/main/presets/index.ts index 2c48c59..62cec8e 100644 --- a/src/main/presets/index.ts +++ b/src/main/presets/index.ts @@ -4,7 +4,7 @@ import { deepSeekProviderPreset } from "./deepseek"; import { geminiProviderPreset } from "./gemini"; import { kimiCodingProviderPreset } from "./kimi-coding"; import { mistralProviderPreset } from "./mistral"; -import { moonshotProviderPreset } from "./moonshot"; +import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "./moonshot"; import { openaiProviderPreset } from "./openai"; import { openRouterProviderPreset } from "./openrouter"; import { siliconFlowProviderPreset } from "./siliconflow"; @@ -35,7 +35,8 @@ export const providerPresets: ProviderPreset[] = [ zaiGlobalCodingProviderPreset, zaiGlobalGeneralProviderPreset, mistralProviderPreset, - moonshotProviderPreset, + moonshotChinaProviderPreset, + moonshotGlobalProviderPreset, bailianProviderPreset, siliconFlowProviderPreset ]; diff --git a/src/main/presets/kimi-coding/index.ts b/src/main/presets/kimi-coding/index.ts index 9ff6dd8..6848c28 100644 --- a/src/main/presets/kimi-coding/index.ts +++ b/src/main/presets/kimi-coding/index.ts @@ -26,14 +26,16 @@ export const kimiCodingProviderPreset: ProviderPreset = { endpoints: [ { baseUrl: "https://api.kimi.com/coding/v1", - protocols: ["openai_chat_completions"] + protocols: ["openai_chat_completions"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" }, { baseUrl: "https://api.kimi.com/coding/", - protocols: ["anthropic_messages"] + protocols: ["anthropic_messages"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" } ], id: "kimi-coding", name: "Kimi Code - Coding Plan", - websiteUrl: "https://www.kimi.com/code/docs/" + websiteUrl: "https://www.kimi.com/code?aff=ccr" }; diff --git a/src/main/presets/mistral/index.ts b/src/main/presets/mistral/index.ts index 10ec1fa..ff351f4 100644 --- a/src/main/presets/mistral/index.ts +++ b/src/main/presets/mistral/index.ts @@ -41,5 +41,6 @@ export const mistralProviderPreset: ProviderPreset = { } ], id: "mistral", - name: "Mistral" + name: "Mistral", + websiteUrl: "https://mistral.ai/" }; diff --git a/src/main/presets/moonshot/index.ts b/src/main/presets/moonshot/index.ts index c7c9125..dd2c72f 100644 --- a/src/main/presets/moonshot/index.ts +++ b/src/main/presets/moonshot/index.ts @@ -1,7 +1,7 @@ import type { ProviderAccountConfig } from "../../../shared/app"; import type { ProviderPreset } from "../../../shared/provider-presets"; -const moonshotProviderAccountConfig: ProviderAccountConfig = { +const moonshotGlobalProviderAccountConfig: ProviderAccountConfig = { connectors: [ { auth: "provider-api-key", @@ -37,20 +37,70 @@ const moonshotProviderAccountConfig: ProviderAccountConfig = { enabled: true }; -export const moonshotProviderPreset: ProviderPreset = { - account: moonshotProviderAccountConfig, - aliases: ["kimi", "moonshot"], +const moonshotChinaProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.moonshot.cn/v1/users/me/balance", + mapping: { + meters: [ + { + id: "balance", + kind: "balance", + label: "Balance", + remaining: "$.data.available_balance", + unit: "CNY" + }, + { + id: "voucher_balance", + kind: "balance", + label: "Voucher balance", + remaining: "$.data.voucher_balance", + unit: "CNY" + }, + { + id: "cash_balance", + kind: "balance", + label: "Cash balance", + remaining: "$.data.cash_balance", + unit: "CNY" + } + ] + }, + type: "http-json" + } + ], + enabled: true +}; + +export const moonshotChinaProviderPreset: ProviderPreset = { + account: moonshotChinaProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], + defaultModels: ["moonshot-v1-8k"], + endpoints: [ + { + baseUrl: "https://api.moonshot.cn/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.com/?aff=ccr" + } + ], + id: "moonshot", + name: "Kimi API (China)", + websiteUrl: "https://platform.kimi.com/?aff=ccr" +}; + +export const moonshotGlobalProviderPreset: ProviderPreset = { + account: moonshotGlobalProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], defaultModels: ["moonshot-v1-8k"], endpoints: [ { baseUrl: "https://api.moonshot.ai/v1", - protocols: ["openai_chat_completions"] - }, - { - baseUrl: "https://api.moonshot.cn/v1", - protocols: ["openai_chat_completions"] + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.ai/?aff=ccr" } ], - id: "moonshot", - name: "Moonshot Kimi" + id: "moonshot-global", + name: "Kimi API (Global)", + websiteUrl: "https://platform.kimi.ai/?aff=ccr" }; diff --git a/src/main/presets/openai/index.ts b/src/main/presets/openai/index.ts index cec8000..e2d5cf9 100644 --- a/src/main/presets/openai/index.ts +++ b/src/main/presets/openai/index.ts @@ -14,5 +14,6 @@ export const openaiProviderPreset: ProviderPreset = { name: "OpenAI", officialApiKeyPatterns: [ { flags: "i", source: "^sk-(?:proj|svcacct)-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openai.com/" }; diff --git a/src/main/presets/openrouter/index.ts b/src/main/presets/openrouter/index.ts index 1294dae..86d56da 100644 --- a/src/main/presets/openrouter/index.ts +++ b/src/main/presets/openrouter/index.ts @@ -51,5 +51,6 @@ export const openRouterProviderPreset: ProviderPreset = { name: "OpenRouter", officialApiKeyPatterns: [ { flags: "i", source: "^sk-or-v1-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openrouter.ai/" }; diff --git a/src/main/presets/siliconflow/index.ts b/src/main/presets/siliconflow/index.ts index 8436699..737c338 100644 --- a/src/main/presets/siliconflow/index.ts +++ b/src/main/presets/siliconflow/index.ts @@ -47,5 +47,6 @@ export const siliconFlowProviderPreset: ProviderPreset = { } ], id: "siliconflow", - name: "SiliconFlow" + name: "SiliconFlow", + websiteUrl: "https://siliconflow.cn/" }; diff --git a/src/main/presets/zai-global-coding/index.ts b/src/main/presets/zai-global-coding/index.ts index 8e0e41e..d757fcc 100644 --- a/src/main/presets/zai-global-coding/index.ts +++ b/src/main/presets/zai-global-coding/index.ts @@ -58,5 +58,6 @@ export const zaiGlobalCodingProviderPreset: ProviderPreset = { } ], id: "zai-global-coding", - name: "Z.ai (Global) - Coding Plan" + name: "Z.ai (Global) - Coding Plan", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zai-global-general/index.ts b/src/main/presets/zai-global-general/index.ts index b0d63c5..44612fd 100644 --- a/src/main/presets/zai-global-general/index.ts +++ b/src/main/presets/zai-global-general/index.ts @@ -54,5 +54,6 @@ export const zaiGlobalGeneralProviderPreset: ProviderPreset = { } ], id: "zai-global-general", - name: "Z.ai (Global) - General Endpoint" + name: "Z.ai (Global) - General Endpoint", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zhipu-cn-coding/index.ts b/src/main/presets/zhipu-cn-coding/index.ts index 17456d3..a28dda1 100644 --- a/src/main/presets/zhipu-cn-coding/index.ts +++ b/src/main/presets/zhipu-cn-coding/index.ts @@ -58,5 +58,6 @@ export const zhipuCnCodingProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-coding", - name: "Zhipu AI (China) - Coding Plan" + name: "Zhipu AI (China) - Coding Plan", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/presets/zhipu-cn-general/index.ts b/src/main/presets/zhipu-cn-general/index.ts index c178ba5..c0c0e52 100644 --- a/src/main/presets/zhipu-cn-general/index.ts +++ b/src/main/presets/zhipu-cn-general/index.ts @@ -54,5 +54,6 @@ export const zhipuCnGeneralProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-general", - name: "Zhipu AI (China) - General Endpoint" + name: "Zhipu AI (China) - General Endpoint", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index e9e465c..70e9b48 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -38,6 +38,7 @@ const presetCatalogProviderIds: Record = { "kimi-coding": ["kimi-for-coding"], mistral: ["mistral"], moonshot: ["moonshotai-cn"], + "moonshot-global": ["moonshotai"], openai: ["openai"], openrouter: ["openrouter"], siliconflow: ["siliconflow-cn"], diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts index f720d17..a9096f2 100644 --- a/src/main/web-client-bridge.ts +++ b/src/main/web-client-bridge.ts @@ -1,4 +1,8 @@ const rpcEndpoint = "/api/ccr/rpc"; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; +const webAuthStorageKey = "ccr.webAuthToken"; +const webAuthToken = readWebAuthToken(); type RpcResponse = | { ok: true; value: unknown } @@ -8,7 +12,8 @@ async function rpc(method: string, args: unknown[] = []): Promise { const response = await fetch(rpcEndpoint, { body: JSON.stringify({ args, method }), headers: { - "content-type": "application/json" + "content-type": "application/json", + ...(webAuthToken ? { [webAuthHeader]: webAuthToken } : {}) }, method: "POST" }); @@ -27,6 +32,46 @@ async function rpc(method: string, args: unknown[] = []): Promise { return payload.value; } +function readWebAuthToken(): string { + const tokenFromUrl = readWebAuthTokenFromUrl(); + if (tokenFromUrl) { + writeStoredWebAuthToken(tokenFromUrl); + return tokenFromUrl; + } + return readStoredWebAuthToken(); +} + +function readWebAuthTokenFromUrl(): string { + try { + const url = new URL(window.location.href); + const token = url.searchParams.get(webAuthQueryParam)?.trim() ?? ""; + if (!token) { + return ""; + } + url.searchParams.delete(webAuthQueryParam); + window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`); + return token; + } catch { + return ""; + } +} + +function readStoredWebAuthToken(): string { + try { + return window.sessionStorage.getItem(webAuthStorageKey)?.trim() ?? ""; + } catch { + return ""; + } +} + +function writeStoredWebAuthToken(token: string): void { + try { + window.sessionStorage.setItem(webAuthStorageKey, token); + } catch { + // The in-memory token still works for the current page load if storage is unavailable. + } +} + function noopSubscription(): () => void { return () => undefined; } diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts index c22697f..4af4773 100644 --- a/src/main/web-management-server.ts +++ b/src/main/web-management-server.ts @@ -1,6 +1,8 @@ import { spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -80,10 +82,19 @@ type RpcRequest = { type RpcHandler = (...args: unknown[]) => Promise | unknown; +type WebManagementSecurityContext = { + allowIpLiteralHosts: boolean; + allowedHostnames: Set; + authToken: string; + port: number; +}; + const defaultWebHost = "127.0.0.1"; const defaultWebPort = 3458; const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const maxRpcBodyBytes = 8 * 1024 * 1024; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; const staticRoot = path.resolve(__dirname, "..", "renderer"); const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html"); const rendererAssetsRoot = path.join(staticRoot, "assets"); @@ -111,8 +122,14 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [ export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise { const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost; const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort; + const authToken = randomBytes(32).toString("base64url"); + let security: WebManagementSecurityContext | undefined; const server = createServer((request, response) => { - void handleRequest(request, response).catch((error) => { + if (!security) { + sendJson(response, 503, { error: { message: "CCR web management server is not ready." }, ok: false }); + return; + } + void handleRequest(request, response, security).catch((error) => { sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); }); }); @@ -120,7 +137,9 @@ export async function startWebManagementServer(options: WebManagementServerOptio const listenedPort = await listenWithFallback(server, requestedPort, host); const address = server.address(); const port = typeof address === "object" && address ? address.port : listenedPort; - const url = `http://${formatListenHost(host)}:${port}/`; + const baseUrl = `http://${formatListenHost(host)}:${port}/`; + const url = urlWithWebAuthToken(baseUrl, authToken); + security = createWebManagementSecurityContext(host, port, authToken); if (options.startGateway !== false) { await startConfiguredServices("web startup"); @@ -141,10 +160,15 @@ export async function startWebManagementServer(options: WebManagementServerOptio }; } -async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { +async function handleRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { + if (!isAllowedWebRequestHost(request, security)) { + sendText(response, 403, "Forbidden host"); + return; + } + const url = requestUrl(request); if (url.pathname === "/api/ccr/rpc") { - await handleRpcRequest(request, response); + await handleRpcRequest(request, response, security); return; } if (request.method !== "GET" && request.method !== "HEAD") { @@ -162,11 +186,23 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) sendText(response, 404, "Not found"); } -async function handleRpcRequest(request: IncomingMessage, response: ServerResponse): Promise { +async function handleRpcRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { if (request.method !== "POST") { sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false }); return; } + if (!isJsonRequest(request)) { + sendJson(response, 415, { error: { message: "RPC requests must use application/json." }, ok: false }); + return; + } + if (!isAllowedWebRequestOrigin(request, security)) { + sendJson(response, 403, { error: { message: "Forbidden RPC origin." }, ok: false }); + return; + } + if (!hasValidWebAuthToken(request, security)) { + sendJson(response, 401, { error: { message: "CCR web authentication token is missing or invalid." }, ok: false }); + return; + } let payload: RpcRequest; try { @@ -498,7 +534,8 @@ function sendBuffer(response: ServerResponse, status: number, body: Buffer, cont response.writeHead(status, { "cache-control": "no-store", "content-length": body.length, - "content-type": contentType + "content-type": contentType, + "x-content-type-options": "nosniff" }); if (headOnly) { response.end(); @@ -512,7 +549,8 @@ function sendJson(response: ServerResponse, status: number, payload: unknown): v response.writeHead(status, { "cache-control": "no-store", "content-length": body.length, - "content-type": "application/json; charset=utf-8" + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff" }); response.end(body); } @@ -525,6 +563,109 @@ function requestUrl(request: IncomingMessage): URL { return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); } +function createWebManagementSecurityContext(host: string, port: number, authToken: string): WebManagementSecurityContext { + const normalizedHost = normalizeHostname(host); + const allowedHostnames = new Set(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]); + if (normalizedHost) { + allowedHostnames.add(normalizedHost); + } + return { + allowIpLiteralHosts: isWildcardBindHost(normalizedHost), + allowedHostnames, + authToken, + port + }; +} + +function urlWithWebAuthToken(value: string, authToken: string): string { + const url = new URL(value); + url.searchParams.set(webAuthQueryParam, authToken); + return url.toString(); +} + +function isAllowedWebRequestHost(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const hostname = requestHostname(request); + return Boolean(hostname && isAllowedWebHostname(hostname, security)); +} + +function isAllowedWebRequestOrigin(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const origin = readHeaderValue(request.headers.origin); + if (origin && !isAllowedWebOriginValue(origin, security)) { + return false; + } + + const referer = readHeaderValue(request.headers.referer); + if (!origin && referer && !isAllowedWebOriginValue(referer, security)) { + return false; + } + + return true; +} + +function isAllowedWebOriginValue(value: string, security: WebManagementSecurityContext): boolean { + try { + const url = new URL(value); + const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : url.protocol === "https:" ? 443 : undefined; + return url.protocol === "http:" && + port === security.port && + isAllowedWebHostname(normalizeHostname(url.hostname), security); + } catch { + return false; + } +} + +function isAllowedWebHostname(hostname: string, security: WebManagementSecurityContext): boolean { + const normalized = normalizeHostname(hostname); + return security.allowedHostnames.has(normalized) || + (security.allowIpLiteralHosts && Boolean(net.isIP(normalized))); +} + +function requestHostname(request: IncomingMessage): string | undefined { + const host = readHeaderValue(request.headers.host); + if (!host) { + return undefined; + } + try { + return normalizeHostname(new URL(`http://${host}`).hostname); + } catch { + return undefined; + } +} + +function isJsonRequest(request: IncomingMessage): boolean { + const contentType = readHeaderValue(request.headers["content-type"])?.toLowerCase() ?? ""; + return contentType.split(";")[0]?.trim() === "application/json"; +} + +function hasValidWebAuthToken(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const token = readHeaderValue(request.headers[webAuthHeader]); + return constantTimeEquals(token, security.authToken); +} + +function constantTimeEquals(value: string | undefined, expected: string): boolean { + if (!value) { + return false; + } + const valueBuffer = Buffer.from(value); + const expectedBuffer = Buffer.from(expected); + return valueBuffer.length === expectedBuffer.length && timingSafeEqual(valueBuffer, expectedBuffer); +} + +function readHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return readString(value[0]); + } + return readString(value); +} + +function normalizeHostname(value: string): string { + return value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); +} + +function isWildcardBindHost(host: string): boolean { + return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; +} + function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index d536974..df5de23 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -147,6 +147,13 @@ function providerNameSlug(value: string): string { .replace(/^-+|-+$/g, "") || "provider"; } +async function loadProviderAccountSnapshots(forceRefresh = false): Promise { + if (!window.ccr) { + return []; + } + return window.ccr.getProviderAccountSnapshots(undefined, forceRefresh ? { forceRefresh: true } : undefined); +} + function extensionActionIndexes(index: number, groupIndexes?: number[]): number[] { const indexes = groupIndexes?.length ? groupIndexes : [index]; return [...new Set(indexes.filter((item) => Number.isInteger(item) && item >= 0))]; @@ -252,6 +259,7 @@ function App() { const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); + const [providerAccountRefreshing, setProviderAccountRefreshing] = useState(false); const updateActionBusyRef = useRef(false); const resolvedLanguage = languagePreference === "system" ? systemLanguage : languagePreference; const copy = appCopy[resolvedLanguage]; @@ -443,7 +451,7 @@ function App() { let cancelled = false; const refreshProviderAccounts = () => { - void window.ccr?.getProviderAccountSnapshots() + void loadProviderAccountSnapshots() .then((snapshots) => { if (!cancelled) { setProviderAccountSnapshots(snapshots); @@ -463,6 +471,20 @@ function App() { }; }, [draftConfig.Providers]); + async function refreshProviderAccountsNow() { + if (providerAccountRefreshing) { + return; + } + setProviderAccountRefreshing(true); + try { + setProviderAccountSnapshots(await loadProviderAccountSnapshots(true)); + } catch { + setProviderAccountSnapshots([]); + } finally { + setProviderAccountRefreshing(false); + } + } + const requestLogsEnabled = Boolean(draftConfig.observability.requestLogs); const agentAnalysisEnabled = Boolean(draftConfig.observability.agentAnalysis); const agentAnalysisFilterKey = JSON.stringify({ @@ -2813,6 +2835,8 @@ function App() { onWidgetsChange: changeOverviewWidgets, overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets), providerAccounts: providerAccountSnapshots, + providerAccountRefreshing, + refreshProviderAccounts: () => void refreshProviderAccountsNow(), setUsageRange, usageRange, usageStats diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index ac4aec4..da62c80 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -8,11 +8,11 @@ import { DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, - LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone, + LabelList, LayoutGroup, Line, LoaderCircle, 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, + PointerSensor, primaryProviderAccountMeter, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, @@ -26,6 +26,8 @@ export function OverviewView({ onWidgetsChange, overviewWidgets, providerAccounts, + providerAccountRefreshing = false, + refreshProviderAccounts, setUsageRange, usageRange, usageStats @@ -33,6 +35,8 @@ export function OverviewView({ onWidgetsChange: (widgets: OverviewWidgetConfig[]) => void; overviewWidgets: OverviewWidgetConfig[]; providerAccounts: ProviderAccountSnapshot[]; + providerAccountRefreshing?: boolean; + refreshProviderAccounts?: () => void | Promise; setUsageRange: (range: UsageStatsRange) => void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; @@ -248,6 +252,8 @@ export function OverviewView({ > void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -567,6 +579,8 @@ function OverviewWidgetDragOverlay({
void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -816,7 +834,7 @@ function OverviewWidgetRenderer({ if (widget.type === "system-status") { content = ; } else if (widget.type === "account-balance") { - content = ; + content = ; } else if (widget.type === "metric") { content = ; } else if (widget.type === "usage-trend") { @@ -952,19 +970,19 @@ function UsageTrendWidget({ } dataKey="requestCount" /> - + ) : null} {variant === "area" ? ( <> - + ) : null} {variant === "line" ? ( <> - + ) : null} {variant === "bar" ? ( @@ -1185,7 +1203,7 @@ function TokenMixOverviewWidget({ const tokenMix = [ { color: "#2563eb", name: t("Input"), value: totals.inputTokens }, { color: "#d97706", name: t("Output"), value: totals.outputTokens }, - { color: "#be123c", name: t("Cache"), value: totals.cacheTokens } + { color: overviewCacheColor, name: t("Cache"), value: totals.cacheTokens } ]; const total = tokenMix.reduce((sum, item) => sum + item.value, 0); const showLegend = dimensions.height >= 2 && dimensions.width >= 2; @@ -1665,6 +1683,8 @@ function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string { type OverviewWidgetDimensions = { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 }; +const overviewCacheColor = "#6366f1"; + function overviewWidgetDimensions(size: OverviewWidgetSize): OverviewWidgetDimensions { const [widthText, heightText] = size.split(":"); const width = overviewWidgetDimensionValue(widthText); @@ -1752,7 +1772,7 @@ function overviewMetricDatum(metric: OverviewMetricKind, totals: UsageTotals, tr return { label: translate("Output tokens"), ratio: totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0, tone: "amber", value: formatCompactNumber(totals.outputTokens) }; } if (metric === "cache-tokens") { - return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "rose", value: formatCompactNumber(totals.cacheTokens) }; + return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "indigo", value: formatCompactNumber(totals.cacheTokens) }; } if (metric === "cache-ratio") { return { label: translate("Cache ratio"), ratio: totals.cacheRatio, tone: "indigo", value: formatPercent(totals.cacheRatio) }; @@ -1897,11 +1917,15 @@ function ProviderAccountsOverview({ accountProvider, accounts, dimensions, + onRefresh, + refreshing = false, variant = "cards" }: { accountProvider?: string; accounts: ProviderAccountSnapshot[]; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant?: OverviewAccountVariant; }) { const t = useAppText(); @@ -1921,7 +1945,7 @@ function ProviderAccountsOverview({ {t("No account balance connectors configured")}
) : isSingleAccount ? ( - + ) : variant === "compact" ? (
{visibleAccounts.map((account) => { @@ -1931,10 +1955,11 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null} + {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
-
- {providerAccountShowStatus(dimensions) ? {account.status} : null} - {meter ?
{formatProviderAccountMeterValue(meter)}
: null} +
+ {providerAccountShowRefresh(dimensions) ? : null} + {meter ?
{formatProviderAccountMeterValue(meter)}
: null}
); @@ -1951,8 +1976,12 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null} + {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null} +
+
+ {meter ? {formatProviderAccountMeterValue(meter)} : null} + {providerAccountShowRefresh(dimensions) ? : null}
-
{meter ? formatProviderAccountMeterValue(meter) : account.status}
{progress !== undefined ? (
@@ -1966,7 +1995,7 @@ function ProviderAccountsOverview({ ) : (
{visibleAccounts.map((account) => { - return ; + return ; })}
)} @@ -1978,10 +2007,14 @@ function ProviderAccountsOverview({ function ProviderAccountSinglePanel({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -1995,8 +2028,9 @@ function ProviderAccountSinglePanel({
{providerAccountSnapshotLabel(account)}
+ {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
- {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
{showQuotaVisual ? ( @@ -2021,10 +2055,14 @@ function ProviderAccountSinglePanel({ function ProviderAccountSummaryCard({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -2038,8 +2076,9 @@ function ProviderAccountSummaryCard({
{providerAccountSnapshotLabel(account)}
+ {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
- {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
{showQuotaVisual ? (
@@ -2065,6 +2104,52 @@ function ProviderAccountSummaryCard({ ); } +function ProviderAccountRefreshButton({ + account, + onRefresh, + refreshing = false +}: { + account: ProviderAccountSnapshot; + onRefresh?: () => void | Promise; + refreshing?: boolean; +}) { + const t = useAppText(); + const label = refreshing ? t("Refreshing account") : t("Refresh account"); + return ( + + ); +} + +function formatProviderAccountRefreshTime(account: ProviderAccountSnapshot, t: (value: string) => string): string { + return `${t("Last updated")}: ${formatProviderAccountUpdatedAt(account.updatedAt) || "-"}`; +} + +function formatProviderAccountUpdatedAt(value: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return ""; + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + function ProviderAccountMeterLine({ account, dimensions, @@ -2394,7 +2479,11 @@ function providerAccountShowSource(dimensions: OverviewWidgetDimensions): boolea return dimensions.height >= 2 && dimensions.width >= 2; } -function providerAccountShowStatus(dimensions: OverviewWidgetDimensions): boolean { +function providerAccountShowRefreshTime(dimensions: OverviewWidgetDimensions): boolean { + return dimensions.height >= 2 && dimensions.width >= 2; +} + +function providerAccountShowRefresh(dimensions: OverviewWidgetDimensions): boolean { return dimensions.width >= 2; } diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index 153e912..4cd8d89 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -629,6 +629,8 @@ function ProviderPresetCombobox({ const filteredOptions = normalizedQuery ? options.filter((option) => providerPresetOptionMatchesQuery(option, normalizedQuery)) : options; + const selectedExternalUrl = providerPresetOptionPlatformUrl(selected); + const selectedDetail = providerPresetOptionDetail(selected, t); useEffect(() => { if (!open) { @@ -662,29 +664,66 @@ function ProviderPresetCombobox({ setOpen(false); } + function toggleOpen() { + setOpen((current) => !current); + } + + function openSelectedExternalUrl() { + if (!selectedExternalUrl) { + return; + } + openExternalUrl(selectedExternalUrl); + } + return (
- + +
+
{selected ? selected.label : t("Select preset provider")}
+ {selectedDetail ? ( +
{selectedDetail}
+ ) : null} +
+ {selectedExternalUrl ? ( + + ) : null} +
{open ? ( @@ -794,11 +833,7 @@ function ProviderImportHeader({ if (!platformUrl) { return; } - if (window.ccr?.openExternal) { - void window.ccr.openExternal(platformUrl).catch(() => undefined); - return; - } - window.open(platformUrl, "_blank", "noopener,noreferrer"); + openExternalUrl(platformUrl); } return ( @@ -825,7 +860,15 @@ function ProviderImportHeader({ } function providerImportPlatformUrl(provider: ProviderDeepLinkPayload, baseUrl: string, preset: ProviderPreset | undefined): string | undefined { - return normalizedHttpUrl(provider.source) ?? normalizedHttpUrl(preset?.websiteUrl) ?? providerBaseOrigin(baseUrl || provider.baseUrl); + return normalizedHttpUrl(provider.source) ?? providerPresetWebsiteUrlForBaseUrl(preset, baseUrl || provider.baseUrl); +} + +function openExternalUrl(url: string) { + if (window.ccr?.openExternal) { + void window.ccr.openExternal(url).catch(() => undefined); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); } function providerBaseOrigin(value: string): string | undefined { @@ -874,6 +917,76 @@ function providerPresetOptionMatchesQuery( return haystack.includes(query); } +function providerPresetOptionDetail(option: ProviderPresetComboboxOption | undefined, t: (value: string) => string): string { + if (!option) { + return ""; + } + if (option.preset) { + return primaryProviderPresetEndpoint(option.preset)?.baseUrl ?? option.preset.websiteUrl ?? ""; + } + if (option.value === customProviderPresetId) { + return t("API endpoint"); + } + return ""; +} + +function providerPresetOptionPlatformUrl(option: ProviderPresetComboboxOption | undefined): string | undefined { + if (!option?.preset) { + return undefined; + } + return providerPresetWebsiteUrlForEndpoint(option.preset, primaryProviderPresetEndpoint(option.preset)); +} + +function providerPresetWebsiteUrlForBaseUrl(preset: ProviderPreset | undefined, baseUrl: string): string | undefined { + if (!preset) { + return undefined; + } + const normalizedBaseUrl = baseUrl.trim(); + const endpoint = normalizedBaseUrl + ? preset.endpoints.find((item) => item.baseUrl.trim() === normalizedBaseUrl) + : undefined; + return providerPresetWebsiteUrlForEndpoint(preset, endpoint); +} + +function providerPresetWebsiteUrlForEndpoint( + preset: ProviderPreset, + endpoint: ReturnType | undefined +): string | undefined { + return normalizedHttpUrl(endpoint?.websiteUrl) ?? normalizedHttpUrl(preset.websiteUrl); +} + +function providerDraftNameShouldFollowPreset( + name: string, + previousPreset: ProviderPreset | undefined, + t: (value: string) => string +): boolean { + const trimmed = name.trim(); + if (!trimmed || /^provider-\d+$/i.test(trimmed)) { + return true; + } + if (!previousPreset) { + return false; + } + return [previousPreset.name, t(previousPreset.name)].some((baseName) => + providerNameMatchesGeneratedPresetName(trimmed, baseName) + ); +} + +function providerNameMatchesGeneratedPresetName(name: string, baseName: string): boolean { + const normalizedName = name.trim().toLowerCase(); + const normalizedBaseName = baseName.trim().toLowerCase(); + if (!normalizedBaseName) { + return false; + } + if (normalizedName === normalizedBaseName) { + return true; + } + if (!normalizedName.startsWith(`${normalizedBaseName} `)) { + return false; + } + return /^\d+$/.test(normalizedName.slice(normalizedBaseName.length + 1)); +} + function LocalAgentProviderImportPanel({ mode, onChange, @@ -1209,7 +1322,8 @@ export function AddProviderForm({ const preset = findProviderPreset(presetId); const endpoint = preset ? primaryProviderPresetEndpoint(preset) : undefined; - const generatedName = !draft.name.trim() || /^provider-\d+$/i.test(draft.name.trim()); + const previousPreset = findProviderPreset(draft.presetId); + const generatedName = providerDraftNameShouldFollowPreset(draft.name, previousPreset, t); const accountDraft = createProviderAccountDraftFromConfig(defaultProviderAccountConfigForPreset(presetId)); onChange({ ...accountDraft, @@ -1240,13 +1354,14 @@ export function AddProviderForm({ providerPlugins={[...providerPlugins, ...draft.providerPlugins]} providers={providers} /> - +
+ {t("Select preset provider")} - +
)} diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index 596d195..bedf620 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -268,6 +268,9 @@ export const appCopy: Record = { "Account component": "Account component", "All accounts": "All accounts", "All credentials": "All credentials", + "Last updated": "Last updated", + "Refresh account": "Refresh account", + "Refreshing account": "Refreshing account", "Add widget": "Add widget", "Analysis component": "Analysis component", "Arc": "Arc", @@ -953,6 +956,9 @@ export const appCopy: Record = { "Account component": "账户组件", "All accounts": "所有账户", "All credentials": "全部凭据", + "Last updated": "上次更新", + "Refresh account": "刷新账户", + "Refreshing account": "正在刷新账户", "Add widget": "添加组件", "Analysis component": "分析组件", "Arc": "弧形", @@ -1385,11 +1391,13 @@ export const appCopy: Record = { "Priority spillover": "优先级溢出", "Ready": "可导入", "Scanning local agent logins": "正在扫描本机 Agent 登录态", - "Select preset provider": "选择预设供应商", + "Select preset provider": "选择 预设供应商", "ZCode login detected. Click Import to add it as a gateway provider.": "已检测到 ZCode 登录态。点击导入即可添加为网关供应商。", "ZCode login was detected, but its local credential is encrypted and cannot be imported automatically.": "已检测到 ZCode 登录态,但本机凭据已加密,无法自动导入。", "ZCode login was detected, but no usable provider API key was found in ZCode config.": "已检测到 ZCode 登录态,但 ZCode 配置中没有找到可用的供应商 API key。", "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.": "已在本机 ZCode 配置中检测到供应商 API key。点击导入即可添加为网关供应商。", + "Kimi API (China)": "Kimi API(国内)", + "Kimi API (Global)": "Kimi API(海外)", "Kimi Code - Coding Plan": "Kimi Code - Coding Plan", "Zhipu AI (China)": "智谱 AI (国内)", "Zhipu AI (China) - Coding Plan": "智谱 AI (国内) - Coding Plan", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index 03b1643..e0578e2 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -328,6 +328,7 @@ export const providerPresetIconUrls: Record = { "kimi-coding": moonshotProviderIconUrl, mistral: mistralProviderIconUrl, moonshot: moonshotProviderIconUrl, + "moonshot-global": moonshotProviderIconUrl, openai: openaiProviderIconUrl, openrouter: openrouterProviderIconUrl, siliconflow: siliconflowProviderIconUrl, diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index b58d908..71864da 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -1571,7 +1571,10 @@ export function cloneProviderAccountConnectors(connectors: ProviderAccountConnec return JSON.parse(JSON.stringify(connectors)) as ProviderAccountConnectorConfig[]; } -export function defaultProviderAccountConfigForPreset(_presetId: string | undefined): ProviderAccountConfig | undefined { +export function defaultProviderAccountConfigForPreset(presetId: string | undefined): ProviderAccountConfig | undefined { + if (presetId === "kimi-coding") { + return cloneProviderAccountConfig(findProviderPreset(presetId)?.account ?? defaultProviderAccountConfig); + } // Keep the advanced settings default on the standard endpoint; main resolves preset-specific connectors at runtime. return cloneProviderAccountConfig(defaultProviderAccountConfig); } diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index c33d28b..ab47884 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -1,6 +1,9 @@ import { createRequire } from "node:module"; import { EventEmitter } from "node:events"; +import os from "node:os"; +import path from "node:path"; import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app"; +import { CONFIGDIR } from "../../main/constants"; type HeaderValue = string | string[] | undefined; @@ -99,8 +102,9 @@ export class ClaudeCodeRouterPlugin { } try { - delete requireFromHere.cache[requireFromHere.resolve(routerPath)]; - const loaded = requireFromHere(routerPath) as unknown; + const resolvedRouterPath = resolveCustomRouterModule(routerPath); + delete requireFromHere.cache[resolvedRouterPath]; + const loaded = requireFromHere(resolvedRouterPath) as unknown; const customRouter = typeof loaded === "function" ? loaded : readDefaultFunction(loaded); if (!customRouter) { request.log.warn(`Custom router does not export a function: ${routerPath}`); @@ -115,6 +119,62 @@ export class ClaudeCodeRouterPlugin { } } +function resolveCustomRouterModule(routerPath: string): string { + const resolved = requireFromHere.resolve(resolveLocalModulePath(routerPath, "Custom router")); + assertJavaScriptModulePath(resolved, "Custom router"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); + if (path.isAbsolute(expanded)) { + return expanded; + } + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); + } + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + function resolveConfiguredRouteDecision( request: MutableRequestLike, config: AppConfig, diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index a824fa0..7dac5f1 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -3139,12 +3139,9 @@ function applyCors(response: ServerResponse, config?: AppConfig): void { async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { let apiKeys = await configuredApiKeys(config); if (apiKeys.length === 0) { - if (isLoopbackBindHost(config.gateway.host)) { - return { ok: true }; - } sendJson(response, 403, { error: { - message: "Configure at least one CCR API key before listening on a non-loopback gateway host." + message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one." } }); return { ok: false }; diff --git a/src/server/proxy/certificates.ts b/src/server/proxy/certificates.ts index 29e4b4e..ea5ae91 100644 --- a/src/server/proxy/certificates.ts +++ b/src/server/proxy/certificates.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; import net from "node:net"; import os from "node:os"; @@ -7,6 +7,9 @@ import forge from "node-forge"; import { CERTDIR, PROXY_CA_CERT_DER_FILE, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "../../main/constants"; const pki = forge.pki; +const certificateDirectoryMode = 0o700; +const certificateFileMode = 0o644; +const privateKeyFileMode = 0o600; export type CertificateAuthority = { cert: forge.pki.Certificate; @@ -25,8 +28,10 @@ type SubjectAltName = { }; export function ensureProxyCertificateAuthority(): void { - mkdirSync(CERTDIR, { recursive: true }); + mkdirSync(CERTDIR, { mode: certificateDirectoryMode, recursive: true }); + securePathPermissions(CERTDIR, certificateDirectoryMode); if (existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE)) { + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); return; } @@ -68,8 +73,9 @@ export function ensureProxyCertificateAuthority(): void { ]); cert.sign(keys.privateKey, forge.md.sha256.create()); - writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), "utf8"); - writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), "utf8"); + writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), { encoding: "utf8", mode: certificateFileMode }); + writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), { encoding: "utf8", mode: privateKeyFileMode }); + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); } @@ -214,7 +220,21 @@ function ensureProxyCertificateDerFile(): boolean { function writeProxyCertificateDerFile(cert: forge.pki.Certificate): void { const der = forge.asn1.toDer(pki.certificateToAsn1(cert)).getBytes(); - writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary")); + writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary"), { mode: certificateFileMode }); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function secureCertificateAuthorityFilePermissions(): void { + securePathPermissions(PROXY_CA_CERT_FILE, certificateFileMode); + securePathPermissions(PROXY_CA_KEY_FILE, privateKeyFileMode); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function securePathPermissions(file: string, mode: number): void { + if (process.platform === "win32" || !existsSync(file)) { + return; + } + chmodSync(file, mode); } function createSerialNumber(): string { diff --git a/src/shared/provider-presets.ts b/src/shared/provider-presets.ts index f33d067..e004fff 100644 --- a/src/shared/provider-presets.ts +++ b/src/shared/provider-presets.ts @@ -4,6 +4,7 @@ export type ProviderPresetEndpoint = { baseUrl: string; label?: string; protocols: GatewayProviderProtocol[]; + websiteUrl?: string; }; export type ProviderOfficialKeyPattern = { From 608915c03732ee007eade82a1e6bc54743aeeb12 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 29 Jun 2026 22:53:27 +0800 Subject: [PATCH 008/101] chore: release 3.0.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3208688..e282583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-router", - "version": "3.0.2", + "version": "3.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-router", - "version": "3.0.2", + "version": "3.0.3", "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.2", diff --git a/package.json b/package.json index ab86d4f..cc38e7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-router", - "version": "3.0.2", + "version": "3.0.3", "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", "repository": { From a784eba8370730e45b173a74037d5c08c4703d60 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 30 Jun 2026 09:19:53 +0800 Subject: [PATCH 009/101] Document SQLite config migration and add test script --- README.md | 8 +- README_zh.md | 8 +- build/run-tests.mjs | 33 +++ build/test.mjs | 44 +++ docs/src/content/docs/en/configuration.md | 4 +- .../en/configuration/configuration-file.md | 15 +- .../docs/en/configuration/extensions.md | 4 +- docs/src/content/docs/en/index.md | 2 +- docs/src/content/docs/zh/configuration.md | 4 +- .../docs/zh/configuration/config-file.md | 15 +- .../docs/zh/configuration/extensions.md | 4 +- docs/src/content/docs/zh/index.md | 2 +- docs/src/i18n/content.ts | 8 +- docs/src/pages/configuration/[slug].astro | 2 +- docs/src/pages/en/configuration/[slug].astro | 2 +- package.json | 1 + src/main/config.ts | 151 +--------- src/main/ipc.ts | 3 +- src/main/main-app.ts | 5 +- src/main/model-catalog-file.ts | 45 +++ src/main/preload.ts | 3 + src/main/provider-model-catalog.ts | 29 +- src/main/provider-probe.ts | 53 +++- src/main/request-log-store.ts | 68 ++++- src/main/tray-controller.ts | 26 +- src/main/usage-store.ts | 274 ++++++++++++++--- src/main/web-client-bridge.ts | 1 + src/main/web-management-server.ts | 4 +- src/renderer/pages/home/App.tsx | 33 +-- .../pages/home/components/network-logs.tsx | 59 +++- src/renderer/pages/home/shared/fallbacks.ts | 157 +--------- src/renderer/pages/home/shared/polling.ts | 53 ++++ src/renderer/types/electron.d.ts | 3 + src/server/gateway/model-catalog.ts | 29 +- src/shared/app.ts | 4 + src/shared/default-config.ts | 166 +++++++++++ src/shared/ipc-channels.ts | 1 + tests/bot-gateway-env.test.mjs | 111 +++++++ tests/deep-link.test.mjs | 126 ++++++++ tests/model-catalog-file.test.mjs | 61 ++++ tests/profile-launch-core.test.mjs | 104 +++++++ tests/provider-preset-utils.test.mjs | 83 ++++++ tests/provider-url.test.mjs | 40 +++ tests/request-log-store.test.mjs | 275 ++++++++++++++++++ tests/usage-activity.test.mjs | 34 +++ tests/usage-normalization.test.mjs | 56 ++++ tests/usage-store.test.mjs | 105 +++++++ 47 files changed, 1858 insertions(+), 460 deletions(-) create mode 100644 build/run-tests.mjs create mode 100644 build/test.mjs create mode 100644 src/main/model-catalog-file.ts create mode 100644 src/renderer/pages/home/shared/polling.ts create mode 100644 src/shared/default-config.ts create mode 100644 tests/bot-gateway-env.test.mjs create mode 100644 tests/deep-link.test.mjs create mode 100644 tests/model-catalog-file.test.mjs create mode 100644 tests/profile-launch-core.test.mjs create mode 100644 tests/provider-preset-utils.test.mjs create mode 100644 tests/provider-url.test.mjs create mode 100644 tests/request-log-store.test.mjs create mode 100644 tests/usage-activity.test.mjs create mode 100644 tests/usage-normalization.test.mjs create mode 100644 tests/usage-store.test.mjs diff --git a/README.md b/README.md index 941b7ad..255c556 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,11 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st - Windows: `Claude Code Router_.exe` - Linux: `Claude Code Router_.AppImage` 3. Install and launch **Claude Code Router**. -4. On first launch, CCR creates its local configuration: - - macOS/Linux: `~/.claude-code-router/config.json` - - Windows: `%APPDATA%\Claude Code Router\config.json` +4. On first launch, CCR creates its local configuration database: + - macOS/Linux: `~/.claude-code-router/config.sqlite` + - Windows: `%APPDATA%\Claude Code Router\config.sqlite` + +CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. CCR starts two local services when the gateway is enabled: diff --git a/README_zh.md b/README_zh.md index b450f38..de8ee68 100644 --- a/README_zh.md +++ b/README_zh.md @@ -67,9 +67,11 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host - Windows:`Claude Code Router_.exe` - Linux:`Claude Code Router_.AppImage` 3. 安装并启动 **Claude Code Router**。 -4. 首次启动后,CCR 会创建本地配置: - - macOS/Linux:`~/.claude-code-router/config.json` - - Windows:`%APPDATA%\Claude Code Router\config.json` +4. 首次启动后,CCR 会创建本地配置数据库: + - macOS/Linux:`~/.claude-code-router/config.sqlite` + - Windows:`%APPDATA%\Claude Code Router\config.sqlite` + +CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 启用网关后,CCR 会启动两个本地服务: diff --git a/build/run-tests.mjs b/build/run-tests.mjs new file mode 100644 index 0000000..086d390 --- /dev/null +++ b/build/run-tests.mjs @@ -0,0 +1,33 @@ +import electron from "electron"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-")); + +const child = spawn(electron, ["--test", "dist/tests/*.js"], { + cwd: projectRoot, + env: { + ...process.env, + CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), + CCR_INTERNAL_HOME_DIR: testHome, + CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), + HOME: testHome, + ELECTRON_RUN_AS_NODE: "1" + }, + shell: process.platform === "win32", + stdio: "inherit" +}); + +child.on("exit", (code, signal) => { + rmSync(testHome, { force: true, recursive: true }); + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/build/test.mjs b/build/test.mjs new file mode 100644 index 0000000..09e17dd --- /dev/null +++ b/build/test.mjs @@ -0,0 +1,44 @@ +import esbuild from "esbuild"; +import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const outdir = path.join(projectRoot, "dist", "tests"); + +rmSync(outdir, { force: true, recursive: true }); +mkdirSync(outdir, { recursive: true }); + +const entryPoints = findTestFiles(path.join(projectRoot, "tests")); + +await esbuild.build({ + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints, + external: [ + "better-sqlite3", + "electron" + ], + format: "cjs", + legalComments: "none", + logLevel: "info", + outdir, + platform: "node", + target: "node22" +}); + +function findTestFiles(dir) { + const files = []; + for (const name of readdirSync(dir)) { + const file = path.join(dir, name); + const stat = statSync(file); + if (stat.isDirectory()) { + files.push(...findTestFiles(file)); + } else if (name.endsWith(".test.mjs")) { + files.push(file); + } + } + return files.sort(); +} diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index 7f6c4e7..dd828f8 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -2,7 +2,7 @@ title: Claude Code Router Detailed Configuration pageTitle: Detailed Configuration eyebrow: Detailed Configuration -lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config file location in detail. +lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config database location in detail. --- ## Page Structure @@ -19,7 +19,7 @@ Detailed configuration docs are split into standalone pages. Every left-sidebar | Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding | | Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging | | Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages | -| Config File Location | Config file location maintained by the desktop app | +| Config Database Location | SQLite config database location maintained by the desktop app | ## Content Relationships diff --git a/docs/src/content/docs/en/configuration/configuration-file.md b/docs/src/content/docs/en/configuration/configuration-file.md index a4f0f4f..5543fc1 100644 --- a/docs/src/content/docs/en/configuration/configuration-file.md +++ b/docs/src/content/docs/en/configuration/configuration-file.md @@ -1,16 +1,17 @@ --- -title: Config File Location -pageTitle: Config File Location +title: Config Database Location +pageTitle: Config Database Location eyebrow: Detailed Configuration -lead: Locate the JSON configuration file maintained by the CCR desktop app. +lead: Locate the SQLite configuration database maintained by the CCR desktop app. --- ## Default Locations -- **macOS**: `~/Library/Application Support/claude-code-router/config.json` -- **Windows**: `%APPDATA%/claude-code-router/config.json` -- **Linux**: `~/.config/claude-code-router/config.json` +- **macOS/Linux**: `~/.claude-code-router/config.sqlite` +- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite` ## Applying Changes -Restart CCR after editing the file directly. +CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once as a migration source when no SQLite config exists; after migration, editing `config.json` does not affect the current configuration. + +Use the desktop UI to change configuration, or export a backup from **Settings**. Do not edit `config.sqlite` directly while CCR is running; SQLite also maintains companion `config.sqlite-wal` and `config.sqlite-shm` files in the same directory. diff --git a/docs/src/content/docs/en/configuration/extensions.md b/docs/src/content/docs/en/configuration/extensions.md index ceccdea..a5e7912 100644 --- a/docs/src/content/docs/en/configuration/extensions.md +++ b/docs/src/content/docs/en/configuration/extensions.md @@ -177,7 +177,7 @@ The recommended flow is through the desktop UI: 4. Save the config. 5. Open **Server** and restart the gateway. -You can also edit the config file directly: +CCR stores runtime configuration in SQLite, so add extensions through the UI instead of editing the legacy JSON config file. The extension entry has this shape: ```json { @@ -194,7 +194,7 @@ You can also edit the config file directly: } ``` -Restart CCR after editing the config file directly. See [Config File Location](/en/configuration/configuration-file/). +Restart the gateway after saving the extension config. See [Config Database Location](/en/configuration/configuration-file/). The local directory picker recognizes entry metadata from: diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index 0091b7d..a628432 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -26,7 +26,7 @@ The top navigation is split into four standalone pages: | --- | --- | | [Documentation](./) | Product positioning, architecture overview, and reading path | | [Quick Start](guides/) | From installation and provider setup to connecting an agent | -| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config file location | +| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config database location | | [Q&A](troubleshooting/) | Request logs, observability panel, and common questions | Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently. diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index 26b0a78..8cc3c8e 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -2,7 +2,7 @@ title: Claude Code Router 详细配置 pageTitle: 详细配置 eyebrow: 详细配置 -lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文件位置。这里是按功能查字段和扩展能力的地方。 +lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置数据库位置。这里是按功能查字段和扩展能力的地方。 --- ## 页面结构 @@ -19,7 +19,7 @@ lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文 | Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 | | 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 | | Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 | -| 配置文件位置 | 桌面 App 维护的配置文件位置 | +| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 | ## 内容关系 diff --git a/docs/src/content/docs/zh/configuration/config-file.md b/docs/src/content/docs/zh/configuration/config-file.md index c60f808..fe34677 100644 --- a/docs/src/content/docs/zh/configuration/config-file.md +++ b/docs/src/content/docs/zh/configuration/config-file.md @@ -1,16 +1,17 @@ --- -title: 配置文件位置 -pageTitle: 配置文件位置 +title: 配置数据库位置 +pageTitle: 配置数据库位置 eyebrow: 详细配置 -lead: 找到 CCR 桌面 App 默认维护的配置文件。 +lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。 --- ## 默认位置 -- macOS:`~/Library/Application Support/claude-code-router/config.json` -- Windows:`%APPDATA%/claude-code-router/config.json` -- Linux:`~/.config/claude-code-router/config.json` +- macOS/Linux:`~/.claude-code-router/config.sqlite` +- Windows:`%APPDATA%\Claude Code Router\config.sqlite` ## 生效方式 -直接编辑配置文件后,需要重启 CCR。 +CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次,迁移完成后继续编辑 `config.json` 不会影响当前配置。 + +建议通过桌面 UI 修改配置,或在 **Settings** 中导出备份。不要在 CCR 运行时直接编辑 `config.sqlite`;SQLite 还会维护同目录的 `config.sqlite-wal` 和 `config.sqlite-shm` 辅助文件。 diff --git a/docs/src/content/docs/zh/configuration/extensions.md b/docs/src/content/docs/zh/configuration/extensions.md index 5fdf852..56dce99 100644 --- a/docs/src/content/docs/zh/configuration/extensions.md +++ b/docs/src/content/docs/zh/configuration/extensions.md @@ -177,7 +177,7 @@ module.exports = { 4. 保存配置。 5. 打开 **Server** 页面,重启网关。 -也可以直接编辑配置文件: +CCR 的运行配置存储在 SQLite 中,因此推荐通过 UI 添加扩展,而不是编辑旧版 JSON 配置文件。扩展条目的配置结构如下: ```json { @@ -194,7 +194,7 @@ module.exports = { } ``` -直接编辑配置文件后需要重启 CCR。配置文件位置见 [配置文件位置](/configuration/config-file/)。 +保存扩展配置后需要重启网关。配置数据库位置见 [配置数据库位置](/configuration/config-file/)。 本地目录选择器会按顺序识别这些入口信息: diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 698cb07..00aef30 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -26,7 +26,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个 | --- | --- | | [文档](./) | 产品定位、架构概览、阅读路径 | | [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 | -| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 | +| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置数据库位置 | | [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 | Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 5148abf..be1739b 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -71,7 +71,7 @@ export const docsContent = { "Agent配置", "扩展机制", "Bot 与 IM 接力 Agent", - "配置文件位置", + "配置数据库位置", ], active: "供应商配置", }, @@ -112,7 +112,7 @@ export const docsContent = { 企业微信: "/bot-与-im-接力-agent/wecom/", 飞书: "/bot-与-im-接力-agent/feishu/", 钉钉: "/bot-与-im-接力-agent/dingtalk/", - 配置文件位置: "/configuration/config-file/", + 配置数据库位置: "/configuration/config-file/", }, sidebarTargets: {}, }, @@ -211,7 +211,7 @@ export const docsContent = { "Agent Config", "Extension Mechanism", "Bots And IM Agent Relay", - "Config File Location", + "Config Database Location", ], active: "Provider Config", }, @@ -242,7 +242,7 @@ export const docsContent = { WeCom: "/en/relay-agents-in-im-with-bots/wecom/", Feishu: "/en/relay-agents-in-im-with-bots/feishu/", DingTalk: "/en/relay-agents-in-im-with-bots/dingtalk/", - "Config File Location": "/en/configuration/configuration-file/", + "Config Database Location": "/en/configuration/configuration-file/", }, sidebarTargets: {}, }, diff --git a/docs/src/pages/configuration/[slug].astro b/docs/src/pages/configuration/[slug].astro index 5e056e9..9253864 100644 --- a/docs/src/pages/configuration/[slug].astro +++ b/docs/src/pages/configuration/[slug].astro @@ -16,7 +16,7 @@ export function getStaticPaths() { extensions: "扩展机制", "bot-relay": "Bot 与 IM 接力 Agent", "bot-setup": "配置步骤", - "config-file": "配置文件位置", + "config-file": "配置数据库位置", }; return Object.entries(zhConfigurationDocs).map(([filePath, mod]) => { diff --git a/docs/src/pages/en/configuration/[slug].astro b/docs/src/pages/en/configuration/[slug].astro index 3c7bd97..4cd9324 100644 --- a/docs/src/pages/en/configuration/[slug].astro +++ b/docs/src/pages/en/configuration/[slug].astro @@ -16,7 +16,7 @@ export function getStaticPaths() { extensions: "Extension Mechanism", bots: "Bots And IM Agent Relay", "bot-setup": "Setup", - "configuration-file": "Config File Location", + "configuration-file": "Config Database Location", }; return Object.entries(enConfigurationDocs).map(([filePath, mod]) => { diff --git a/package.json b/package.json index cc38e7c..0f930d8 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "prepack": "npm run build:assets", "prepublishOnly": "npm run typecheck", "preview": "npm run build:assets && electron .", + "test": "node build/test.mjs && node build/run-tests.mjs", "typecheck": "tsc --noEmit", "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, diff --git a/src/main/config.ts b/src/main/config.ts index 15de3cf..efaca14 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -4,6 +4,7 @@ import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config- import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store"; import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE } from "./constants"; import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app"; +import { createDefaultAppConfig } from "../shared/default-config"; import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "./presets"; import type { AppConfig, @@ -81,15 +82,6 @@ type RawAppConfigLoadResult = { value: Partial; }; -const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [ - { host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] }, - { host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] }, - { host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] }, - { host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] }, - { host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] }, - { host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] } -]; - const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ "legacy-subagent", "legacy-background", @@ -100,143 +92,10 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1"; const GENERATED_GATEWAY_API_KEY_ID = "local-gateway"; -const DEFAULT_CONFIG: AppConfig = { - APIKEY: "", - APIKEYS: [], - API_TIMEOUT_MS: 600000, - CUSTOM_ROUTER_PATH: "", - HOST: "127.0.0.1", - PORT: 3456, - Providers: [], - Router: { - fallback: { - mode: "off", - models: [], - retryCount: 1 - }, - longContextThreshold: 200000, - rules: [] - }, - agent: { - mcpServers: [] - }, - autoStart: false, - botConfigs: [], - botGateway: { - acknowledgeEvents: false, - args: [], - authType: "", - autoStartIntegration: true, - command: "", - createIntegration: false, - credentials: {}, - cwd: "", - enabled: false, - forwardAllAgentMessages: true, - handoff: { - enabled: false, - idleSeconds: 30, - phoneBluetoothTargets: [], - phoneWifiTargets: [], - screenLock: true, - userIdle: true - }, - integrationConfig: {}, - integrationId: "", - platform: "none", - pollIntervalMs: 2000, - requestTimeoutMs: 600000, - sourceDir: "", - startupTimeoutMs: 10000, - stateDir: "", - tenantId: "ccr" - }, - gateway: { - coreHost: INTERNAL_GATEWAY_CORE_HOST, - corePort: 3457, - enabled: true, - generatedConfigFile: GATEWAY_CONFIG_FILE, - host: "127.0.0.1", - port: 3456 - }, - observability: { - agentAnalysis: false, - requestLogs: false - }, - preferredProvider: "", - plugins: [], - overviewWidgets: DEFAULT_OVERVIEW_WIDGETS, - profile: { - claudeCode: { - enabled: true, - model: "", - settingsFile: "~/.claude/settings.json", - smallFastModel: "" - }, - codex: { - cliMiddleware: true, - codexCliPath: "", - codexHome: "", - configFormat: "separate_profile_files", - configFile: "~/.codex/config.toml", - enabled: true, - model: "", - providerId: "claude-code-router", - providerName: "Claude Code Router", - showAllSessions: false - }, - enabled: true, - profiles: [ - { - agent: "claude-code", - enabled: true, - env: { ...CLAUDE_CODE_DEFAULT_ENV }, - id: "default-claude-code", - model: "", - name: "Claude Code", - scope: "global", - settingsFile: "~/.claude/settings.json", - smallFastModel: "", - surface: "auto" - }, - { - agent: "codex", - cliMiddleware: true, - codexCliPath: "", - codexHome: "", - configFormat: "separate_profile_files", - configFile: "~/.codex/config.toml", - enabled: true, - env: {}, - id: "default-codex", - model: "", - name: "Codex", - providerId: "claude-code-router", - providerName: "Claude Code Router", - showAllSessions: false, - scope: "global", - surface: "auto" - } - ] - }, - proxy: { - browserMode: true, - captureNetwork: false, - enabled: false, - host: "127.0.0.1", - mode: "gateway", - port: 7890, - systemProxy: false, - targets: DEFAULT_PROXY_TARGETS - }, - routerEndpoint: "http://127.0.0.1:3456", - theme: "system", - trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS, - trayIcon: "random", - trayProgressTargetTokens: 100000, - trayWidgets: DEFAULT_TRAY_WIDGETS, - trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES -}; +const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({ + coreHost: INTERNAL_GATEWAY_CORE_HOST, + generatedConfigFile: GATEWAY_CONFIG_FILE +}); function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig { const platform = normalizeBotGatewayPlatform(config?.platform ?? DEFAULT_CONFIG.botGateway.platform); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 94b24b0..feb36f5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -24,7 +24,7 @@ import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, sto import { ensureProxyCertificateAuthority } from "../server/proxy/certificates"; import { proxyService } from "../server/proxy/service"; import { listMcpServerTools } from "../server/mcp/tool-discovery"; -import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "./request-log-store"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "./usage-store"; @@ -94,6 +94,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyCertificateStatus, () => proxyService.get ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNetworkCaptures()); ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace); +ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request)); ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter)); ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter)); diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 72f63d5..3fedf93 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -51,7 +51,10 @@ function startPrimaryInstance(): void { void startConfiguredServices("startup"); app.on("activate", () => { - windowsManager.showMainWindow(); + const mainWindow = windowsManager.getWindow("main"); + if (!trayController.consumeMainWindowActivationSuppression() && (!mainWindow || !mainWindow.isVisible())) { + windowsManager.showMainWindow(); + } queueEnsureConfiguredProxyModeActive("activate"); }); }); diff --git a/src/main/model-catalog-file.ts b/src/main/model-catalog-file.ts new file mode 100644 index 0000000..68b5c86 --- /dev/null +++ b/src/main/model-catalog-file.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; + +export type LoadedModelCatalogPayload = { + loadedFrom: string; + payload: unknown; +}; + +export function loadModelCatalogPayload(): LoadedModelCatalogPayload | undefined { + for (const candidate of modelCatalogPathCandidates()) { + if (!existsSync(candidate)) { + continue; + } + return { + loadedFrom: candidate, + payload: JSON.parse(readFileSync(candidate, "utf8")) as unknown + }; + } + return undefined; +} + +export function modelCatalogPathCandidates(): string[] { + return uniqueStrings([ + process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", + process.env.CCR_MODELS_JSON_PATH?.trim() || "", + pathResolve(process.cwd(), "models.json"), + pathResolve(__dirname, "..", "models.json"), + pathResolve(__dirname, "..", "assets", "models.json"), + pathResolve(__dirname, "..", "..", "..", "models.json") + ]); +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const strings: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + strings.push(trimmed); + } + return strings; +} diff --git a/src/main/preload.ts b/src/main/preload.ts index c7d117d..53677e9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -58,6 +58,8 @@ import type { ProxyCertificateStatus, ProxyNetworkSnapshot, ProxyStatus, + RequestLogDetailRequest, + RequestLogEntry, RequestLogListFilter, RequestLogPage, UsageStatsFilter, @@ -108,6 +110,7 @@ contextBridge.exposeInMainWorld("ccr", { getProxyCertificateStatus: () => invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise, getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise, getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise, + getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise, getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise, getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise, getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise, diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index 70e9b48..87519c6 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -1,7 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve as pathResolve } from "node:path"; import type { ProviderCatalogModelsRequest, ProviderCatalogModelsResult } from "../shared/app"; import { providerUrlWithDefaultScheme } from "../shared/provider-url"; +import { loadModelCatalogPayload } from "./model-catalog-file"; import { findProviderPreset, findProviderPresetByBaseUrl } from "./presets"; type CatalogProviderEntry = { @@ -121,17 +120,14 @@ function loadCatalogIndex(): CatalogIndex { return catalogIndex; } - for (const candidate of catalogPathCandidates()) { - if (!existsSync(candidate)) { - continue; - } - try { - const payload = JSON.parse(readFileSync(candidate, "utf8")) as unknown; - catalogIndex = buildCatalogIndex(payload, candidate); + try { + const loaded = loadModelCatalogPayload(); + if (loaded) { + catalogIndex = buildCatalogIndex(loaded.payload, loaded.loadedFrom); return catalogIndex; - } catch (error) { - console.warn(`Failed to load provider model catalog from ${candidate}:`, error); } + } catch (error) { + console.warn("Failed to load provider model catalog:", error); } catalogIndex = { @@ -140,17 +136,6 @@ function loadCatalogIndex(): CatalogIndex { return catalogIndex; } -function catalogPathCandidates(): string[] { - return uniqueStrings([ - process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", - process.env.CCR_MODELS_JSON_PATH?.trim() || "", - pathResolve(process.cwd(), "models.json"), - pathResolve(__dirname, "..", "models.json"), - pathResolve(__dirname, "..", "assets", "models.json"), - pathResolve(__dirname, "..", "..", "..", "models.json") - ]); -} - function buildCatalogIndex(payload: unknown, loadedFrom: string): CatalogIndex { const providers = new Map(); const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : []; diff --git a/src/main/provider-probe.ts b/src/main/provider-probe.ts index 48032f6..91ed2d4 100644 --- a/src/main/provider-probe.ts +++ b/src/main/provider-probe.ts @@ -220,8 +220,10 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) const modelProbe = mode !== "models" || request.skipModelDiscovery ? { models: [] } : await probeModels(parsed, request.apiKey, protocols); - const models = mode === "connectivity" && modelProbe.models.length > 0 ? modelProbe.models : typedModels; - const protocolResults = mode === "models" ? [] : await probeProtocols(parsed, request.apiKey, models, protocols, mode); + const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0 + ? modelProbe.models + : typedModels; + const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode); const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols); return { @@ -466,7 +468,7 @@ async function probeProtocols( results.push( mode === "connectivity" ? await probeProtocolConnectivity(parsed, apiKey, models, protocol) - : await probeProtocolSupport(parsed, protocol) + : await probeProtocolSupport(parsed, apiKey, protocol) ); } @@ -475,6 +477,7 @@ async function probeProtocols( async function probeProtocolSupport( parsed: ParsedProviderUrl, + apiKey: string | undefined, protocol: GatewayProviderProtocol ): Promise { const endpoints = endpointsForProtocol(parsed, protocol, undefined); @@ -482,7 +485,7 @@ async function probeProtocolSupport( let firstResult: GatewayProviderProbeProtocolResult | undefined; for (const candidate of endpoints) { - const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol)); + const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol, apiKey)); const message = readResponseMessage(result); const supported = isProtocolEndpointSupported(result.status, message); const probeResult = { @@ -620,12 +623,12 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap }; } -function requestForProtocolSupport(protocol: GatewayProviderProtocol): RequestInit { +function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: string | undefined): RequestInit { return { body: JSON.stringify({}), headers: { "content-type": "application/json", - ...(protocol === "anthropic_messages" ? { "anthropic-version": "2023-06-01" } : {}) + ...headersForProtocol(protocol, apiKey) }, method: "POST" }; @@ -686,10 +689,16 @@ function endpointsForProtocol( } if (protocol === "anthropic_messages") { - return parsed.anthropicBaseUrlCandidates.map((baseUrl) => ({ - baseUrl, - endpoint: `${baseUrl}/v1/messages` - })); + return parsed.anthropicBaseUrlCandidates.flatMap((baseUrl) => uniqueProtocolEndpoints([ + { + baseUrl, + endpoint: `${baseUrl}/v1/messages` + }, + { + baseUrl, + endpoint: `${baseUrl}/messages` + } + ])); } const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model")); @@ -734,6 +743,16 @@ function geminiHeaders(apiKey: string | undefined): Record { : {}; } +function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | undefined): Record { + if (protocol === "anthropic_messages") { + return anthropicHeaders(apiKey); + } + if (protocol === "gemini_generate_content") { + return geminiHeaders(apiKey); + } + return openAiHeaders(apiKey); +} + function parseModelList(payload: unknown, source: ModelSource): Pick { if (!isRecord(payload)) { return { @@ -1067,6 +1086,20 @@ function uniqueProtocols(values: GatewayProviderProtocol[]): GatewayProviderProt return values.filter((value, index) => values.indexOf(value) === index); } +function uniqueProtocolEndpoints(values: ProtocolEndpoint[]): ProtocolEndpoint[] { + const seen = new Set(); + const result: ProtocolEndpoint[] = []; + for (const value of values) { + const key = `${value.baseUrl}\n${value.endpoint}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(value); + } + return result; +} + function uniqueModelSources(values: ModelSource[]): ModelSource[] { return values.filter((value, index) => values.indexOf(value) === index); } diff --git a/src/main/request-log-store.ts b/src/main/request-log-store.ts index 1ebfea5..25c735f 100644 --- a/src/main/request-log-store.ts +++ b/src/main/request-log-store.ts @@ -30,6 +30,8 @@ import type { AgentKind, GatewayProviderProtocol, RequestLogBody, + RequestLogDetailRequest, + RequestLogEntry, RequestLogFilterOptions, RequestLogListFilter, RequestLogPage, @@ -195,6 +197,10 @@ const maxBodyBytes = 2 * 1024 * 1024; const maxAgentAnalysisRows = 5000; const maxAgentSessionDetailRequests = 250; const maxTracePayloadPreviewChars = 1600; +const requestLogBodyMetadataSelect = ` + '' AS request_body_text, + '' AS response_body_text +`; const emptyAgentAnalysisTotals: AgentAnalysisTotals = { avgDurationMs: 0, cacheRatio: 0, @@ -227,10 +233,18 @@ const sensitiveHeaderNames = new Set([ "x-auth-sub" ]); -class RequestLogStore { +type AgentAnalysisCacheEntry = { + filterKey: string; + revision: number; + snapshot: AgentAnalysisSnapshot; +}; + +export class RequestLogStore { private database?: SqlDatabase; private initPromise?: Promise; private lastRetentionCleanupDay?: string; + private revision = 0; + private analysisCache?: AgentAnalysisCacheEntry; constructor(private readonly dbFile: string) {} @@ -371,6 +385,7 @@ class RequestLogStore { responseBody.truncated ? 1 : 0, responseError ?? "" ); + this.revision += 1; } async updateFromRawTrace(input: RequestLogRawTraceUpdateInput): Promise { @@ -520,6 +535,7 @@ class RequestLogStore { } database.prepare(`UPDATE request_logs SET ${sets.join(", ")} WHERE request_id = ?`).run(...params, requestId); + this.revision += 1; return true; } @@ -563,12 +579,11 @@ class RequestLogStore { cost_usd, request_headers, response_headers, - request_body_text, + ${requestLogBodyMetadataSelect}, request_body_encoding, request_body_content_type, request_body_size_bytes, request_body_truncated, - response_body_text, response_body_encoding, response_body_content_type, response_body_size_bytes, @@ -593,10 +608,26 @@ class RequestLogStore { }; } + async getDetail(request: RequestLogDetailRequest): Promise { + const database = await this.getDatabase(); + const requestLogId = normalizeCount(request.id); + if (requestLogId <= 0) { + return undefined; + } + return readRequestLogById(database, requestLogId); + } + async analyze(filter: AgentAnalysisFilter = {}): Promise { const database = await this.getDatabase(); this.pruneOldRequestLogs(database); const now = new Date(); + const filterKey = agentAnalysisCacheKey(filter); + if (this.analysisCache?.revision === this.revision && this.analysisCache.filterKey === filterKey) { + return { + ...this.analysisCache.snapshot, + generatedAt: now.toISOString() + }; + } const range = normalizeAgentAnalysisRange(filter.range); const since = getAgentAnalysisSince(range, now); const rows = queryRows( @@ -665,7 +696,7 @@ class RequestLogStore { ? buildAgentSessionDetail(analysisRequests) : undefined; - return { + const snapshot: AgentAnalysisSnapshot = { agents: buildAgentRows(analysisRequests), clients: buildAgentClientRows(analysisRequests), concurrency: buildAgentConcurrencySeries(range, now, analysisRequests), @@ -682,6 +713,12 @@ class RequestLogStore { tools: buildAgentToolRows(analysisRequests), totals: buildAgentAnalysisTotals(analysisRequests) }; + this.analysisCache = { + filterKey, + revision: this.revision, + snapshot + }; + return snapshot; } async getTracePayload(request: AgentAnalysisTracePayloadRequest): Promise { @@ -840,6 +877,15 @@ export async function getRequestLogs(filter?: RequestLogListFilter): Promise { + try { + return await requestLogStore.getDetail(request); + } catch (error) { + console.warn(`[request-log] Failed to read request log detail: ${formatError(error)}`); + throw error; + } +} + export async function getAgentAnalysis(filter?: AgentAnalysisFilter): Promise { try { return await requestLogStore.analyze(filter); @@ -902,6 +948,15 @@ function toAnalyzedAgentRequest(entry: StoredRequestLogEntry): AnalyzedAgentRequ }; } +function agentAnalysisCacheKey(filter: AgentAnalysisFilter): string { + return JSON.stringify({ + agent: normalizeAgentFilter(filter.agent), + range: normalizeAgentAnalysisRange(filter.range), + sessionAgent: normalizeAgentFilter(filter.sessionAgent), + sessionId: normalizeFilterValue(filter.sessionId) + }); +} + function extractAgentLogDetails(entry: StoredRequestLogEntry): AgentLogDetails { const requestPayloads = parseLogBodyPayloads(entry.requestBody); const responsePayloads = parseLogBodyPayloads(entry.responseBody); @@ -2632,6 +2687,11 @@ function ensureRequestLogSchema(database: SqlDatabase): void { 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)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_list_idx ON request_logs(source_usage_id, created_at DESC, id DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_credential_created_at_idx ON request_logs(credential_id, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_model_created_at_idx ON request_logs(model, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_created_at_idx ON request_logs(provider, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_created_at_idx ON request_logs(ok, created_at DESC)"); } function backfillRequestLogStreamFlags(database: SqlDatabase): void { diff --git a/src/main/tray-controller.ts b/src/main/tray-controller.ts index 0bcf23f..11d5b1e 100644 --- a/src/main/tray-controller.ts +++ b/src/main/tray-controller.ts @@ -14,6 +14,7 @@ const popoverDetailGap = 12; const popoverDetailTopOffset = 0; const popoverDetailWidth = 420; const popoverMargin = 8; +const trayActivationSuppressMs = 750; const trayMenuBarIconSize = 20; const trayWindowBackgroundColor = "#020617"; const trayTokenFallbackTitle = "0 tokens"; @@ -38,6 +39,7 @@ class TrayController { private randomTrayIconDateKey?: string; private resolvedRandomTrayIcon?: TrayMascotIconId; private refreshTimer?: NodeJS.Timeout; + private suppressMainWindowActivationUntil = 0; private tray?: Tray; private trayBalanceProgress?: TrayBalanceProgressConfig; private trayIconPreference: TrayIconPreference = "random"; @@ -52,8 +54,14 @@ class TrayController { const icon = createTrayIcon(this.resolveTrayIconId("random")); this.tray = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon); this.applyTrayTitle(trayTokenFallbackTitle); - this.tray.on("click", () => this.togglePopover()); - this.tray.on("right-click", () => this.showContextMenu()); + this.tray.on("click", () => { + this.suppressMainWindowActivation(); + this.togglePopover(); + }); + this.tray.on("right-click", () => { + this.suppressMainWindowActivation(); + this.showContextMenu(); + }); void this.refreshIconFromConfig(); this.unsubscribeUsageUpdates = onUsageRecorded(() => { @@ -102,6 +110,14 @@ class TrayController { void this.refreshTrayTitle(); } + consumeMainWindowActivationSuppression(): boolean { + if (process.platform !== "darwin" || Date.now() > this.suppressMainWindowActivationUntil) { + return false; + } + this.suppressMainWindowActivationUntil = 0; + return true; + } + async refreshIconFromConfig(config?: AppConfig): Promise { if (!supportsTrayPlatform() || !this.tray) { return; @@ -140,6 +156,12 @@ class TrayController { this.showPopover(); } + private suppressMainWindowActivation(): void { + if (process.platform === "darwin") { + this.suppressMainWindowActivationUntil = Date.now() + trayActivationSuppressMs; + } + } + private showPopover(): void { const popover = this.ensurePopover(); this.clearDetailCloseTimer(); diff --git a/src/main/usage-store.ts b/src/main/usage-store.ts index 351d8c5..45cd1f9 100644 --- a/src/main/usage-store.ts +++ b/src/main/usage-store.ts @@ -59,6 +59,11 @@ type UsageStatsQueryOptions = { includeProxy?: boolean; }; +type UsageWhereClause = { + params: SqlValue[]; + where: string; +}; + type StoredUsageEvent = { cacheReadTokens: number; cacheWriteTokens: number; @@ -98,7 +103,7 @@ const emptyTotals: UsageTotals = { totalTokens: 0 }; -class UsageStore { +export class UsageStore { private database?: SqlDatabase; private initPromise?: Promise; @@ -174,27 +179,23 @@ class UsageStore { const database = await this.getDatabase(); const now = new Date(); const since = getRangeSince(range, now); - const query = buildUsageStatsQuery(since, filter); - const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); + const query = buildUsageWhereClause(since, filter); return { - clientModels: buildClientModelRows(events), + clientModels: readClientModelRows(database, query), generatedAt: now.toISOString(), - models: buildModelRows(events), - providerModels: buildProviderModelRows(events), + models: readModelRows(database, query), + providerModels: readProviderModelRows(database, query), range, - recentRequests: buildRecentRequestRows(events), - series: buildSeries(range, now, events), - totals: buildTotals(events) + recentRequests: readRecentRequestRows(database, query), + series: readUsageSeries(database, range, now, query), + totals: readUsageTotals(database, query) }; } async getTotalsSince(since: Date, filter: UsageStatsFilter = {}, options: UsageStatsQueryOptions = {}): Promise { const database = await this.getDatabase(); - const query = buildUsageStatsQuery(since, filter, options); - const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); - - return buildTotals(events); + return readUsageTotals(database, buildUsageWhereClause(since, filter, options)); } private async getDatabase(): Promise { @@ -277,6 +278,10 @@ function ensureUsageSchema(database: SqlDatabase): void { 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)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_created_filter_idx ON usage_events(created_at, provider, model, credential_id)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_provider_created_at_idx ON usage_events(provider, created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_created_at_idx ON usage_events(model, created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_created_at_idx ON usage_events(credential_id, created_at)"); } export async function getUsageStats(range?: UsageStatsRange, filter?: UsageStatsFilter): Promise { @@ -339,11 +344,11 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi } } -function buildUsageStatsQuery( +function buildUsageWhereClause( since: Date, filter: UsageStatsFilter, options: UsageStatsQueryOptions = {} -): { params: SqlValue[]; sql: string } { +): UsageWhereClause { const where = ["created_at >= ?"]; const params: SqlValue[] = [since.toISOString()]; const credential = normalizeFilterValue(filter.credential); @@ -368,30 +373,7 @@ function buildUsageStatsQuery( return { params, - sql: ` - SELECT - id, - created_at, - request_id, - client, - method, - path, - model, - provider, - credential_id, - status_code, - duration_ms, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens, - cost_usd, - cost_source - FROM usage_events - WHERE ${where.join(" AND ")} - ORDER BY created_at ASC - ` + where: where.join(" AND ") }; } @@ -428,6 +410,220 @@ function toStoredUsageEvent(row: Record): StoredUsageEvent { }; } +const usageTotalsSelect = ` + COUNT(*) AS request_count, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(CASE + WHEN total_tokens > 0 THEN total_tokens + ELSE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + END), 0) AS computed_total_tokens, + COALESCE(SUM(COALESCE(cost_usd, 0)), 0) AS cost_usd, + COALESCE(SUM(duration_ms), 0) AS duration_ms, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END), 0) AS success_count, + COALESCE(SUM(CASE + WHEN total_tokens - output_tokens > 0 THEN MAX(input_tokens, total_tokens - output_tokens) + ELSE input_tokens + cache_read_tokens + cache_write_tokens + END), 0) AS prompt_tokens +`; + +function readUsageTotals(database: SqlDatabase, query: UsageWhereClause): UsageTotals { + const row = queryRows( + database, + ` + SELECT + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + `, + query.params + )[0]; + return usageTotalsFromRow(row); +} + +function readUsageSeries( + database: SqlDatabase, + range: UsageStatsRange, + now: Date, + query: UsageWhereClause +): UsageSeriesPoint[] { + const unit: "day" | "hour" = range === "today" || range === "24h" ? "hour" : "day"; + const bucketExpression = unit === "hour" + ? "strftime('%Y-%m-%d %H:00', created_at, 'localtime')" + : "strftime('%Y-%m-%d', created_at, 'localtime')"; + const rows = queryRows( + database, + ` + SELECT + ${bucketExpression} AS bucket, + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + GROUP BY bucket + `, + query.params + ); + const totalsByBucket = new Map(rows.map((row) => [String(row.bucket ?? ""), usageTotalsFromRow(row)])); + + return buildBuckets(range, now).map(({ key, label }) => ({ + ...(totalsByBucket.get(key) ?? { ...emptyTotals }), + bucket: key, + label + })); +} + +function readModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "provider, model", + "provider, model, MAX(credential_id) AS credential_id", + 8 + ).map((row) => ({ + ...usageTotalsFromRow(row), + caption: normalizeLabel(String(row.provider ?? ""), "unknown"), + credentialId: normalizeFilterValue(String(row.credential_id ?? "")), + key: `${normalizeLabel(String(row.provider ?? ""), "unknown")}::${normalizeLabel(String(row.model ?? ""), "unknown")}`, + label: normalizeLabel(String(row.model ?? ""), "unknown"), + maxShare: 0, + model: normalizeLabel(String(row.model ?? ""), "unknown"), + provider: normalizeLabel(String(row.provider ?? ""), "unknown") + })); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readClientModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "client, provider, credential_id, model", + "client, provider, credential_id, model", + 25 + ).map((row) => { + const client = normalizeLabel(String(row.client ?? ""), "unknown"); + const model = normalizeLabel(String(row.model ?? ""), "unknown"); + const provider = normalizeLabel(String(row.provider ?? ""), "unknown"); + const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? ""; + return { + ...usageTotalsFromRow(row), + caption: credentialId ? `${provider} / ${credentialId} / ${model}` : `${provider} / ${model}`, + client, + credentialId: credentialId || undefined, + key: `${client}::${provider}::${credentialId}::${model}`, + label: client, + maxShare: 0, + model, + provider + }; + }); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readProviderModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "provider, credential_id, model", + "provider, credential_id, model", + 25 + ).map((row) => { + const model = normalizeLabel(String(row.model ?? ""), "unknown"); + const provider = normalizeLabel(String(row.provider ?? ""), "unknown"); + const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? ""; + return { + ...usageTotalsFromRow(row), + caption: credentialId ? `${credentialId} / ${model}` : model, + credentialId: credentialId || undefined, + key: `${provider}::${credentialId}::${model}`, + label: provider, + maxShare: 0, + model, + provider + }; + }); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readUsageGroupRows( + database: SqlDatabase, + query: UsageWhereClause, + groupBy: string, + selectColumns: string, + limit: number +): Record[] { + return queryRows( + database, + ` + SELECT + ${selectColumns}, + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + GROUP BY ${groupBy} + ORDER BY computed_total_tokens DESC, request_count DESC + LIMIT ? + `, + [...query.params, limit] + ); +} + +function readRecentRequestRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const events = queryRows( + database, + ` + SELECT + id, + created_at, + request_id, + client, + method, + path, + model, + provider, + credential_id, + status_code, + duration_ms, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + cost_usd, + cost_source + FROM usage_events + WHERE ${query.where} + ORDER BY created_at DESC, id DESC + LIMIT 10 + `, + query.params + ).map(toStoredUsageEvent).reverse(); + return buildRecentRequestRows(events); +} + +function usageTotalsFromRow(row: Record | undefined): UsageTotals { + const requestCount = normalizeCount(row?.request_count); + if (requestCount === 0) { + return { ...emptyTotals }; + } + const successfulRequests = normalizeCount(row?.success_count); + const promptTokens = normalizeCount(row?.prompt_tokens); + const cacheTokens = normalizeCount(row?.cache_read_tokens); + return { + avgDurationMs: Math.round(normalizeCount(row?.duration_ms) / requestCount), + cacheRatio: ratio(cacheTokens, promptTokens), + cacheTokens, + costUsd: normalizeCost(row?.cost_usd), + errorCount: requestCount - successfulRequests, + inputTokens: normalizeCount(row?.input_tokens), + outputTokens: normalizeCount(row?.output_tokens), + requestCount, + successRate: successfulRequests / requestCount, + totalTokens: normalizeCount(row?.computed_total_tokens) + }; +} + function buildSeries(range: UsageStatsRange, now: Date, events: StoredUsageEvent[]): UsageSeriesPoint[] { const buckets = buildBuckets(range, now); const grouped = new Map(); diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts index a9096f2..e5bf48c 100644 --- a/src/main/web-client-bridge.ts +++ b/src/main/web-client-bridge.ts @@ -112,6 +112,7 @@ window.ccr = { getProxyCertificateStatus: () => rpc("getProxyCertificateStatus") as ReturnType["getProxyCertificateStatus"]>, getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType["getProxyNetworkCaptures"]>, getProxyStatus: () => rpc("getProxyStatus") as ReturnType["getProxyStatus"]>, + getRequestLogDetail: (request) => rpc("getRequestLogDetail", [request]) as ReturnType["getRequestLogDetail"]>, getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType["getRequestLogs"]>, getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType["getUpdateStatus"]>, getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType["getUsageStats"]>, diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts index 4af4773..40d7f2b 100644 --- a/src/main/web-management-server.ts +++ b/src/main/web-management-server.ts @@ -24,7 +24,7 @@ import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, sto import { ensureProxyCertificateAuthority } from "../server/proxy/certificates"; import { proxyService } from "../server/proxy/service"; import { listMcpServerTools } from "../server/mcp/tool-discovery"; -import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "./request-log-store"; import { getUsageStats } from "./usage-store"; import { gatewayService } from "../server/gateway/service"; import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service"; @@ -57,6 +57,7 @@ import type { ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, + RequestLogDetailRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange @@ -285,6 +286,7 @@ const rpcHandlers: Record = { getProxyCertificateStatus: () => proxyService.getCertificateStatus(), getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(), getProxyStatus: () => proxyService.getStatus(), + getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest), getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined), getUpdateStatus: () => unsupportedUpdateStatus, getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined), diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index df5de23..633aea5 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -11,7 +11,7 @@ import { enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, - fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig, + fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatProxyCertificateInstallMessage, GatewayProviderConfig, fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile, GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid, isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, @@ -34,6 +34,7 @@ import { useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, VirtualModelDraft, virtualModelProfileFromDraft } from "./shared"; +import { startVisiblePolling } from "./shared/polling"; import { AppDialogStack, LightToast, MainLayout, OnboardingLayout } from "./components"; @@ -336,10 +337,9 @@ function App() { void window.ccr?.getProxyStatus().then(setProxyStatus); void refreshProfileRuntimeStatus(); }; - refreshRuntimeStatus(); - const timer = window.setInterval(refreshRuntimeStatus, 2000); + const stopPolling = startVisiblePolling(refreshRuntimeStatus, 2000); return () => { - window.clearInterval(timer); + stopPolling(); unsubscribeOpenSettings(); unsubscribeOpenUpdate(); }; @@ -435,11 +435,10 @@ function App() { } }); }; - refreshUsageStats(); - const timer = window.setInterval(refreshUsageStats, 5000); + const stopPolling = startVisiblePolling(refreshUsageStats, 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [usageRange]); @@ -463,11 +462,10 @@ function App() { } }); }; - refreshProviderAccounts(); - const timer = window.setInterval(refreshProviderAccounts, 30000); + const stopPolling = startVisiblePolling(refreshProviderAccounts, 30000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [draftConfig.Providers]); @@ -538,11 +536,11 @@ function App() { }); }; + const stopPolling = startVisiblePolling(() => refreshAgentAnalysis(), 5000, { immediate: false }); refreshAgentAnalysis(true); - const timer = window.setInterval(() => refreshAgentAnalysis(), 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, agentAnalysisEnabled, agentAnalysisFilterKey]); @@ -587,11 +585,11 @@ function App() { }); }; + const stopPolling = startVisiblePolling(() => refreshRequestLogs(), 5000, { immediate: false }); refreshRequestLogs(true); - const timer = window.setInterval(() => refreshRequestLogs(), 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, requestLogsEnabled, requestLogFilterKey]); @@ -612,15 +610,14 @@ function App() { } }); }; - refreshNetworkCaptures(); - const timer = window.setInterval(refreshNetworkCaptures, 1500); + const stopPolling = startVisiblePolling(refreshNetworkCaptures, 1500); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, draftConfig.proxy.captureNetwork]); - const dirty = useMemo(() => formatJson(savedConfig) !== formatJson(draftConfig), [draftConfig, savedConfig]); + const dirty = draftConfig !== savedConfig; const apiKeys = useMemo(() => createApiKeyList(draftConfig), [draftConfig.APIKEY, draftConfig.APIKEYS]); const apiKeyEditItem = apiKeyEditIndex === undefined ? undefined : apiKeys.find((apiKey) => apiKey.index === apiKeyEditIndex); const providerDeleteItem = providerDeleteIndex === undefined ? undefined : draftConfig.Providers[providerDeleteIndex]; diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index 35ee0da..da6e060 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -284,6 +284,9 @@ export function LogsView({ }) { const t = useAppText(); const [expandedId, setExpandedId] = useState(); + const [detailById, setDetailById] = useState>({}); + const [detailErrorById, setDetailErrorById] = useState>({}); + const [detailLoadingId, setDetailLoadingId] = useState(); const firstItem = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1; const lastItem = Math.min(page.total, page.page * page.pageSize); const hasAnyCredentialInfo = Boolean(filter.credential) || @@ -292,9 +295,36 @@ export function LogsView({ const logTableGridClass = hasAnyCredentialInfo ? "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.78fr)_minmax(120px,0.42fr)_minmax(0,0.68fr)_82px]" : "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.9fr)_minmax(0,0.74fr)_82px]"; + const loadLogDetail = useCallback((id: number) => { + if (detailById[id] || detailLoadingId === id || !window.ccr?.getRequestLogDetail) { + return; + } + setDetailLoadingId(id); + setDetailErrorById((current) => ({ ...current, [id]: "" })); + void window.ccr.getRequestLogDetail({ id }) + .then((detail) => { + if (detail) { + setDetailById((current) => ({ ...current, [id]: detail })); + return; + } + setDetailErrorById((current) => ({ ...current, [id]: t("Request log not found.") })); + }) + .catch((error) => { + setDetailErrorById((current) => ({ ...current, [id]: error instanceof Error ? error.message : String(error) })); + }) + .finally(() => { + setDetailLoadingId((current) => current === id ? undefined : current); + }); + }, [detailById, detailLoadingId, t]); const toggleExpandedLog = useCallback((id: number) => { - setExpandedId((current) => current === id ? undefined : id); - }, []); + setExpandedId((current) => { + const next = current === id ? undefined : id; + if (next !== undefined) { + loadLogDetail(next); + } + return next; + }); + }, [loadLogDetail]); useEffect(() => { if (!expandedId || page.items.some((item) => item.id === expandedId)) { @@ -420,10 +450,12 @@ export function LogsView({ {page.items.map((item, index) => ( {tokenSummary}
{formatDuration(item.durationMs)}
- {expanded ? : null} + {expanded ? : null}
); }); -function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) { +function LogExpandedDetails({ + detailError, + detailLoading, + entry +}: { + detailError?: string; + detailLoading?: boolean; + entry: RequestLogEntry; +}) { const t = useAppText(); const hasCredentialInfo = logHasCredentialInfo(entry); @@ -529,6 +573,11 @@ function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) {
{entry.retryAttempts.length > 0 ? : null} + {detailLoading || detailError ? ( +
+ {detailError || t("Loading full payload...")} +
+ ) : null}
Promise | unknown; + +type PollingOptions = { + immediate?: boolean; +}; + +export function startVisiblePolling(refresh: PollingRefresh, intervalMs: number, options: PollingOptions = {}): () => void { + let cancelled = false; + let inFlight = false; + let pending = false; + + const run = () => { + if (cancelled || document.hidden) { + pending = true; + return; + } + if (inFlight) { + pending = true; + return; + } + + pending = false; + inFlight = true; + void Promise.resolve(refresh()) + .catch(() => { + // Callers own user-facing error state. + }) + .finally(() => { + inFlight = false; + if (pending) { + run(); + } + }); + }; + + const onVisibilityChange = () => { + if (!document.hidden) { + run(); + } + }; + + if (options.immediate !== false) { + run(); + } + const timer = window.setInterval(run, intervalMs); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + cancelled = true; + window.clearInterval(timer); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; +} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 4935b74..3ac4e52 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -57,6 +57,8 @@ import type { ProxyCertificateStatus, ProxyNetworkSnapshot, ProxyStatus, + RequestLogDetailRequest, + RequestLogEntry, RequestLogListFilter, RequestLogPage, UsageStatsFilter, @@ -95,6 +97,7 @@ declare global { getProxyCertificateStatus: () => Promise; getProxyNetworkCaptures: () => Promise; getProxyStatus: () => Promise; + getRequestLogDetail: (request: RequestLogDetailRequest) => Promise; getRequestLogs: (filter?: RequestLogListFilter) => Promise; getUpdateStatus: () => Promise; getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise; diff --git a/src/server/gateway/model-catalog.ts b/src/server/gateway/model-catalog.ts index a176913..5220e95 100644 --- a/src/server/gateway/model-catalog.ts +++ b/src/server/gateway/model-catalog.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve as pathResolve } from "node:path"; +import { loadModelCatalogPayload } from "../../main/model-catalog-file"; const claudeCodeDefaultContextTokens = 200_000; let modelCatalogIndex: ModelCatalogIndex | undefined; @@ -94,17 +93,14 @@ function loadModelCatalogIndex(): ModelCatalogIndex { return modelCatalogIndex; } - for (const candidate of modelCatalogPathCandidates()) { - if (!existsSync(candidate)) { - continue; - } - try { - const parsed = JSON.parse(readFileSync(candidate, "utf8")) as unknown; - modelCatalogIndex = buildModelCatalogIndex(parsed, candidate); + try { + const loaded = loadModelCatalogPayload(); + if (loaded) { + modelCatalogIndex = buildModelCatalogIndex(loaded.payload, loaded.loadedFrom); return modelCatalogIndex; - } catch (error) { - console.warn(`Failed to load model catalog from ${candidate}:`, error); } + } catch (error) { + console.warn("Failed to load model catalog:", error); } modelCatalogIndex = { @@ -114,17 +110,6 @@ function loadModelCatalogIndex(): ModelCatalogIndex { return modelCatalogIndex; } -function modelCatalogPathCandidates(): string[] { - return uniqueStrings([ - process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", - process.env.CCR_MODELS_JSON_PATH?.trim() || "", - pathResolve(process.cwd(), "models.json"), - pathResolve(__dirname, "..", "models.json"), - pathResolve(__dirname, "..", "assets", "models.json"), - pathResolve(__dirname, "..", "..", "..", "models.json") - ]); -} - function buildModelCatalogIndex(payload: unknown, loadedFrom: string): ModelCatalogIndex { const byKey = new Map(); const byModelKey = new Map(); diff --git a/src/shared/app.ts b/src/shared/app.ts index 5d2ea3a..2e83828 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -1436,6 +1436,10 @@ export type RequestLogListFilter = { status?: RequestLogStatusFilter; }; +export type RequestLogDetailRequest = { + id: number; +}; + export type RequestLogBody = ProxyNetworkBody; export type RequestLogRetryAttempt = { diff --git a/src/shared/default-config.ts b/src/shared/default-config.ts new file mode 100644 index 0000000..1e01a9b --- /dev/null +++ b/src/shared/default-config.ts @@ -0,0 +1,166 @@ +import { + CLAUDE_CODE_DEFAULT_ENV, + DEFAULT_OVERVIEW_WIDGETS, + DEFAULT_TRAY_COMPONENT_VARIANTS, + DEFAULT_TRAY_WIDGETS, + DEFAULT_TRAY_WINDOW_MODULES, + type AppConfig, + type ProxyRouteTarget +} from "./app"; + +export const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [ + { host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] }, + { host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] }, + { host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] }, + { host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] }, + { host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] }, + { host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] } +]; + +export type DefaultAppConfigOptions = { + coreHost?: string; + generatedConfigFile: string; +}; + +export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppConfig { + const coreHost = options.coreHost ?? "127.0.0.1"; + return { + APIKEY: "", + APIKEYS: [], + API_TIMEOUT_MS: 600000, + CUSTOM_ROUTER_PATH: "", + HOST: "127.0.0.1", + PORT: 3456, + Providers: [], + Router: { + fallback: { + mode: "off", + models: [], + retryCount: 1 + }, + longContextThreshold: 200000, + rules: [] + }, + agent: { + mcpServers: [] + }, + autoStart: false, + botConfigs: [], + botGateway: { + acknowledgeEvents: false, + args: [], + authType: "", + autoStartIntegration: true, + command: "", + createIntegration: false, + credentials: {}, + cwd: "", + enabled: false, + forwardAllAgentMessages: true, + handoff: { + enabled: false, + idleSeconds: 30, + phoneBluetoothTargets: [], + phoneWifiTargets: [], + screenLock: true, + userIdle: true + }, + integrationConfig: {}, + integrationId: "", + platform: "none", + pollIntervalMs: 2000, + requestTimeoutMs: 600000, + sourceDir: "", + startupTimeoutMs: 10000, + stateDir: "", + tenantId: "ccr" + }, + gateway: { + coreHost, + corePort: 3457, + enabled: true, + generatedConfigFile: options.generatedConfigFile, + host: "127.0.0.1", + port: 3456 + }, + observability: { + agentAnalysis: false, + requestLogs: false + }, + preferredProvider: "", + plugins: [], + profile: { + claudeCode: { + enabled: true, + model: "", + settingsFile: "~/.claude/settings.json", + smallFastModel: "" + }, + codex: { + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFormat: "separate_profile_files", + configFile: "~/.codex/config.toml", + enabled: true, + model: "", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: false + }, + enabled: true, + profiles: [ + { + agent: "claude-code", + enabled: true, + env: { ...CLAUDE_CODE_DEFAULT_ENV }, + id: "default-claude-code", + model: "", + name: "Claude Code", + scope: "global", + settingsFile: "~/.claude/settings.json", + smallFastModel: "", + surface: "auto" + }, + { + agent: "codex", + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFormat: "separate_profile_files", + configFile: "~/.codex/config.toml", + enabled: true, + env: {}, + id: "default-codex", + model: "", + name: "Codex", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: false, + scope: "global", + surface: "auto" + } + ] + }, + proxy: { + browserMode: true, + captureNetwork: false, + enabled: false, + host: "127.0.0.1", + mode: "gateway", + port: 7890, + systemProxy: false, + targets: DEFAULT_PROXY_TARGETS + }, + providerPlugins: [], + overviewWidgets: DEFAULT_OVERVIEW_WIDGETS, + routerEndpoint: "http://127.0.0.1:3456", + theme: "system", + trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS, + trayIcon: "random", + trayProgressTargetTokens: 100000, + trayWidgets: DEFAULT_TRAY_WIDGETS, + trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES, + virtualModelProfiles: [] + }; +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index f88003a..1bfe296 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -19,6 +19,7 @@ export const IPC_CHANNELS = { appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status", appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures", appGetProxyStatus: "ccr:app:get-proxy-status", + appGetRequestLogDetail: "ccr:app:get-request-log-detail", appGetRequestLogs: "ccr:app:get-request-logs", appGetUpdateStatus: "ccr:app:get-update-status", appGetUsageStats: "ccr:app:get-usage-stats", diff --git a/tests/bot-gateway-env.test.mjs b/tests/bot-gateway-env.test.mjs new file mode 100644 index 0000000..ac3e384 --- /dev/null +++ b/tests/bot-gateway-env.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { botGatewayProfileEnv } from "../src/main/bot-gateway-env.ts"; + +function botGateway(overrides = {}) { + return { + acknowledgeEvents: true, + args: ["--stdio"], + authType: "token", + autoStartIntegration: true, + command: "bot-gateway", + createIntegration: true, + credentials: { sendMode: "legacy", token: "secret", webhookUrl: "https://old.example.com" }, + cwd: "/tmp", + enabled: true, + forwardAllAgentMessages: true, + handoff: { + enabled: true, + idleSeconds: 45, + phoneBluetoothTargets: ["bt-phone"], + phoneWifiTargets: ["wifi-phone"], + screenLock: false, + userIdle: true + }, + integrationConfig: { sendMode: "webhook", team: "T1", transport: "http" }, + integrationId: "integration-1", + platform: "slack", + pollIntervalMs: 2500, + requestTimeoutMs: 600000, + sourceDir: "", + startupTimeoutMs: 15000, + stateDir: "", + tenantId: "tenant-1", + ...overrides + }; +} + +const profile = { + agent: "codex", + botConfigId: "saved-bot", + enabled: true, + id: "codex-main", + model: "provider,model", + name: "Codex Main", + surface: "app" +}; + +test("botGatewayProfileEnv disables bot gateway outside app surface", () => { + const env = botGatewayProfileEnv({ botConfigs: [], botGateway: botGateway() }, profile, "cli"); + + assert.deepEqual(env, { + CCR_BOT_GATEWAY_ENABLED: "false", + CODEXL_BOT_GATEWAY_ENABLED: "false" + }); +}); + +test("botGatewayProfileEnv merges saved config and normalizes websocket integration", () => { + const env = botGatewayProfileEnv( + { + botConfigs: [ + { + botGateway: botGateway({ + authType: "appsecret", + credentials: { appId: "app-1", webhookSecret: "drop-me" }, + integrationConfig: { appId: "app-1", transport: "http" }, + platform: "lark", + stateDir: "~/bot-state" + }), + id: "saved-bot", + name: "Saved Bot" + } + ], + botGateway: botGateway({ enabled: false, platform: "none" }) + }, + profile, + "app" + ); + + assert.equal(env.CCR_BOT_GATEWAY_ENABLED, "true"); + assert.equal(env.CCR_BOT_GATEWAY_PLATFORM, "feishu"); + assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "app_secret"); + assert.equal(env.CCR_BOT_GATEWAY_CREATE_INTEGRATION, "true"); + assert.equal(env.CCR_BOT_GATEWAY_STATE_DIR, `${process.env.HOME}/bot-state`); + assert.equal(env.CCR_BOT_PROFILE_ID, "codex-main"); + assert.equal(env.CCR_BOT_PROFILE_NAME, "Codex Main"); + + const credentials = JSON.parse(env.CCR_BOT_GATEWAY_CREDENTIALS_JSON); + assert.deepEqual(credentials, { appId: "app-1", token: "secret" }); + + const integrationConfig = JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON); + assert.deepEqual(integrationConfig, { appId: "app-1", team: "T1", transport: "websocket" }); +}); + +test("botGatewayProfileEnv disables create integration for QR login platforms", () => { + const env = botGatewayProfileEnv( + { + botConfigs: [], + botGateway: botGateway({ + authType: "qr", + platform: "weixin" + }) + }, + { ...profile, botConfigId: undefined }, + "app" + ); + + assert.equal(env.CCR_BOT_GATEWAY_PLATFORM, "weixin-ilink"); + assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "qr_login"); + assert.equal(env.CCR_BOT_GATEWAY_CREATE_INTEGRATION, "false"); + assert.equal(JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON).transport, "websocket"); +}); diff --git a/tests/deep-link.test.mjs b/tests/deep-link.test.mjs new file mode 100644 index 0000000..2a9c63b --- /dev/null +++ b/tests/deep-link.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createProviderDeepLinkRequest, + isAppDeepLinkUrl, + parseProviderDeepLinkPayload, + parseProviderManifestDeepLinkPayload, + parseProviderManifestPayload +} from "../src/shared/deep-link.ts"; + +function base64UrlJson(value) { + return Buffer.from(JSON.stringify(value), "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +test("parseProviderDeepLinkPayload reads payload JSON, models, display names, and usage account mapping", () => { + const payload = { + account: { + connectors: { + auth: "provider-api-key", + endpoint: "https://usage.example.com/balance", + mapping: { meters: [{ id: "balance", kind: "balance", remaining: "$.balance" }] }, + type: "http-json" + }, + enabled: true, + refreshIntervalMs: 60000 + }, + api_key: "sk-test", + base_url: "https://api.example.com/v1", + fetch_usage: true, + model_display_names: { + "model-a": "Model A" + }, + models: [ + { displayName: "Model B", id: "model-b" }, + "model-a,model-c" + ], + name: "Example AI", + protocol: "openai_chat_completions", + source: "https://example.com/install" + }; + + const parsed = parseProviderDeepLinkPayload(`ccr://provider?payload=${base64UrlJson(payload)}`); + + assert.equal(parsed.name, "Example AI"); + assert.equal(parsed.baseUrl, "https://api.example.com/v1"); + assert.equal(parsed.apiKey, "sk-test"); + assert.equal(parsed.protocol, "openai_chat_completions"); + assert.deepEqual(parsed.models, ["model-b", "model-a", "model-c"]); + assert.deepEqual(parsed.modelDisplayNames, { + "model-a": "Model A", + "model-b": "Model B" + }); + assert.equal(parsed.account?.enabled, true); + assert.equal(parsed.account?.refreshIntervalMs, 60000); + assert.equal(parsed.account?.connectors?.[0]?.type, "http-json"); +}); + +test("parseProviderDeepLinkPayload builds usage account config from query params", () => { + const usageHeaders = encodeURIComponent(JSON.stringify({ "x-usage": "yes", ignored: 123 })); + const parsed = parseProviderDeepLinkPayload( + [ + "ccr://provider?name=Query%20AI", + "base_url=https%3A%2F%2Fapi.example.com%2Fv1", + "models=model-a%2Cmodel-b", + "models=model-b%0Amodel-c", + "fetch_usage=true", + "usage_url=https%3A%2F%2Fusage.example.com%2Fme", + "usage_method=post", + `usage_headers=${usageHeaders}`, + "balance=%24.balance.remaining", + "balance_unit=CNY", + "subscription=%24.quota.remaining", + "subscription_limit=%24.quota.limit" + ].join("&") + ); + + assert.deepEqual(parsed.models, ["model-a", "model-b", "model-c"]); + const connector = parsed.account?.connectors?.[0]; + assert.equal(connector?.type, "http-json"); + assert.equal(connector?.method, "POST"); + assert.deepEqual(connector?.headers, { "x-usage": "yes" }); + assert.equal(connector?.mapping.meters.length, 2); + assert.equal(connector?.mapping.meters[0].unit, "CNY"); +}); + +test("provider deeplink manifest parsing accepts only HTTPS manifest URLs", () => { + assert.equal(isAppDeepLinkUrl(" ccr://provider?base_url=https://api.example.com "), true); + assert.deepEqual( + parseProviderManifestDeepLinkPayload("ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr.json"), + { url: "https://example.com/ccr.json" } + ); + assert.throws( + () => parseProviderManifestDeepLinkPayload("ccr://provider?manifest=http%3A%2F%2Fexample.com%2Fccr.json"), + /must use https/ + ); +}); + +test("createProviderDeepLinkRequest captures parsing errors without throwing", () => { + const request = createProviderDeepLinkRequest("https://example.com/not-ccr", new Date("2026-06-30T00:00:00.000Z")); + + assert.equal(request.rawUrl, "https://example.com/not-ccr"); + assert.equal(request.receivedAt, "2026-06-30T00:00:00.000Z"); + assert.match(request.error ?? "", /Unsupported link protocol/); +}); + +test("parseProviderManifestPayload accepts provider wrappers and source fallback", () => { + const parsed = parseProviderManifestPayload( + { + provider: { + base_url: "https://api.example.com/v1", + models: [{ display_name: "Display Model", id: "display-model" }], + name: "Manifest AI" + } + }, + "https://example.com/manifest.json" + ); + + assert.equal(parsed.name, "Manifest AI"); + assert.equal(parsed.source, "https://example.com/manifest.json"); + assert.deepEqual(parsed.models, ["display-model"]); + assert.deepEqual(parsed.modelDisplayNames, { "display-model": "Display Model" }); +}); diff --git a/tests/model-catalog-file.test.mjs b/tests/model-catalog-file.test.mjs new file mode 100644 index 0000000..8a93289 --- /dev/null +++ b/tests/model-catalog-file.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../src/main/model-catalog-file.ts"; + +test("modelCatalogPathCandidates prefers env paths and removes duplicates", () => { + const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH; + const previousModelsPath = process.env.CCR_MODELS_JSON_PATH; + try { + process.env.CCR_MODEL_CATALOG_PATH = "/tmp/ccr-models.json"; + process.env.CCR_MODELS_JSON_PATH = "/tmp/ccr-models.json"; + + const candidates = modelCatalogPathCandidates(); + + assert.equal(candidates[0], "/tmp/ccr-models.json"); + assert.equal(candidates.filter((candidate) => candidate === "/tmp/ccr-models.json").length, 1); + assert.ok(candidates.some((candidate) => candidate.endsWith("models.json"))); + } finally { + if (previousCatalogPath === undefined) { + delete process.env.CCR_MODEL_CATALOG_PATH; + } else { + process.env.CCR_MODEL_CATALOG_PATH = previousCatalogPath; + } + if (previousModelsPath === undefined) { + delete process.env.CCR_MODELS_JSON_PATH; + } else { + process.env.CCR_MODELS_JSON_PATH = previousModelsPath; + } + } +}); + +test("loadModelCatalogPayload reads the first configured existing catalog", () => { + const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH; + const previousModelsPath = process.env.CCR_MODELS_JSON_PATH; + const dir = mkdtempSync(path.join(tmpdir(), "ccr-model-catalog-test-")); + try { + const catalogFile = path.join(dir, "models.json"); + writeFileSync(catalogFile, JSON.stringify({ models: [{ id: "test-model" }] }), "utf8"); + process.env.CCR_MODEL_CATALOG_PATH = path.join(dir, "missing.json"); + process.env.CCR_MODELS_JSON_PATH = catalogFile; + + const loaded = loadModelCatalogPayload(); + + assert.equal(loaded?.loadedFrom, catalogFile); + assert.deepEqual(loaded?.payload, { models: [{ id: "test-model" }] }); + } finally { + rmSync(dir, { force: true, recursive: true }); + if (previousCatalogPath === undefined) { + delete process.env.CCR_MODEL_CATALOG_PATH; + } else { + process.env.CCR_MODEL_CATALOG_PATH = previousCatalogPath; + } + if (previousModelsPath === undefined) { + delete process.env.CCR_MODELS_JSON_PATH; + } else { + process.env.CCR_MODELS_JSON_PATH = previousModelsPath; + } + } +}); diff --git a/tests/profile-launch-core.test.mjs b/tests/profile-launch-core.test.mjs new file mode 100644 index 0000000..2659aa4 --- /dev/null +++ b/tests/profile-launch-core.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { + buildProfileLaunchPlan, + ccrManagedProfileDir, + findProfileForOpen, + profileOpenCommand, + profileOpenSurfaces, + resolveClaudeCodeSettingsFile, + resolveCodexConfigFile, + resolveProfileOpenSurface +} from "../src/main/profile-launch-core.ts"; + +const claudeProfile = { + agent: "claude-code", + enabled: true, + id: "claude-main", + model: "provider,model", + name: "Claude Main", + scope: "ccr", + surface: "auto" +}; + +const codexProfile = { + agent: "codex", + enabled: true, + id: "codex-main", + model: "provider,model", + name: "Codex Main", + providerId: "openai-codex", + scope: "ccr", + surface: "auto" +}; + +test("findProfileForOpen resolves enabled profiles and reports ambiguous names", () => { + const config = { + profile: { + profiles: [ + claudeProfile, + { ...claudeProfile, enabled: false, id: "disabled", name: "Disabled" }, + { ...codexProfile, id: "duplicate-a", name: "Duplicate Name" }, + { ...codexProfile, id: "duplicate-b", name: "duplicate name" } + ] + } + }; + + assert.equal(findProfileForOpen(config, "claude-main").id, "claude-main"); + assert.equal(findProfileForOpen(config, "claude main").id, "claude-main"); + assert.throws(() => findProfileForOpen(config, "duplicate name"), /ambiguous/); + assert.throws(() => findProfileForOpen(config, "Disabled"), /not found or is disabled/); +}); + +test("profile open surfaces enforce agent capabilities", () => { + assert.deepEqual(profileOpenSurfaces(claudeProfile), ["cli", "app"]); + assert.deepEqual(profileOpenSurfaces({ ...claudeProfile, surface: "cli" }), ["cli"]); + assert.deepEqual(profileOpenSurfaces({ ...codexProfile, agent: "zcode" }), ["app"]); + assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app"); + assert.throws(() => resolveProfileOpenSurface({ ...claudeProfile, surface: "cli" }, "app"), /does not support APP/); +}); + +test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { + const configDir = path.join(path.sep, "tmp", "ccr-config"); + const codexPlan = buildProfileLaunchPlan(configDir, codexProfile, "app"); + const claudePlan = buildProfileLaunchPlan(configDir, claudeProfile, "cli", ["--debug"]); + + assert.equal(codexPlan.surface, "app"); + assert.deepEqual(codexPlan.args, ["app"]); + assert.equal(path.basename(codexPlan.command), process.platform === "win32" ? "ccr-codex-cli-stdio-codex-main.cmd" : "ccr-codex-cli-stdio-codex-main"); + assert.equal(codexPlan.env.CCR_PROFILE_SURFACE, "app"); + + assert.equal(claudePlan.surface, "cli"); + assert.deepEqual(claudePlan.args, ["--debug"]); + assert.equal(path.basename(claudePlan.command), process.platform === "win32" ? "ccr-claude-code-wrapper-claude-main.cmd" : "ccr-claude-code-wrapper-claude-main"); + assert.equal(claudePlan.env.CCR_PROFILE_SURFACE, "cli"); + assert.match(claudePlan.env.CLAUDE_CONFIG_DIR, /claude$/); + + assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/); +}); + +test("profile config paths honor CCR, custom, and global scopes", () => { + const configDir = path.join(path.sep, "tmp", "ccr-config"); + const customProfile = { ...codexProfile, id: "Custom Profile", scope: "custom" }; + const globalCodex = { ...codexProfile, codexHome: "~/codex-home", scope: "global" }; + + assert.equal( + ccrManagedProfileDir(configDir, customProfile), + path.join(configDir, "profiles", "custom-profile", "custom") + ); + assert.equal( + resolveClaudeCodeSettingsFile(configDir, claudeProfile), + path.join(configDir, "profiles", "claude-main", "claude", "settings.json") + ); + assert.equal( + resolveCodexConfigFile(configDir, customProfile), + path.join(configDir, "profiles", "custom-profile", "custom", "codex", "config.toml") + ); + assert.equal(resolveCodexConfigFile(configDir, globalCodex), path.join(process.env.HOME, "codex-home", "config.toml")); +}); + +test("profileOpenCommand quotes profile references for shell usage", () => { + assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Claude/); + assert.match(profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"), /Main/); +}); diff --git a/tests/provider-preset-utils.test.mjs b/tests/provider-preset-utils.test.mjs new file mode 100644 index 0000000..7ee4b50 --- /dev/null +++ b/tests/provider-preset-utils.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + findProviderPresetByBaseUrlInList, + findProviderPresetByIdentityInList, + providerApiKeySafetyIssueInList, + providerEndpointCanReceiveProviderApiKeyInList, + providerIdentitySafetyIssueInList, + providerPresetMatchesBaseUrl +} from "../src/shared/provider-preset-utils.ts"; + +const openAiPreset = { + aliases: ["OpenAI", "ChatGPT"], + endpoints: [{ baseUrl: "https://api.openai.com/v1", protocols: ["openai_chat_completions"] }], + id: "openai", + name: "OpenAI", + officialApiKeyPatterns: [{ source: "^sk-openai-" }] +}; + +const anthropicPreset = { + aliases: ["Claude"], + endpoints: [{ baseUrl: "https://api.anthropic.com", protocols: ["anthropic_messages"] }], + id: "anthropic", + name: "Anthropic", + officialApiKeyPatterns: [{ source: "^sk-ant-" }] +}; + +const presets = [openAiPreset, anthropicPreset]; + +test("provider preset matching accepts endpoint subpaths but rejects different hosts", () => { + assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://api.openai.com/v1/chat/completions"), true); + assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://api.openai.com"), true); + assert.equal(providerPresetMatchesBaseUrl(openAiPreset, "https://proxy.example.com/v1"), false); + assert.equal(findProviderPresetByBaseUrlInList(presets, "api.anthropic.com/v1/messages")?.id, "anthropic"); +}); + +test("provider identity lookup normalizes aliases and punctuation", () => { + assert.equal(findProviderPresetByIdentityInList(presets, "my ChatGPT gateway")?.id, "openai"); + assert.equal(findProviderPresetByIdentityInList(presets, "Claude Provider")?.id, "anthropic"); +}); + +test("provider identity safety allows loopback but warns on branded third-party endpoints", () => { + assert.equal( + providerIdentitySafetyIssueInList(presets, { + baseUrl: "http://127.0.0.1:3456/v1", + name: "OpenAI local test" + }), + undefined + ); + assert.match( + providerIdentitySafetyIssueInList(presets, { + baseUrl: "https://proxy.example.com/v1", + name: "OpenAI proxy" + })?.message ?? "", + /Provider identity looks like OpenAI/ + ); +}); + +test("provider API key safety blocks official-looking keys on untrusted endpoints", () => { + assert.match( + providerApiKeySafetyIssueInList(presets, { + apiKey: "sk-openai-test", + baseUrl: "https://proxy.example.com/v1", + name: "neutral proxy" + })?.message ?? "", + /official OpenAI key/ + ); + assert.equal( + providerApiKeySafetyIssueInList(presets, { + apiKey: "sk-openai-test", + baseUrl: "https://api.openai.com/v1" + }), + undefined + ); + assert.match( + providerEndpointCanReceiveProviderApiKeyInList(presets, { + apiKey: "sk-ant-test", + endpoint: "https://proxy.example.com/anthropic", + providerName: "Anthropic" + })?.message ?? "", + /official Anthropic key/ + ); +}); diff --git a/tests/provider-url.test.mjs b/tests/provider-url.test.mjs new file mode 100644 index 0000000..b776876 --- /dev/null +++ b/tests/provider-url.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeProviderBaseUrl, + parseProviderBaseUrl, + providerBaseUrlForProtocol, + providerUrlWithDefaultScheme +} from "../src/shared/provider-url.ts"; + +test("provider URL parsing strips endpoint paths and unsafe URL parts", () => { + const parsed = parseProviderBaseUrl("https://user:secret@api.example.com/v1/chat/completions?token=secret#section"); + + assert.equal(parsed.normalizedInputBaseUrl, "https://api.example.com/v1"); + assert.equal(parsed.rootBaseUrl, "https://api.example.com"); + assert.equal(providerBaseUrlForProtocol(parsed, "openai_chat_completions"), "https://api.example.com/v1"); + assert.equal(providerBaseUrlForProtocol(parsed, "openai_responses"), "https://api.example.com/v1"); + assert.equal(providerBaseUrlForProtocol(parsed, "anthropic_messages"), "https://api.example.com"); + assert.equal(providerBaseUrlForProtocol(parsed, "gemini_generate_content"), "https://api.example.com"); +}); + +test("provider URL parsing handles local and Gemini endpoint variants", () => { + const parsed = parseProviderBaseUrl("localhost:8787/v1beta/models/gemini-2.5-pro:generateContent"); + + assert.equal(parsed.normalizedInputBaseUrl, "http://localhost:8787/v1beta"); + assert.equal(parsed.rootBaseUrl, "http://localhost:8787"); + assert.equal(parsed.geminiBaseUrl, "http://localhost:8787"); +}); + +test("provider URL normalization chooses protocol-specific bases", () => { + assert.equal(providerUrlWithDefaultScheme("127.0.0.1:3456/v1"), "http://127.0.0.1:3456/v1"); + assert.equal(providerUrlWithDefaultScheme("api.example.com/v1"), "https://api.example.com/v1"); + assert.equal( + normalizeProviderBaseUrl("api.example.com/v1/messages", "anthropic_messages"), + "https://api.example.com" + ); + assert.equal( + normalizeProviderBaseUrl("api.example.com/v1/chat/completions", "openai_chat_completions"), + "https://api.example.com/v1" + ); +}); diff --git a/tests/request-log-store.test.mjs b/tests/request-log-store.test.mjs new file mode 100644 index 0000000..30a9cb1 --- /dev/null +++ b/tests/request-log-store.test.mjs @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { RequestLogStore } from "../src/main/request-log-store.ts"; + +test("RequestLogStore keeps list rows lightweight and detail rows complete", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const body = JSON.stringify({ messages: [{ content: "hello", role: "user" }], model: "request-model" }); + const response = JSON.stringify({ + model: "response-model", + usage: { + input_tokens: 3, + output_tokens: 4, + total_tokens: 7 + } + }); + + await store.record({ + completedAt: new Date().toISOString(), + durationMs: 42, + method: "POST", + path: "/v1/messages", + providerName: "test-provider", + requestBody: Buffer.from(body, "utf8"), + requestHeaders: { "content-type": "application/json" }, + requestId: "request-log-test", + responseBodyText: response, + responseHeaders: { "content-type": "application/json" }, + startedAt: new Date().toISOString(), + statusCode: 200, + url: "http://127.0.0.1:3456/v1/messages" + }); + + const page = await store.list({ pageSize: 25 }); + assert.equal(page.items.length, 1); + assert.equal(page.items[0].requestBody.text, ""); + assert.equal(page.items[0].responseBody?.text, ""); + assert.equal(page.items[0].requestBody.sizeBytes, Buffer.byteLength(body)); + assert.equal(page.items[0].responseBody?.sizeBytes, Buffer.byteLength(response)); + + const detail = await store.getDetail({ id: page.items[0].id }); + assert.ok(detail); + assert.match(detail.requestBody.text, /request-model/); + assert.match(detail.responseBody?.text ?? "", /response-model/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("RequestLogStore redacts secrets and records CCR metadata", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-metadata-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const startedAt = new Date().toISOString(); + + await store.record({ + completedAt: startedAt, + durationMs: 75, + method: "POST", + path: "/v1/chat/completions", + providerName: "openai-like", + providerProtocol: "openai_chat_completions", + requestBody: Buffer.from(JSON.stringify({ model: "gpt-test", stream: true }), "utf8"), + requestHeaders: { + accept: "text/event-stream", + authorization: "Bearer request-secret", + cookie: "session=request-secret", + "content-type": "application/json", + "x-ccr-provider-credential-chain": "cred-a, cred-b", + "x-ccr-provider-credential-id": "cred-a" + }, + requestId: "request-log-metadata-test", + responseBodyText: "", + responseHeaders: { + "content-type": "text/event-stream", + "x-api-key": "response-secret", + "x-ccr-provider-credential-saturated": "true", + "x-gateway-billing-cache-read-tokens": "10", + "x-gateway-billing-input-tokens": "100", + "x-gateway-billing-output-tokens": "20", + "x-gateway-billing-total-tokens": "130" + }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/chat/completions" + }); + + const page = await store.list({ pageSize: 25 }); + const detail = await store.getDetail({ id: page.items[0].id }); + + assert.ok(detail); + assert.equal(detail.requestHeaders.authorization, "[redacted]"); + assert.equal(detail.requestHeaders.cookie, "[redacted]"); + assert.equal(detail.responseHeaders["x-api-key"], "[redacted]"); + assert.equal(detail.credentialId, "cred-a"); + assert.deepEqual(detail.credentialChain, ["cred-a", "cred-b"]); + assert.equal(detail.credentialSaturated, true); + assert.equal(detail.isStream, true); + assert.equal(detail.inputTokens, 90); + assert.equal(detail.cacheReadTokens, 10); + assert.equal(detail.outputTokens, 20); + assert.equal(detail.totalTokens, 130); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("RequestLogStore applies raw trace updates to existing request logs", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-raw-trace-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const startedAt = new Date().toISOString(); + const errorStream = [ + "event: error", + 'data: {"error":{"type":"rate_limit_error","message":"quota exceeded"}}', + "" + ].join("\n"); + + await store.record({ + completedAt: startedAt, + durationMs: 20, + method: "POST", + path: "/v1/messages", + providerName: "before-provider", + requestBody: Buffer.from(JSON.stringify({ model: "before-model" }), "utf8"), + requestHeaders: { "content-type": "application/json" }, + requestId: "raw-trace-request", + responseBodyText: "{}", + responseHeaders: { "content-type": "application/json" }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/messages" + }); + + const applied = await store.updateFromRawTrace({ + isStream: true, + model: "trace-model", + provider: "trace-provider", + requestHeaders: { "x-client-name": "codex-cli" }, + requestId: "raw-trace-request", + responseBodyContentType: "text/event-stream", + responseBodyText: errorStream, + responseHeaders: { "content-type": "text/event-stream" }, + statusCode: 200 + }); + + assert.equal(applied, true); + const page = await store.list({ query: "quota exceeded" }); + assert.equal(page.items.length, 1); + const detail = await store.getDetail({ id: page.items[0].id }); + + assert.ok(detail); + assert.equal(detail.model, "trace-model"); + assert.equal(detail.provider, "trace-provider"); + assert.equal(detail.ok, false); + assert.equal(detail.isStream, true); + assert.match(detail.error, /rate_limit_error: quota exceeded/); + assert.match(detail.responseBody?.text ?? "", /quota exceeded/); + assert.equal(detail.requestHeaders["x-client-name"], "codex-cli"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("RequestLogStore analyzes agent sessions and exposes trace payloads", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-agent-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const startedAt = new Date(Date.now() - 1000).toISOString(); + const completedAt = new Date().toISOString(); + const requestBody = { + messages: [ + { content: "inspect repo", role: "user" }, + { + content: JSON.stringify({ ok: true, files: ["README.md"] }), + role: "tool", + tool_call_id: "call-read" + } + ], + model: "gpt-test", + session_id: "session-1" + }; + const responseBody = { + choices: [ + { + message: { + role: "assistant", + tool_calls: [ + { + function: { + arguments: JSON.stringify({ path: "README.md" }), + name: "read_file" + }, + id: "call-read", + type: "function" + } + ] + } + } + ], + model: "gpt-test", + usage: { + completion_tokens: 3, + prompt_tokens: 7, + total_tokens: 10 + } + }; + + await store.record({ + completedAt, + durationMs: 150, + method: "POST", + path: "/v1/chat/completions", + providerName: "test-provider", + providerProtocol: "openai_chat_completions", + requestBody: Buffer.from(JSON.stringify(requestBody), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "openai-codex test", + "x-ccr-route-reason": "default", + "x-codex-session-id": "session-1" + }, + requestId: "agent-request-1", + responseBodyText: JSON.stringify(responseBody), + responseHeaders: { "content-type": "application/json" }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/chat/completions" + }); + + const page = await store.list({ pageSize: 25 }); + const detail = await store.getDetail({ id: page.items[0].id }); + assert.ok(detail); + + const analysis = await store.analyze({ range: "30d" }); + assert.equal(analysis.scannedRequestCount, 1); + assert.equal(analysis.totals.requestCount, 1); + assert.equal(analysis.agents[0]?.agent, "codex"); + assert.equal(analysis.sessions[0]?.id, "session-1"); + assert.equal(analysis.tools[0]?.name, "read_file"); + + const selected = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId: "session-1" + }); + assert.equal(selected.selectedSession?.trace.toolRunCount, 1); + assert.equal(selected.selectedSession?.trace.llmRunCount, 1); + assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true); + + const inputPayload = await store.getTracePayload({ + callId: "call-read", + part: "tool-input", + requestLogId: detail.id + }); + assert.equal(inputPayload.found, true); + assert.equal(inputPayload.kind, "json"); + assert.match(inputPayload.content, /README\.md/); + + const resultPayload = await store.getTracePayload({ + callId: "call-read", + part: "tool-result", + requestLogId: detail.id + }); + assert.equal(resultPayload.found, true); + assert.equal(resultPayload.kind, "json"); + assert.match(resultPayload.content, /files/); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); diff --git a/tests/usage-activity.test.mjs b/tests/usage-activity.test.mjs new file mode 100644 index 0000000..a3c0e2a --- /dev/null +++ b/tests/usage-activity.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildTokenActivity, activityDateKey } from "../src/renderer/lib/usage-activity.ts"; + +test("buildTokenActivity summarizes observed token days and streaks", () => { + const summary = buildTokenActivity( + [ + { bucket: "2026-06-02", totalTokens: 10 }, + { bucket: "2026-06-03", totalTokens: 30 }, + { bucket: "2026-06-04", totalTokens: 60 }, + { bucket: "2026-06-05", totalTokens: -20 }, + { bucket: "not-a-date", totalTokens: 999 } + ], + { minWeeks: 2 } + ); + + assert.equal(summary.totalTokens, 100); + assert.equal(summary.activeDays, 3); + assert.equal(summary.dayCount, 4); + assert.equal(summary.longestStreak, 3); + assert.equal(summary.maxTokens, 60); + assert.equal(summary.weekCount, 2); + assert.equal(summary.cells.length, 14); + + const cellsByDate = new Map(summary.cells.map((cell) => [cell.dateKey, cell])); + assert.equal(cellsByDate.get("2026-06-02")?.intensity, 1); + assert.equal(cellsByDate.get("2026-06-03")?.intensity, 3); + assert.equal(cellsByDate.get("2026-06-04")?.intensity, 4); + assert.equal(cellsByDate.get("2026-06-05")?.intensity, 0); +}); + +test("activityDateKey formats local calendar dates", () => { + assert.equal(activityDateKey(new Date(2026, 0, 5, 14, 30)), "2026-01-05"); +}); diff --git a/tests/usage-normalization.test.mjs b/tests/usage-normalization.test.mjs new file mode 100644 index 0000000..65dfc03 --- /dev/null +++ b/tests/usage-normalization.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeUsageInputTokens } from "../src/main/usage-normalization.ts"; + +test("normalizeUsageInputTokens subtracts cache tokens for OpenAI-compatible protocols", () => { + const usage = normalizeUsageInputTokens( + { + cacheReadTokens: 20, + cacheWriteTokens: 5, + inputTokens: 100, + outputTokens: 12 + }, + { providerProtocol: "openai_chat_completions" } + ); + + assert.deepEqual(usage, { + cacheReadTokens: 20, + cacheWriteTokens: 5, + inputTokens: 75, + outputTokens: 12 + }); +}); + +test("normalizeUsageInputTokens keeps Anthropic input tokens unchanged", () => { + const usage = normalizeUsageInputTokens( + { + cacheReadTokens: 20, + cacheWriteTokens: 5, + inputTokens: 100 + }, + { providerProtocol: "anthropic_messages" } + ); + + assert.deepEqual(usage, { + cacheReadTokens: 20, + cacheWriteTokens: 5, + inputTokens: 100 + }); +}); + +test("normalizeUsageInputTokens falls back to path and usage hints", () => { + assert.equal( + normalizeUsageInputTokens( + { cacheReadTokens: 8, inputTokens: 50 }, + { path: "/v1/responses" } + )?.inputTokens, + 42 + ); + assert.equal( + normalizeUsageInputTokens( + { cacheReadTokens: 8, inputTokens: 50 }, + { usageHint: { inputIncludesCacheTokens: false } } + )?.inputTokens, + 50 + ); +}); diff --git a/tests/usage-store.test.mjs b/tests/usage-store.test.mjs new file mode 100644 index 0000000..0cae4ff --- /dev/null +++ b/tests/usage-store.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { UsageStore } from "../src/main/usage-store.ts"; + +test("UsageStore aggregates stats in SQLite without loading all events", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-test-")); + try { + const store = new UsageStore(path.join(dir, "usage.sqlite")); + const now = new Date(); + const earlier = new Date(now.getTime() - 60_000); + + await store.record({ + createdAt: earlier.toISOString(), + durationMs: 120, + method: "POST", + model: "alpha-model", + path: "/v1/messages", + provider: "alpha", + requestId: "req-1", + statusCode: 200, + usage: { + cacheReadTokens: 2, + inputTokens: 10, + outputTokens: 5, + totalTokens: 17 + } + }); + await store.record({ + createdAt: now.toISOString(), + durationMs: 80, + method: "POST", + model: "beta-model", + path: "/v1/messages", + provider: "beta", + requestId: "req-2", + statusCode: 500, + usage: { + inputTokens: 4, + outputTokens: 6 + } + }); + + const stats = await store.getStats("30d", { includeProxy: true }); + assert.equal(stats.totals.requestCount, 2); + assert.equal(stats.totals.errorCount, 1); + assert.equal(stats.totals.totalTokens, 27); + assert.equal(stats.totals.inputTokens, 14); + assert.equal(stats.totals.outputTokens, 11); + assert.equal(stats.recentRequests.length, 2); + assert.equal(stats.models[0]?.requestCount, 1); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + +test("UsageStore excludes proxy rows by default and includes them on request", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-proxy-test-")); + try { + const store = new UsageStore(path.join(dir, "usage.sqlite")); + const createdAt = new Date().toISOString(); + + await store.record({ + createdAt, + durationMs: 10, + method: "POST", + model: "direct/model-a", + path: "/v1/messages", + requestId: "direct-1", + statusCode: 200, + usage: { + inputTokens: 5, + outputTokens: 7 + } + }); + await store.record({ + createdAt, + durationMs: 10, + method: "POST", + model: "proxy-model", + path: "/v1/messages", + provider: "proxy", + requestId: "proxy-1", + statusCode: 200, + usage: { + inputTokens: 100, + outputTokens: 200 + } + }); + + const defaultStats = await store.getStats("30d"); + assert.equal(defaultStats.totals.requestCount, 1); + assert.equal(defaultStats.totals.totalTokens, 12); + assert.equal(defaultStats.providerModels[0]?.provider, "direct"); + assert.equal(defaultStats.providerModels[0]?.model, "model-a"); + + const withProxy = await store.getStats("30d", { includeProxy: true }); + assert.equal(withProxy.totals.requestCount, 2); + assert.equal(withProxy.totals.totalTokens, 312); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); From 20691a8215164a87c58e7fdb7b0e790da2974776 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 30 Jun 2026 09:55:07 +0800 Subject: [PATCH 010/101] fix tray radial metric arc alignment --- src/renderer/pages/tray/components/widgets.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/pages/tray/components/widgets.tsx b/src/renderer/pages/tray/components/widgets.tsx index a1c5dc0..c77f295 100644 --- a/src/renderer/pages/tray/components/widgets.tsx +++ b/src/renderer/pages/tray/components/widgets.tsx @@ -566,8 +566,10 @@ export function RadialMetric({ const circumference = 2 * Math.PI * radius; const clamped = Math.max(0, Math.min(1, value)); const span = variant === "ring" ? 1 : variant === "arc" ? 0.78 : 0.55; + const sweepDegrees = span * 360; const dash = circumference * span; - const rotation = variant === "ring" ? -90 : variant === "arc" ? 130 : 160; + const progressDash = dash * clamped; + const rotation = variant === "ring" ? -90 : (540 - sweepDegrees) / 2; return (
@@ -589,7 +591,7 @@ export function RadialMetric({ fill="none" r={radius} stroke={color} - strokeDasharray={`${dash * clamped} ${circumference - dash * clamped}`} + strokeDasharray={`${progressDash} ${circumference - progressDash}`} strokeLinecap="round" strokeWidth="4" transform={`rotate(${rotation} 20 20)`} From 1a8d7cb610381cda685543fb964774bac11ab979 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 30 Jun 2026 10:38:33 +0800 Subject: [PATCH 011/101] Refactor router config handling --- build/run-tests.mjs | 76 ++++-- build/test.mjs | 112 ++++++-- package.json | 2 + tests/{ => main}/bot-gateway-env.test.mjs | 2 +- tests/{ => main}/deep-link.test.mjs | 2 +- tests/{ => main}/model-catalog-file.test.mjs | 2 +- tests/{ => main}/profile-launch-core.test.mjs | 2 +- .../{ => main}/provider-preset-utils.test.mjs | 2 +- tests/{ => main}/provider-url.test.mjs | 2 +- tests/{ => main}/request-log-store.test.mjs | 2 +- tests/{ => main}/usage-normalization.test.mjs | 2 +- tests/{ => main}/usage-store.test.mjs | 2 +- tests/renderer/components.test.tsx | 79 ++++++ tests/renderer/fixtures.ts | 189 +++++++++++++ tests/renderer/overview-components.test.tsx | 71 +++++ tests/renderer/tray-components.test.tsx | 258 ++++++++++++++++++ tests/{ => renderer}/usage-activity.test.mjs | 2 +- 17 files changed, 754 insertions(+), 53 deletions(-) rename tests/{ => main}/bot-gateway-env.test.mjs (97%) rename tests/{ => main}/deep-link.test.mjs (99%) rename tests/{ => main}/model-catalog-file.test.mjs (98%) rename tests/{ => main}/profile-launch-core.test.mjs (98%) rename tests/{ => main}/provider-preset-utils.test.mjs (98%) rename tests/{ => main}/provider-url.test.mjs (97%) rename tests/{ => main}/request-log-store.test.mjs (99%) rename tests/{ => main}/usage-normalization.test.mjs (94%) rename tests/{ => main}/usage-store.test.mjs (98%) create mode 100644 tests/renderer/components.test.tsx create mode 100644 tests/renderer/fixtures.ts create mode 100644 tests/renderer/overview-components.test.tsx create mode 100644 tests/renderer/tray-components.test.tsx rename tests/{ => renderer}/usage-activity.test.mjs (92%) diff --git a/build/run-tests.mjs b/build/run-tests.mjs index 086d390..a870705 100644 --- a/build/run-tests.mjs +++ b/build/run-tests.mjs @@ -8,26 +8,60 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-")); +const testSuites = ["main", "renderer"]; +const requestedSuites = process.argv.slice(2); +const suites = requestedSuites.length === 0 ? testSuites : requestedSuites; -const child = spawn(electron, ["--test", "dist/tests/*.js"], { - cwd: projectRoot, - env: { - ...process.env, - CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), - CCR_INTERNAL_HOME_DIR: testHome, - CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), - HOME: testHome, - ELECTRON_RUN_AS_NODE: "1" - }, - shell: process.platform === "win32", - stdio: "inherit" -}); - -child.on("exit", (code, signal) => { - rmSync(testHome, { force: true, recursive: true }); - if (signal) { - process.kill(process.pid, signal); - return; +for (const suite of suites) { + if (!testSuites.includes(suite)) { + cleanup(); + throw new Error(`Unknown test suite: ${suite}`); } - process.exit(code ?? 1); -}); +} + +try { + for (const suite of suites) { + await runSuite(suite); + } +} finally { + cleanup(); +} + +function runSuite(suite) { + console.log(`\nRunning ${suite} tests...`); + + return new Promise((resolve, reject) => { + const child = spawn(electron, ["--test", `dist/tests/${suite}/*.js`], { + cwd: projectRoot, + env: { + ...process.env, + CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), + CCR_INTERNAL_HOME_DIR: testHome, + CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), + HOME: testHome, + ELECTRON_RUN_AS_NODE: "1" + }, + shell: process.platform === "win32", + stdio: "inherit" + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${suite} tests exited with code ${code ?? 1}`)); + }); + }); +} + +function cleanup() { + rmSync(testHome, { force: true, recursive: true }); +} diff --git a/build/test.mjs b/build/test.mjs index 09e17dd..8319a30 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -1,44 +1,112 @@ import esbuild from "esbuild"; -import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); -const outdir = path.join(projectRoot, "dist", "tests"); +const testsOutDir = path.join(projectRoot, "dist", "tests"); +const rendererRoot = path.join(projectRoot, "src", "renderer"); +const testSuites = [ + { name: "main", testDir: path.join(projectRoot, "tests", "main") }, + { name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") } +]; +const requestedSuites = new Set(process.argv.slice(2)); +const suiteNames = new Set(testSuites.map((suite) => suite.name)); +const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite)); +const selectedSuites = requestedSuites.size === 0 + ? testSuites + : testSuites.filter((suite) => requestedSuites.has(suite.name)); -rmSync(outdir, { force: true, recursive: true }); -mkdirSync(outdir, { recursive: true }); +if (unknownSuites.length > 0) { + throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`); +} -const entryPoints = findTestFiles(path.join(projectRoot, "tests")); +rmSync(testsOutDir, { force: true, recursive: true }); -await esbuild.build({ - absWorkingDir: projectRoot, - bundle: true, - entryNames: "[name]", - entryPoints, - external: [ - "better-sqlite3", - "electron" - ], - format: "cjs", - legalComments: "none", - logLevel: "info", - outdir, - platform: "node", - target: "node22" -}); +for (const suite of selectedSuites) { + const entryPoints = findTestFiles(suite.testDir); + if (entryPoints.length === 0) { + continue; + } + + await esbuild.build({ + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints, + external: [ + "better-sqlite3", + "electron" + ], + format: "cjs", + legalComments: "none", + loader: { + ".gif": "file", + ".ico": "file", + ".jpg": "file", + ".jpeg": "file", + ".png": "file", + ".svg": "file", + ".webp": "file" + }, + logLevel: "info", + outdir: path.join(testsOutDir, suite.name), + platform: "node", + plugins: [rendererAliasPlugin()], + target: "node22" + }); +} function findTestFiles(dir) { + if (!existsSync(dir)) { + return []; + } + const files = []; for (const name of readdirSync(dir)) { const file = path.join(dir, name); const stat = statSync(file); if (stat.isDirectory()) { files.push(...findTestFiles(file)); - } else if (name.endsWith(".test.mjs")) { + } else if (/\.(test|spec)\.(mjs|ts|tsx)$/.test(name)) { files.push(file); } } return files.sort(); } + +function rendererAliasPlugin() { + return { + name: "renderer-test-alias", + setup(build) { + build.onResolve({ filter: /^@\// }, (args) => { + return { path: resolveRendererImport(args.path.slice(2)) }; + }); + } + }; +} + +function resolveRendererImport(importPath) { + const basePath = path.resolve(rendererRoot, importPath); + const candidates = [ + basePath, + `${basePath}.tsx`, + `${basePath}.ts`, + `${basePath}.jsx`, + `${basePath}.js`, + `${basePath}.json`, + path.join(basePath, "index.tsx"), + path.join(basePath, "index.ts"), + path.join(basePath, "index.jsx"), + path.join(basePath, "index.js") + ]; + + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + + return basePath; +} diff --git a/package.json b/package.json index 0f930d8..16e4cf8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "prepublishOnly": "npm run typecheck", "preview": "npm run build:assets && electron .", "test": "node build/test.mjs && node build/run-tests.mjs", + "test:main": "node build/test.mjs main && node build/run-tests.mjs main", + "test:renderer": "node build/test.mjs renderer && node build/run-tests.mjs renderer", "typecheck": "tsc --noEmit", "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, diff --git a/tests/bot-gateway-env.test.mjs b/tests/main/bot-gateway-env.test.mjs similarity index 97% rename from tests/bot-gateway-env.test.mjs rename to tests/main/bot-gateway-env.test.mjs index ac3e384..a4e8acb 100644 --- a/tests/bot-gateway-env.test.mjs +++ b/tests/main/bot-gateway-env.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { botGatewayProfileEnv } from "../src/main/bot-gateway-env.ts"; +import { botGatewayProfileEnv } from "../../src/main/bot-gateway-env.ts"; function botGateway(overrides = {}) { return { diff --git a/tests/deep-link.test.mjs b/tests/main/deep-link.test.mjs similarity index 99% rename from tests/deep-link.test.mjs rename to tests/main/deep-link.test.mjs index 2a9c63b..ba1ab95 100644 --- a/tests/deep-link.test.mjs +++ b/tests/main/deep-link.test.mjs @@ -6,7 +6,7 @@ import { parseProviderDeepLinkPayload, parseProviderManifestDeepLinkPayload, parseProviderManifestPayload -} from "../src/shared/deep-link.ts"; +} from "../../src/shared/deep-link.ts"; function base64UrlJson(value) { return Buffer.from(JSON.stringify(value), "utf8") diff --git a/tests/model-catalog-file.test.mjs b/tests/main/model-catalog-file.test.mjs similarity index 98% rename from tests/model-catalog-file.test.mjs rename to tests/main/model-catalog-file.test.mjs index 8a93289..4a012e7 100644 --- a/tests/model-catalog-file.test.mjs +++ b/tests/main/model-catalog-file.test.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../src/main/model-catalog-file.ts"; +import { loadModelCatalogPayload, modelCatalogPathCandidates } from "../../src/main/model-catalog-file.ts"; test("modelCatalogPathCandidates prefers env paths and removes duplicates", () => { const previousCatalogPath = process.env.CCR_MODEL_CATALOG_PATH; diff --git a/tests/profile-launch-core.test.mjs b/tests/main/profile-launch-core.test.mjs similarity index 98% rename from tests/profile-launch-core.test.mjs rename to tests/main/profile-launch-core.test.mjs index 2659aa4..228b0bb 100644 --- a/tests/profile-launch-core.test.mjs +++ b/tests/main/profile-launch-core.test.mjs @@ -10,7 +10,7 @@ import { resolveClaudeCodeSettingsFile, resolveCodexConfigFile, resolveProfileOpenSurface -} from "../src/main/profile-launch-core.ts"; +} from "../../src/main/profile-launch-core.ts"; const claudeProfile = { agent: "claude-code", diff --git a/tests/provider-preset-utils.test.mjs b/tests/main/provider-preset-utils.test.mjs similarity index 98% rename from tests/provider-preset-utils.test.mjs rename to tests/main/provider-preset-utils.test.mjs index 7ee4b50..3aea090 100644 --- a/tests/provider-preset-utils.test.mjs +++ b/tests/main/provider-preset-utils.test.mjs @@ -7,7 +7,7 @@ import { providerEndpointCanReceiveProviderApiKeyInList, providerIdentitySafetyIssueInList, providerPresetMatchesBaseUrl -} from "../src/shared/provider-preset-utils.ts"; +} from "../../src/shared/provider-preset-utils.ts"; const openAiPreset = { aliases: ["OpenAI", "ChatGPT"], diff --git a/tests/provider-url.test.mjs b/tests/main/provider-url.test.mjs similarity index 97% rename from tests/provider-url.test.mjs rename to tests/main/provider-url.test.mjs index b776876..633a8c8 100644 --- a/tests/provider-url.test.mjs +++ b/tests/main/provider-url.test.mjs @@ -5,7 +5,7 @@ import { parseProviderBaseUrl, providerBaseUrlForProtocol, providerUrlWithDefaultScheme -} from "../src/shared/provider-url.ts"; +} from "../../src/shared/provider-url.ts"; test("provider URL parsing strips endpoint paths and unsafe URL parts", () => { const parsed = parseProviderBaseUrl("https://user:secret@api.example.com/v1/chat/completions?token=secret#section"); diff --git a/tests/request-log-store.test.mjs b/tests/main/request-log-store.test.mjs similarity index 99% rename from tests/request-log-store.test.mjs rename to tests/main/request-log-store.test.mjs index 30a9cb1..09ba8ad 100644 --- a/tests/request-log-store.test.mjs +++ b/tests/main/request-log-store.test.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { RequestLogStore } from "../src/main/request-log-store.ts"; +import { RequestLogStore } from "../../src/main/request-log-store.ts"; test("RequestLogStore keeps list rows lightweight and detail rows complete", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-test-")); diff --git a/tests/usage-normalization.test.mjs b/tests/main/usage-normalization.test.mjs similarity index 94% rename from tests/usage-normalization.test.mjs rename to tests/main/usage-normalization.test.mjs index 65dfc03..0876d04 100644 --- a/tests/usage-normalization.test.mjs +++ b/tests/main/usage-normalization.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeUsageInputTokens } from "../src/main/usage-normalization.ts"; +import { normalizeUsageInputTokens } from "../../src/main/usage-normalization.ts"; test("normalizeUsageInputTokens subtracts cache tokens for OpenAI-compatible protocols", () => { const usage = normalizeUsageInputTokens( diff --git a/tests/usage-store.test.mjs b/tests/main/usage-store.test.mjs similarity index 98% rename from tests/usage-store.test.mjs rename to tests/main/usage-store.test.mjs index 0cae4ff..ce8d20c 100644 --- a/tests/usage-store.test.mjs +++ b/tests/main/usage-store.test.mjs @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { UsageStore } from "../src/main/usage-store.ts"; +import { UsageStore } from "../../src/main/usage-store.ts"; test("UsageStore aggregates stats in SQLite without loading all events", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-test-")); diff --git a/tests/renderer/components.test.tsx b/tests/renderer/components.test.tsx new file mode 100644 index 0000000..e6de94c --- /dev/null +++ b/tests/renderer/components.test.tsx @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Badge } from "../../src/renderer/components/ui/badge.tsx"; +import { Button } from "../../src/renderer/components/ui/button.tsx"; +import { Card, CardContent, CardHeader, CardTitle } from "../../src/renderer/components/ui/card.tsx"; +import { Switch } from "../../src/renderer/components/ui/switch.tsx"; + +test("Button renders default button semantics and variant classes", () => { + const html = renderToStaticMarkup( + + ); + + assert.match(html, /^ + ); + + assert.match(html, /class="plain-button"/); + assert.match(html, /type="button"/); + assert.doesNotMatch(html, /inline-flex/); +}); + +test("Badge renders the selected visual variant", () => { + const html = renderToStaticMarkup( + + Delayed + + ); + + assert.match(html, /^Delayed<\/span>$/); +}); + +test("Card primitives compose the expected document structure", () => { + const html = renderToStaticMarkup( + + + Provider settings + + Ready + + ); + + assert.match(html, /^
Provider settings<\/h2>/); + assert.match(html, /
Ready<\/div>/); +}); + +test("Switch renders accessible checked and disabled state", () => { + const html = renderToStaticMarkup( + + ); + + assert.match(html, /provider-switch/); + assert.match(html, /role="switch"/); + assert.match(html, /aria-checked="true"/); + assert.match(html, /aria-label="Enable provider"/); + assert.match(html, /disabled=""/); + assert.match(html, /translate-x-\[24px\]/); +}); diff --git a/tests/renderer/fixtures.ts b/tests/renderer/fixtures.ts new file mode 100644 index 0000000..79cf1e6 --- /dev/null +++ b/tests/renderer/fixtures.ts @@ -0,0 +1,189 @@ +import type { + ProviderAccountSnapshot, + UsageComparisonRow, + UsageStatsRange, + UsageStatsSnapshot, + UsageTotals +} from "../../src/shared/app.ts"; + +export function installBrowserGlobals() { + const localStorage = { + getItem: () => null, + removeItem: () => undefined, + setItem: () => undefined + }; + const windowMock = { + addEventListener: () => undefined, + cancelAnimationFrame: () => undefined, + ccr: { + closeTray: () => undefined, + quitApp: () => undefined, + setTrayDetailOpen: () => undefined, + showMainWindow: () => undefined + }, + clearInterval: () => undefined, + localStorage, + matchMedia: () => ({ + addEventListener: () => undefined, + matches: false, + removeEventListener: () => undefined + }), + removeEventListener: () => undefined, + requestAnimationFrame: () => 0, + setInterval: () => 0 + }; + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: windowMock + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + body: { + classList: { + add: () => undefined, + remove: () => undefined + }, + style: {} + }, + documentElement: { + lang: "en" + } + } + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + language: "en-US", + languages: ["en-US"] + } + }); +} + +export function usageTotals(patch: Partial = {}): UsageTotals { + return { + avgDurationMs: 842, + cacheRatio: 0.24, + cacheTokens: 2400, + costUsd: 1.23, + errorCount: 2, + inputTokens: 6200, + outputTokens: 3400, + requestCount: 128, + successRate: 0.984, + totalTokens: 12000, + ...patch + }; +} + +export function usageRow(key: string, label: string, patch: Partial = {}): UsageComparisonRow { + return { + ...usageTotals(), + caption: "Test usage row", + key, + label, + maxShare: 0.5, + ...patch + }; +} + +export function usageStats(range: UsageStatsRange = "30d", patch: Partial = {}): UsageStatsSnapshot { + const series = Array.from({ length: 8 }, (_, index) => { + const totalTokens = 1000 + index * 250; + const requestCount = 8 + index; + return { + ...usageTotals({ + cacheRatio: 0.1 + index * 0.02, + cacheTokens: 120 + index * 30, + errorCount: index % 4 === 0 ? 1 : 0, + inputTokens: 560 + index * 120, + outputTokens: 320 + index * 80, + requestCount, + successRate: index % 4 === 0 ? 0.92 : 1, + totalTokens + }), + bucket: `2026-06-${String(20 + index).padStart(2, "0")}T00:00:00.000Z`, + label: `6/${20 + index}` + }; + }); + const models = [ + usageRow("model:gpt-4.1", "gpt-4.1", { maxShare: 0.62, model: "gpt-4.1", provider: "openai", totalTokens: 7200 }), + usageRow("model:claude-sonnet", "claude-sonnet", { maxShare: 0.38, model: "claude-sonnet", provider: "anthropic", totalTokens: 4800 }) + ]; + + return { + clientModels: [ + usageRow("client:claude-code", "Claude Code", { client: "claude-code", model: "gpt-4.1", provider: "openai", totalTokens: 6800 }), + usageRow("client:codex", "Codex", { client: "codex", model: "claude-sonnet", provider: "anthropic", totalTokens: 5200 }) + ], + generatedAt: "2026-06-30T00:00:00.000Z", + models, + providerModels: [ + usageRow("provider:openai", "OpenAI", { credentialId: "primary", model: "gpt-4.1", provider: "openai", totalTokens: 7200 }), + usageRow("provider:anthropic", "Anthropic", { credentialId: "secondary", model: "claude-sonnet", provider: "anthropic", totalTokens: 4800 }) + ], + range, + recentRequests: [], + series, + totals: usageTotals(), + ...patch + }; +} + +export function accountSnapshots(): ProviderAccountSnapshot[] { + return [ + { + credentialId: "primary", + credentialLabel: "Primary Key", + meters: [ + { + id: "5h", + kind: "quota", + label: "5h quota", + limit: 100, + remaining: 42, + unit: "requests", + window: "5h" + }, + { + id: "weekly", + kind: "quota", + label: "Weekly quota", + limit: 500, + remaining: 310, + unit: "requests", + window: "weekly" + }, + { + id: "balance", + kind: "balance", + label: "Cash balance", + remaining: 12.34, + unit: "USD" + } + ], + provider: "openai", + source: "standard", + status: "warning", + updatedAt: "2026-06-30T00:00:00.000Z" + }, + { + credentialId: "secondary", + credentialLabel: "Secondary Key", + meters: [ + { + id: "balance", + kind: "balance", + label: "Credit balance", + remaining: 88, + unit: "USD" + } + ], + provider: "anthropic", + source: "standard", + status: "ok", + updatedAt: "2026-06-30T00:00:00.000Z" + } + ]; +} diff --git a/tests/renderer/overview-components.test.tsx b/tests/renderer/overview-components.test.tsx new file mode 100644 index 0000000..b8e5b84 --- /dev/null +++ b/tests/renderer/overview-components.test.tsx @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { OverviewView } from "../../src/renderer/pages/home/components/dashboard.tsx"; +import type { OverviewWidgetConfig } from "../../src/shared/app.ts"; +import { accountSnapshots, installBrowserGlobals, usageStats } from "./fixtures.ts"; + +installBrowserGlobals(); + +test("OverviewView renders every overview widget type", () => { + const widgets: OverviewWidgetConfig[] = [ + { enabled: true, id: "status", size: "4:1", type: "system-status", variant: "timeline" }, + { enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "nested-rings" }, + { enabled: true, id: "metric-requests", metric: "requests", size: "1:1", type: "metric", variant: "card" }, + { enabled: true, id: "metric-cache", metric: "cache-ratio", size: "1:1", type: "metric", variant: "ring" }, + { enabled: true, id: "metric-errors", metric: "errors", size: "1:1", type: "metric", variant: "bar" }, + { enabled: true, id: "trend", size: "3:2", type: "usage-trend", variant: "composed" }, + { enabled: true, id: "activity", size: "4:2", type: "token-activity", variant: "heatmap" }, + { enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "stacked" }, + { enabled: true, id: "models", size: "2:2", type: "model-distribution", variant: "donut" }, + { enabled: true, id: "clients", size: "4:2", type: "client-analysis", variant: "table" }, + { enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" } + ]; + + const html = renderToStaticMarkup( + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + ); + + assert.match(html, /

Overview<\/h2>/); + assert.match(html, /aria-label="Edit widgets"/); + assert.match(html, /System status/); + assert.match(html, /API Service/); + assert.match(html, /openai \/ Primary Key/); + assert.match(html, /Requests/); + assert.match(html, /Cache ratio/); + assert.match(html, /Errors/); + assert.match(html, /Usage Trend/); + assert.match(html, /Activity/); + assert.match(html, /Token Mix/); + assert.match(html, /Model Distribution/); + assert.match(html, /Client Analysis/); + assert.match(html, /Provider Analysis/); + assert.match(html, /claude-code/); + assert.match(html, /openai/); +}); + +test("OverviewView renders the empty widget layout state", () => { + const html = renderToStaticMarkup( + undefined} + usageRange="7d" + usageStats={usageStats("7d")} + onWidgetsChange={() => undefined} + /> + ); + + assert.match(html, /Overview/); + assert.match(html, /No widgets configured/); + assert.match(html, /aria-label="Edit widgets"/); +}); diff --git a/tests/renderer/tray-components.test.tsx b/tests/renderer/tray-components.test.tsx new file mode 100644 index 0000000..6e55776 --- /dev/null +++ b/tests/renderer/tray-components.test.tsx @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + AccountSummaryPanel, + AnimatedUsageChart, + ChartShell, + ModelShareChart, + RadialMetric, + RangeSwitch, + RingMetrics, + SourceGrid, + StatsGrid, + TokenActivityPanel, + TokenMixPanel, + TrayStatusStrip, + UsageDetailPanel, + UsageOverviewPanel +} from "../../src/renderer/pages/tray/components/index.ts"; +import { TrayApp } from "../../src/renderer/pages/tray/TrayApp.tsx"; +import { TrayDetailApp } from "../../src/renderer/pages/tray/TrayDetailApp.tsx"; +import { accountSnapshots, installBrowserGlobals, usageStats, usageTotals } from "./fixtures.ts"; + +installBrowserGlobals(); + +const componentVariants = { + account: "bar", + modelShare: "bars", + rings: "rings", + stats: "cards", + tokenFlow: "line", + tokenMix: "bars" +} as const; + +test("UsageOverviewPanel renders every enabled overview tray module", () => { + const activeStats = usageStats("30d"); + const html = renderToStaticMarkup( + + ); + + assert.match(html, /openai \/ Primary Key/); + assert.match(html, /30d Token Flow/); + assert.match(html, /Activity/); + assert.match(html, /Today tokens/); + assert.match(html, /Token Mix/); + assert.match(html, /Circular metrics/); + assert.match(html, /Model Share/); + assert.match(html, /Syncing usage\.\.\./); +}); + +test("UsageDetailPanel renders configured detail widgets and empty state", () => { + const activeStats = usageStats("7d"); + const html = renderToStaticMarkup( + undefined} + /> + ); + const emptyHtml = renderToStaticMarkup( + undefined} + /> + ); + + assert.match(html, /Usage Detail/); + assert.match(html, /7d - OpenAI/); + assert.match(html, /7d tokens/); + assert.match(html, /Token Flow/); + assert.match(html, /Token Mix/); + assert.match(html, /Circular metrics/); + assert.match(html, /Model Share/); + assert.match(emptyHtml, /No tray modules enabled/); +}); + +test("TrayStatusStrip renders open and quit actions", () => { + const html = renderToStaticMarkup(); + + assert.match(html, /aria-label="Open CCR"/); + assert.match(html, /title="Open CCR"/); + assert.match(html, /12\.5K tokens/); + assert.match(html, /CCR/); + assert.match(html, /aria-label="Quit"/); +}); + +test("SourceGrid renders provider tabs with the selected state", () => { + const html = renderToStaticMarkup( + undefined} + /> + ); + + assert.match(html, />All<\/button>/); + assert.match(html, /border-teal-300\/35 bg-teal-300\/16 text-teal-50/); + assert.match(html, />OpenAI<\/button>/); + assert.match(html, />Anthropic<\/button>/); +}); + +test("AccountSummaryPanel covers empty and metered account states", () => { + const emptyHtml = renderToStaticMarkup(); + const meteredHtml = renderToStaticMarkup( + undefined} /> + ); + + assert.match(emptyHtml, /No account data configured/); + assert.match(meteredHtml, /openai \/ Primary Key/); + assert.match(meteredHtml, /5h quota/); + assert.match(meteredHtml, /42 requests/); + assert.match(meteredHtml, /style="\s*width:42%"/); +}); + +test("RangeSwitch renders every usage range option", () => { + const html = renderToStaticMarkup( undefined} />); + + assert.match(html, />Today<\/button>/); + assert.match(html, />24h<\/button>/); + assert.match(html, /bg-white\/14 text-slate-50/); + assert.match(html, />7d<\/button>/); + assert.match(html, />30d<\/button>/); +}); + +test("ChartShell renders title, meta, and children", () => { + const html = renderToStaticMarkup( + + chart body + + ); + + assert.match(html, /Token Flow/); + assert.match(html, /gpt-4\.1/); + assert.match(html, /chart body/); +}); + +test("StatsGrid renders cards, compact, and pill variants", () => { + const items = [ + { label: "Tokens", value: "12K" }, + { label: "Requests", value: "128" } + ]; + const cardsHtml = renderToStaticMarkup(); + const compactHtml = renderToStaticMarkup(); + const pillsHtml = renderToStaticMarkup(); + + assert.match(cardsHtml, /grid-cols-2/); + assert.match(compactHtml, /py-0\.5/); + assert.match(pillsHtml, /rounded-full/); + assert.match(`${cardsHtml}${compactHtml}${pillsHtml}`, /Tokens/); + assert.match(`${cardsHtml}${compactHtml}${pillsHtml}`, /12K/); +}); + +test("AnimatedUsageChart renders line, area, bar, and sparkline output", () => { + const series = usageStats().series; + const lineHtml = renderToStaticMarkup(); + const areaHtml = renderToStaticMarkup(); + const barHtml = renderToStaticMarkup(); + const sparkHtml = renderToStaticMarkup(); + + assert.match(lineHtml, /aria-label="Usage chart"/); + assert.match(lineHtml, /line-chart-glow/); + assert.match(areaHtml, /fill="rgba\(45,212,191,.18\)"/); + assert.match(barHtml, / { + const html = renderToStaticMarkup(); + + assert.match(html, /Activity/); + assert.match(html, /Longest streak/); + assert.match(html, /aria-label="Activity Tokens"/); + assert.match(html, /Less/); + assert.match(html, /More/); +}); + +test("TokenMixPanel renders bars, stacked, and share chart variants", () => { + const totals = usageTotals(); + const barsHtml = renderToStaticMarkup(); + const stackedHtml = renderToStaticMarkup(); + const donutHtml = renderToStaticMarkup(); + + assert.match(barsHtml, /Token Mix/); + assert.match(barsHtml, /Input/); + assert.match(stackedHtml, /bg-blue-400/); + assert.match(donutHtml, /aria-label="Share chart"/); +}); + +test("RingMetrics and RadialMetric render accessible radial charts", () => { + const ringsHtml = renderToStaticMarkup(); + const radialHtml = renderToStaticMarkup( + + ); + + assert.match(ringsHtml, /Circular metrics/); + assert.match(ringsHtml, /Success/); + assert.match(ringsHtml, /Cache/); + assert.match(radialHtml, /aria-label="Cache 24%"/); + assert.match(radialHtml, /12K/); + assert.match(radialHtml, /tokens/); +}); + +test("ModelShareChart renders populated variants and empty state", () => { + const rows = usageStats().models; + const barsHtml = renderToStaticMarkup(); + const listHtml = renderToStaticMarkup(); + const donutHtml = renderToStaticMarkup(); + const emptyHtml = renderToStaticMarkup(); + + assert.match(barsHtml, /Model Share/); + assert.match(barsHtml, /gpt-4\.1/); + assert.match(listHtml, /1\. gpt-4\.1/); + assert.match(listHtml, /62%/); + assert.match(donutHtml, /aria-label="Share chart"/); + assert.match(emptyHtml, /No usage captured yet/); +}); + +test("TrayApp and TrayDetailApp render their shell components without browser runtime", () => { + const trayHtml = renderToStaticMarkup(); + const detailHtml = renderToStaticMarkup(); + + assert.match(trayHtml, /Usage Overview/); + assert.match(trayHtml, /Open CCR/); + assert.match(detailHtml, /Usage Detail/); + assert.match(detailHtml, /OpenAI/); +}); diff --git a/tests/usage-activity.test.mjs b/tests/renderer/usage-activity.test.mjs similarity index 92% rename from tests/usage-activity.test.mjs rename to tests/renderer/usage-activity.test.mjs index a3c0e2a..37dc1a2 100644 --- a/tests/usage-activity.test.mjs +++ b/tests/renderer/usage-activity.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildTokenActivity, activityDateKey } from "../src/renderer/lib/usage-activity.ts"; +import { buildTokenActivity, activityDateKey } from "../../src/renderer/lib/usage-activity.ts"; test("buildTokenActivity summarizes observed token days and streaks", () => { const summary = buildTokenActivity( From 13517cc2a0aba2a7a2f561ea2783ad3f012a4148 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 30 Jun 2026 12:04:26 +0800 Subject: [PATCH 012/101] Make model catalog capability-aware --- src/main/codex-cli-middleware-runtime.ts | 23 +- src/main/codex-model-catalog.ts | 316 +++++++++++++++++++++-- src/server/gateway/service.ts | 57 +++- tests/main/codex-model-catalog.test.mjs | 169 ++++++++++++ 4 files changed, 526 insertions(+), 39 deletions(-) create mode 100644 tests/main/codex-model-catalog.test.mjs diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 0813c76..339d775 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -2260,13 +2260,8 @@ function modelCatalogConfigItem(model, priority) { slug: model, display_name: model, description: "CCR gateway model " + model, - default_reasoning_level: "medium", - supported_reasoning_levels: [ - { effort: "low", description: "Low reasoning" }, - { effort: "medium", description: "Medium reasoning" }, - { effort: "high", description: "High reasoning" }, - { effort: "xhigh", description: "Extra high reasoning" } - ], + default_reasoning_level: null, + supported_reasoning_levels: [], shell_type: "shell_command", visibility: "list", supported_in_api: true, @@ -2276,21 +2271,21 @@ function modelCatalogConfigItem(model, priority) { availability_nux: null, upgrade: null, base_instructions: "You are Codex, a coding agent.", - supports_reasoning_summaries: true, + supports_reasoning_summaries: false, default_reasoning_summary: "none", support_verbosity: true, default_verbosity: "low", - apply_patch_tool_type: "freeform", - web_search_tool_type: "text_and_image", + apply_patch_tool_type: null, + web_search_tool_type: "text", truncation_policy: { mode: "tokens", limit: 10000 }, - supports_parallel_tool_calls: true, - supports_image_detail_original: true, + supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: 128000, max_context_window: 128000, effective_context_window_percent: 95, experimental_supported_tools: [], - input_modalities: ["text", "image"], - supports_search_tool: true + input_modalities: ["text"], + supports_search_tool: false }; } diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts index d901519..fc2185e 100644 --- a/src/main/codex-model-catalog.ts +++ b/src/main/codex-model-catalog.ts @@ -1,5 +1,11 @@ -import type { AppConfig, VirtualModelProfileConfig } from "../shared/app"; -import { findModelCatalogEntry, modelCatalogMaxInputTokens } from "../server/gateway/model-catalog"; +import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "../shared/app"; +import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "../shared/app"; +import { + findModelCatalogEntry, + modelCatalogMaxInputTokens, + readCatalogCapability, + type ModelCatalogEntry +} from "../server/gateway/model-catalog"; const fusionModelProviderName = "Fusion"; const codexDefaultContextWindow = 128_000; @@ -11,11 +17,11 @@ export type CodexModelCatalog = { export type CodexModelCatalogItem = { additional_speed_tiers: unknown[]; - apply_patch_tool_type: string; + apply_patch_tool_type: string | null; availability_nux: null; base_instructions: string; context_window: number; - default_reasoning_level: string; + default_reasoning_level: string | null; default_reasoning_summary: string; description: string; display_name: string; @@ -42,7 +48,7 @@ export type CodexModelCatalogItem = { export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog { return { - models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index)) + models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config)) }; } @@ -101,21 +107,26 @@ export function codexModelCatalogBase64(config?: Partial> +): CodexModelCatalogItem { + const profile = codexModelCapabilityProfile(model, config); + const contextWindow = codexModelContextWindow(model, profile.catalogEntry); return { additional_speed_tiers: [], - apply_patch_tool_type: "freeform", + apply_patch_tool_type: profile.applyPatchToolType, availability_nux: null, base_instructions: "You are Codex, a coding agent.", context_window: contextWindow, - default_reasoning_level: "medium", + default_reasoning_level: profile.supportsReasoning ? "medium" : null, default_reasoning_summary: "none", description: `CCR gateway model ${model}`, display_name: model, effective_context_window_percent: codexEffectiveContextWindowPercent, experimental_supported_tools: [], - input_modalities: ["text", "image"], + input_modalities: profile.inputModalities, max_context_window: contextWindow, priority, service_tiers: [], @@ -123,28 +134,287 @@ function codexModelCatalogItem(model: string, priority: number): CodexModelCatal slug: model, support_verbosity: true, supported_in_api: true, - supported_reasoning_levels: [ - { effort: "low", description: "Low reasoning" }, - { effort: "medium", description: "Medium reasoning" }, - { effort: "high", description: "High reasoning" }, - { effort: "xhigh", description: "Extra high reasoning" } - ], - supports_image_detail_original: true, - supports_parallel_tool_calls: true, - supports_reasoning_summaries: true, - supports_search_tool: true, + supported_reasoning_levels: profile.supportedReasoningLevels, + supports_image_detail_original: profile.supportsImageInput, + supports_parallel_tool_calls: profile.supportsParallelToolCalls, + supports_reasoning_summaries: profile.supportsReasoning, + supports_search_tool: profile.supportsSearchTool, truncation_policy: { mode: "tokens", limit: 10_000 }, upgrade: null, visibility: "list", - web_search_tool_type: "text_and_image" + web_search_tool_type: profile.supportsSearchTool && profile.supportsImageInput ? "text_and_image" : "text" }; } -function codexModelContextWindow(model: string): number { - const entry = findModelCatalogEntry(model); +type CodexCapabilityProfile = { + applyPatchToolType: string | null; + catalogEntry?: ModelCatalogEntry; + inputModalities: string[]; + supportedReasoningLevels: Array<{ description: string; effort: string }>; + supportsImageInput: boolean; + supportsParallelToolCalls: boolean; + supportsReasoning: boolean; + supportsSearchTool: boolean; +}; + +function codexModelCapabilityProfile( + model: string, + config?: Partial> +): CodexCapabilityProfile { + const selector = parseModelSelector(model); + const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined; + const catalogEntry = findModelCatalogEntry(model); + const capabilities = catalogEntry?.capabilities ?? {}; + const providerProtocol = provider ? codexProviderProtocol(provider) : undefined; + const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false; + const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config); + const supportsReasoning = readCatalogCapability(capabilities, "reasoning"); + const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry); + const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling"); + const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) + ? "freeform" + : null; + const supportsSearchTool = + supportsFusionWebSearch || + ( + readCatalogCapability(capabilities, "webSearch") && + (providerProtocol === "openai_responses" || providerProtocol === "anthropic_messages") + ); + + return { + applyPatchToolType, + catalogEntry, + inputModalities: supportsImageInput ? ["text", "image"] : ["text"], + supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [], + supportsImageInput, + supportsParallelToolCalls, + supportsReasoning, + supportsSearchTool + }; +} + +function codexModelContextWindow(model: string, entry = findModelCatalogEntry(model)): number { return modelCatalogMaxInputTokens(entry) || codexDefaultContextWindow; } +function catalogEntrySupportsImageInput(entry: ModelCatalogEntry | undefined): boolean { + const capabilities = entry?.capabilities ?? {}; + const modalities = new Set((entry?.modalities?.input ?? []).map((item) => item.toLowerCase())); + return modalities.has("image") || + readCatalogCapability(capabilities, "imageInput") || + readCatalogCapability(capabilities, "vision") || + readCatalogCapability(capabilities, "multimodal"); +} + +function supportedReasoningLevels(capabilities: Record): Array<{ description: string; effort: string }> { + const levels = [ + { effort: "low", description: "Low reasoning" }, + { effort: "medium", description: "Medium reasoning" }, + { effort: "high", description: "High reasoning" } + ]; + if (readCatalogCapability(capabilities, "xhighReasoningEffort") || readCatalogCapability(capabilities, "maxReasoningEffort")) { + levels.push({ effort: "xhigh", description: "Extra high reasoning" }); + } + return levels; +} + +function findConfiguredProvider( + config: Partial> | undefined, + providerName: string +): GatewayProviderConfig | undefined { + const normalized = providerName.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + return (config?.Providers ?? []).find((provider) => provider.name.trim().toLowerCase() === normalized); +} + +function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined { + const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))); + for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content"] as GatewayProviderProtocol[]) { + if (capabilityProtocols.includes(protocol)) { + return protocol; + } + } + + return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProviderProtocol(provider); +} + +function codexProviderSupportsResponses(provider: GatewayProviderConfig): boolean { + return uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))).includes("openai_responses") || + normalizeProviderProtocol(provider.type) === "openai_responses" || + normalizeProviderProtocol(provider.provider) === "openai_responses" || + providerEndpointLooksLikeResponses(provider); +} + +function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { + const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase(); + const transformer = JSON.stringify(provider.transformer ?? "").toLowerCase(); + if (providerEndpointLooksLikeResponses(provider)) { + return "openai_responses"; + } + if (url.includes("generativelanguage.googleapis.com") || transformer.includes("gemini")) { + return "gemini_generate_content"; + } + if (url.includes("anthropic") || transformer.includes("anthropic")) { + return "anthropic_messages"; + } + return "openai_chat_completions"; +} + +function providerEndpointLooksLikeResponses(provider: GatewayProviderConfig): boolean { + const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase(); + return url.endsWith("/responses") || url.includes("/responses?"); +} + +function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | undefined): boolean { + return [ + model, + entry?.id, + entry?.model + ].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt")); +} + +function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "openai" || normalized === "openai_responses") { + return "openai_responses"; + } + if (normalized === "openai_chat" || normalized === "openai_chat_completions") { + return "openai_chat_completions"; + } + if (normalized === "anthropic" || normalized === "anthropic_messages") { + return "anthropic_messages"; + } + if (normalized === "gemini" || normalized === "gemini_generate_content") { + return "gemini_generate_content"; + } + return undefined; +} + +function uniqueProviderProtocols(values: Array): GatewayProviderProtocol[] { + const seen = new Set(); + const output: GatewayProviderProtocol[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + output.push(value); + } + return output; +} + +function parseModelSelector(model: string): { model: string; provider: string } | undefined { + const normalized = normalizeModelSelector(model); + const slashIndex = normalized.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= normalized.length - 1) { + return undefined; + } + return { + provider: normalized.slice(0, slashIndex), + model: normalized.slice(slashIndex + 1) + }; +} + +function codexVirtualModelSupportsFusionWebSearch( + model: string, + config?: Partial> +): boolean { + return (config?.virtualModelProfiles ?? []).some((profile) => + virtualModelIsCatalogVisible(profile) && + virtualModelMatchesCatalogModel(profile, model, config) && + virtualModelProfileSupportsFusionWebSearch(profile) + ); +} + +function virtualModelMatchesCatalogModel( + profile: VirtualModelProfileConfig, + model: string, + config?: Partial> +): boolean { + const normalizedModel = normalizeModelSelector(model); + const normalizedModelLower = normalizedModel.toLowerCase(); + if (!normalizedModelLower) { + return false; + } + + for (const alias of virtualModelRawCatalogNames(profile)) { + const normalizedAlias = alias.trim().toLowerCase(); + if (normalizedAlias && (normalizedModelLower === normalizedAlias || normalizedModelLower === fusionModelSelector(alias).toLowerCase())) { + return true; + } + } + + const selector = parseModelSelector(normalizedModel); + if (!selector) { + return false; + } + const provider = findConfiguredProvider(config, selector.provider); + if (!provider) { + return false; + } + const configuredModels = new Set(provider.models.map((item) => item.trim().toLowerCase()).filter(Boolean)); + const selectedModel = selector.model.trim(); + const selectedModelLower = selectedModel.toLowerCase(); + + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (!normalizedPrefix || !selectedModelLower.startsWith(normalizedPrefix.toLowerCase())) { + continue; + } + const baseModel = selectedModel.slice(normalizedPrefix.length).trim().toLowerCase(); + if (configuredModels.has(baseModel)) { + return true; + } + } + + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (!normalizedSuffix || !selectedModelLower.endsWith(normalizedSuffix.toLowerCase())) { + continue; + } + const baseModel = selectedModel.slice(0, selectedModel.length - normalizedSuffix.length).trim().toLowerCase(); + if (configuredModels.has(baseModel)) { + return true; + } + } + + return false; +} + +function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfileConfig): boolean { + const metadata = recordValue(profile.metadata); + const fusionWebSearch = recordValue(metadata?.fusionWebSearch); + if (stringRecordValue(fusionWebSearch, "toolName")) { + return true; + } + + if (recordValue(profile.execution)?.matchWebSearch === true) { + return true; + } + + return (profile.tools ?? []).some((tool) => { + const name = tool.name.trim(); + return name === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + name.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`); + }); +} + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function stringRecordValue(record: Record | undefined, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value.trim() : ""; +} + function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean { return profile.enabled !== false && profile.materialization?.enabled !== false && diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 7dac5f1..3726623 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -1787,7 +1787,9 @@ function rewriteModelSelectorForProtocol( return model; } const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; - const selector = resolveConfiguredProviderModelSelector(publicModel, config); + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined; return selector && capability ? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}` @@ -1997,7 +1999,9 @@ function prepareUpstreamCredentialAttempt(input: { if (!target) { return { ...input.attempt, - body: normalizedBody?.body ?? input.attempt.body, + body: bodyHasConfiguredProviderModelSelector(input.attempt.body, input.config) + ? input.attempt.body + : normalizedBody?.body ?? input.attempt.body, headers: input.headers }; } @@ -2059,6 +2063,12 @@ function normalizeConfiguredProviderModelBody( }; } +function bodyHasConfiguredProviderModelSelector(body: Buffer | undefined, config: AppConfig): boolean { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + return Boolean(resolveConfiguredProviderModelSelector(model, config)); +} + function resolveProviderCredentialRoutingTarget( config: AppConfig, headers: Record, @@ -2183,6 +2193,49 @@ function resolveConfiguredProviderModelSelector( return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined; } +function resolveUniqueConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + const model = normalizeRouteSelector(value); + if (!model) { + return undefined; + } + + const exactMatches = configuredProviderModelMatches(model, config, false); + if (exactMatches.length === 1) { + return exactMatches[0]; + } + if (exactMatches.length > 1) { + return undefined; + } + + const caseInsensitiveMatches = configuredProviderModelMatches(model, config, true); + return caseInsensitiveMatches.length === 1 ? caseInsensitiveMatches[0] : undefined; +} + +function configuredProviderModelMatches( + model: string, + config: AppConfig, + caseInsensitive: boolean +): Array<{ model: string; provider: GatewayProviderConfig }> { + const normalized = caseInsensitive ? model.toLowerCase() : model; + const matches: Array<{ model: string; provider: GatewayProviderConfig }> = []; + for (const provider of config.Providers) { + for (const candidate of provider.models) { + const configuredModel = candidate.trim(); + if (!configuredModel) { + continue; + } + const comparable = caseInsensitive ? configuredModel.toLowerCase() : configuredModel; + if (comparable === normalized) { + matches.push({ model: configuredModel, provider }); + } + } + } + return matches; +} + function firstTargetProviderHeader(headers: Record): string | undefined { const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; if (provider?.trim()) { diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs new file mode 100644 index 0000000..5585183 --- /dev/null +++ b/tests/main/codex-model-catalog.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildCodexModelCatalog } from "../../src/main/codex-model-catalog.ts"; + +function catalogModelFor(config, slug) { + const catalog = buildCodexModelCatalog(config, slug); + const model = catalog.models.find((item) => item.slug === slug); + assert.ok(model, `expected catalog model ${slug}`); + return model; +} + +test("codex catalog treats unknown models as text-only without advanced tools", () => { + const model = catalogModelFor({ + Providers: [ + { name: "Custom", type: "openai_chat_completions", models: ["unknown-model"] } + ] + }, "Custom/unknown-model"); + + assert.deepEqual(model.input_modalities, ["text"]); + assert.equal(model.supports_search_tool, false); + assert.equal(model.supports_parallel_tool_calls, false); + assert.equal(model.supports_reasoning_summaries, false); + assert.equal(model.supports_image_detail_original, false); + assert.deepEqual(model.supported_reasoning_levels, []); + assert.equal(model.default_reasoning_level, null); + assert.equal(model.apply_patch_tool_type, null); +}); + +test("codex catalog uses model catalog capabilities for known text models", () => { + const model = catalogModelFor({ + Providers: [ + { name: "deepseek", type: "openai_chat_completions", models: ["deepseek-chat"] } + ] + }, "deepseek/deepseek-chat"); + + assert.deepEqual(model.input_modalities, ["text"]); + assert.equal(model.supports_parallel_tool_calls, true); + assert.equal(model.supports_search_tool, false); + assert.equal(model.supports_reasoning_summaries, false); + assert.equal(model.supports_image_detail_original, false); + assert.deepEqual(model.supported_reasoning_levels, []); + assert.equal(model.default_reasoning_level, null); + assert.equal(model.apply_patch_tool_type, null); +}); + +test("codex catalog enables multimodal reasoning and search when provider protocol supports it", () => { + const model = catalogModelFor({ + Providers: [ + { name: "openrouter", type: "openai_responses", models: ["google/gemini-2.5-pro"] } + ] + }, "openrouter/google/gemini-2.5-pro"); + + assert.deepEqual(model.input_modalities, ["text", "image"]); + assert.equal(model.supports_image_detail_original, true); + assert.equal(model.supports_parallel_tool_calls, true); + assert.equal(model.supports_reasoning_summaries, true); + assert.equal(model.supports_search_tool, true); + assert.equal(model.web_search_tool_type, "text_and_image"); + assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["low", "medium", "high"]); + assert.equal(model.default_reasoning_level, "medium"); + assert.equal(model.apply_patch_tool_type, "freeform"); +}); + +test("codex catalog does not expose native search through chat-completions-only providers", () => { + const model = catalogModelFor({ + Providers: [ + { name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] } + ] + }, "openrouter/google/gemini-2.5-pro"); + + assert.deepEqual(model.input_modalities, ["text", "image"]); + assert.equal(model.supports_parallel_tool_calls, true); + assert.equal(model.supports_reasoning_summaries, true); + assert.equal(model.supports_search_tool, false); + assert.equal(model.web_search_tool_type, "text"); + assert.equal(model.apply_patch_tool_type, null); +}); + +test("codex catalog keeps freeform apply_patch for GPT-named chat-compatible models", () => { + const model = catalogModelFor({ + Providers: [ + { name: "gateway", type: "openai_chat_completions", models: ["gpt-compatible-coder"] } + ] + }, "gateway/gpt-compatible-coder"); + + assert.equal(model.apply_patch_tool_type, "freeform"); +}); + +test("codex catalog keeps freeform apply_patch when provider advertises Responses capability", () => { + const model = catalogModelFor({ + Providers: [ + { + capabilities: [ + { baseUrl: "https://openrouter.ai/api/v1", type: "openai_chat_completions" }, + { baseUrl: "https://openrouter.ai/api/v1", type: "openai_responses" } + ], + name: "openrouter", + type: "openai_chat_completions", + models: ["google/gemini-2.5-pro"] + } + ] + }, "openrouter/google/gemini-2.5-pro"); + + assert.equal(model.apply_patch_tool_type, "freeform"); +}); + +test("codex catalog marks Fusion aliases with builtin web search as searchable", () => { + const model = catalogModelFor({ + Providers: [], + virtualModelProfiles: [ + { + displayName: "Research Fusion", + enabled: true, + execution: { + clientToolsPolicy: "allow", + matchWebSearch: false, + maxToolCalls: 4, + maxTurns: 4, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "research-fusion", + key: "research", + match: { exactAliases: ["research"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionWebSearch: { provider: "brave", toolName: "web_search_research" } + }, + tools: [{ name: "web_search_research", visibility: "internal" }] + } + ] + }, "Fusion/research"); + + assert.deepEqual(model.input_modalities, ["text"]); + assert.equal(model.supports_search_tool, true); + assert.equal(model.web_search_tool_type, "text"); + assert.equal(model.apply_patch_tool_type, null); +}); + +test("codex catalog marks prefixed Fusion virtual models with legacy web search tools as searchable", () => { + const model = catalogModelFor({ + Providers: [ + { name: "deepseek", type: "openai_chat_completions", models: ["deepseek-chat"] } + ], + virtualModelProfiles: [ + { + displayName: "Web Prefix", + enabled: true, + execution: { + clientToolsPolicy: "allow", + maxToolCalls: 4, + maxTurns: 4, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "web-prefix", + key: "web-prefix", + match: { exactAliases: [], prefixes: ["web-"], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + tools: [{ name: "web_search", visibility: "internal" }] + } + ] + }, "deepseek/web-deepseek-chat"); + + assert.deepEqual(model.input_modalities, ["text"]); + assert.equal(model.supports_search_tool, true); + assert.equal(model.web_search_tool_type, "text"); + assert.equal(model.apply_patch_tool_type, null); +}); From f6174752b7e4d47b2941196081052a2a403758b3 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 30 Jun 2026 13:57:06 +0800 Subject: [PATCH 013/101] Improve app packaging diagnostics and usage formatting --- build/verify-packaged-app.cjs | 212 ++++++++++++++++++ electron-builder.json | 1 + src/main/main-app.ts | 15 +- src/main/main.ts | 63 +++++- .../pages/home/components/network-logs.tsx | 18 +- src/renderer/pages/home/shared/i18n.tsx | 5 + src/renderer/pages/home/shared/logs.ts | 12 +- src/renderer/pages/home/shared/usage.ts | 4 +- .../pages/tray/components/account-panel.tsx | 15 +- tests/renderer/usage-format.test.ts | 45 ++++ 10 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 build/verify-packaged-app.cjs create mode 100644 tests/renderer/usage-format.test.ts diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs new file mode 100644 index 0000000..0d6371a --- /dev/null +++ b/build/verify-packaged-app.cjs @@ -0,0 +1,212 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const betterSqliteNativeRelativePath = path.join( + "app.asar.unpacked", + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); + +module.exports = async function verifyPackagedApp(context) { + const platform = context?.electronPlatformName; + const arch = normalizeArch(context?.arch); + const appOutDir = context?.appOutDir; + + if (!platform || !appOutDir) { + throw new Error("Packaged app verification did not receive electron-builder platform context."); + } + + const resourcesDir = findResourcesDir(appOutDir, platform); + assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive"); + + const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath); + assertFile(nativeModule, "better-sqlite3 native module"); + + const nativeInfo = inspectNativeModule(nativeModule); + if (nativeInfo.platform !== platform) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Rebuild native dependencies for the target platform before packaging.` + ); + } + + if (arch && !nativeArchMatches(nativeInfo, arch)) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Run npm run rebuild:sqlite3 on the target platform before packaging.` + ); + } +}; + +function findResourcesDir(appOutDir, platform) { + if (platform !== "darwin") { + return path.join(appOutDir, "resources"); + } + + const appBundle = fs.readdirSync(appOutDir) + .find((entry) => entry.endsWith(".app") && fs.statSync(path.join(appOutDir, entry)).isDirectory()); + if (!appBundle) { + throw new Error(`Could not find a .app bundle in ${appOutDir}`); + } + return path.join(appOutDir, appBundle, "Contents", "Resources"); +} + +function assertFile(file, label) { + let stat; + try { + stat = fs.statSync(file); + } catch { + throw new Error(`${label} is missing: ${file}`); + } + + if (!stat.isFile() || stat.size === 0) { + throw new Error(`${label} is empty or not a file: ${file}`); + } +} + +function inspectNativeModule(file) { + const buffer = fs.readFileSync(file); + if (buffer.length < 32) { + throw new Error(`Native module is too small to identify: ${file}`); + } + + if (buffer[0] === 0x4d && buffer[1] === 0x5a) { + return inspectPe(buffer, file); + } + if (buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) { + return inspectElf(buffer); + } + + const magicLe = buffer.readUInt32LE(0); + const magicBe = buffer.readUInt32BE(0); + if (magicLe === 0xfeedface || magicLe === 0xfeedfacf || magicLe === 0xcefaedfe || magicLe === 0xcffaedfe) { + return inspectMachO(buffer, magicLe); + } + if (magicBe === 0xcafebabe || magicBe === 0xcafebabf) { + return inspectFatMachO(buffer, magicBe); + } + + throw new Error(`Native module has an unknown binary format: ${file}`); +} + +function inspectPe(buffer, file) { + const peOffset = buffer.readUInt32LE(0x3c); + if (peOffset + 6 >= buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") { + throw new Error(`Native module has an invalid PE header: ${file}`); + } + const machine = buffer.readUInt16LE(peOffset + 4); + return { + arch: peMachineArch(machine), + platform: "win32" + }; +} + +function inspectElf(buffer) { + const machine = buffer.readUInt16LE(18); + return { + arch: elfMachineArch(machine), + platform: "linux" + }; +} + +function inspectMachO(buffer, magicLe) { + const bigEndian = magicLe === 0xcefaedfe || magicLe === 0xcffaedfe; + const cpuType = bigEndian ? buffer.readUInt32BE(4) : buffer.readUInt32LE(4); + return { + arch: machCpuArch(cpuType), + platform: "darwin" + }; +} + +function inspectFatMachO(buffer, magicBe) { + const archs = []; + const count = buffer.readUInt32BE(4); + const entrySize = magicBe === 0xcafebabf ? 32 : 20; + for (let index = 0; index < count; index += 1) { + const offset = 8 + index * entrySize; + if (offset + 8 > buffer.length) { + break; + } + archs.push(machCpuArch(buffer.readUInt32BE(offset))); + } + return { + arch: "universal", + archs, + platform: "darwin" + }; +} + +function peMachineArch(machine) { + if (machine === 0x8664) { + return "x64"; + } + if (machine === 0xaa64) { + return "arm64"; + } + if (machine === 0x014c) { + return "ia32"; + } + return `unknown-pe-${machine.toString(16)}`; +} + +function elfMachineArch(machine) { + if (machine === 0x3e) { + return "x64"; + } + if (machine === 0xb7) { + return "arm64"; + } + if (machine === 0x03) { + return "ia32"; + } + if (machine === 0x28) { + return "armv7l"; + } + return `unknown-elf-${machine.toString(16)}`; +} + +function machCpuArch(cpuType) { + if (cpuType === 0x01000007) { + return "x64"; + } + if (cpuType === 0x0100000c) { + return "arm64"; + } + if (cpuType === 0x00000007) { + return "ia32"; + } + return `unknown-macho-${cpuType.toString(16)}`; +} + +function normalizeArch(arch) { + if (typeof arch === "string") { + return arch; + } + const electronBuilderArchNames = new Map([ + [0, "ia32"], + [1, "x64"], + [2, "armv7l"], + [3, "arm64"], + [4, "universal"] + ]); + return electronBuilderArchNames.get(arch); +} + +function nativeArchMatches(info, arch) { + if (info.arch === arch) { + return true; + } + if (info.arch === "universal") { + return arch === "universal" || Boolean(info.archs?.includes(arch)); + } + return false; +} + +function formatNativeInfo(info) { + return info.arch === "universal" && info.archs?.length + ? `${info.platform}/${info.arch} (${info.archs.join(", ")})` + : `${info.platform}/${info.arch}`; +} diff --git a/electron-builder.json b/electron-builder.json index 2918119..b0a4bf0 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -16,6 +16,7 @@ "directories": { "output": "release/${version}" }, + "afterPack": "build/verify-packaged-app.cjs", "afterAllArtifactBuild": "build/verify-update-metadata.cjs", "protocols": [ { diff --git a/src/main/main-app.ts b/src/main/main-app.ts index 3fedf93..9c1d9ca 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -36,7 +36,7 @@ function startPrimaryInstance(): void { queueEnsureConfiguredProxyModeActive("second-instance"); }); - app.whenReady().then(() => { + void app.whenReady().then(() => { configureProxyDesktopIntegration(); try { ensureCcrCliLauncher(); @@ -57,6 +57,15 @@ function startPrimaryInstance(): void { } queueEnsureConfiguredProxyModeActive("activate"); }); + }).catch((error) => { + const detail = formatErrorDetail(error); + console.error(`Failed to initialize ${app.name || "application"}: ${detail}`); + try { + dialog.showErrorBox("Claude Code Router failed to start", detail); + } catch { + // Keep the console log as the fallback diagnostic channel. + } + app.exit(1); }); app.on("before-quit", (event) => { @@ -247,3 +256,7 @@ function logProxySystemProxyIssue(reason: string, status: ReturnType { - console.error(error instanceof Error ? error.message : String(error)); - app.quit(); -}); +let fatalStartupErrorReported = false; + +process.once("uncaughtException", reportFatalStartupError); +process.once("unhandledRejection", reportFatalStartupError); + +void import("./main-app.js") + .then(() => { + process.off("uncaughtException", reportFatalStartupError); + process.off("unhandledRejection", reportFatalStartupError); + }) + .catch((error) => { + reportFatalStartupError(error); + }); + +function reportFatalStartupError(error: unknown): void { + if (fatalStartupErrorReported) { + return; + } + fatalStartupErrorReported = true; + + const detail = formatErrorDetail(error); + console.error(detail); + + try { + dialog.showErrorBox("Claude Code Router failed to start", startupErrorMessage(detail)); + } catch { + // If the platform dialog is unavailable, the console output above still + // preserves the actionable failure for command-line launches. + } + + app.exit(1); +} + +function startupErrorMessage(detail: string): string { + if (isBetterSqliteNativeError(detail)) { + return [ + "The bundled SQLite native module could not be loaded.", + "", + "This usually means the Windows package was built with a missing or incompatible better-sqlite3 binary. Rebuild the app with npm run rebuild:sqlite3 before packaging, or build the Windows artifact on a Windows x64 runner.", + "", + detail + ].join("\n"); + } + + return detail; +} + +function isBetterSqliteNativeError(detail: string): boolean { + return /better[-_]sqlite3|better_sqlite3\.node|NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|Cannot find module ['"]better-sqlite3/i.test(detail); +} + +function formatErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.stack || error.message; + } + return String(error); +} diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index da6e060..48117dc 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -10,7 +10,7 @@ import { Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter, RequestLogPage, requestLogPageSizeOptions, RequestLogStatusFilter, requestLogStatusOptions, Search, Select, - translateOptions, Trash2, useAppText, useCallback, useEffect, useMemo, useRef, + translateOptions, Trash2, useAppNumberLocale, useAppText, useCallback, useEffect, useMemo, useRef, useState } from "../shared"; type NetworkRequestTab = "body" | "header" | "query" | "raw" | "summary"; @@ -489,8 +489,9 @@ const LogRow = memo(function LogRow({ onToggle: (id: number) => void; }) { const t = useAppText(); + const numberLocale = useAppNumberLocale(); const createdAt = useMemo(() => formatLogDateTime(item.createdAt), [item.createdAt]); - const tokenSummary = useMemo(() => formatLogTokenSummary(item, t), [item, t]); + const tokenSummary = useMemo(() => formatLogTokenSummary(item, t, numberLocale), [item, numberLocale, t]); return (
@@ -542,6 +543,7 @@ function LogExpandedDetails({ entry: RequestLogEntry; }) { const t = useAppText(); + const numberLocale = useAppNumberLocale(); const hasCredentialInfo = logHasCredentialInfo(entry); return ( @@ -564,12 +566,12 @@ function LogExpandedDetails({ {entry.credentialChain.length ? ")} /> : null} {hasCredentialInfo ? : null} {entry.retryAttempts.length > 0 ? : null} - - - - - - + + + + + +
{entry.retryAttempts.length > 0 ? : null} diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index bedf620..7de0745 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -1563,6 +1563,11 @@ export function useAppText() { return useMemo(() => (value: string) => translateText(copy, value), [copy]); } +export function useAppNumberLocale(): Intl.LocalesArgument { + const copy = useContext(AppI18nContext); + return copy === appCopy.zh ? "zh-CN" : "en-US"; +} + export function translateText(copy: AppCopy, value: string): string { return copy.text[value] ?? value; } diff --git a/src/renderer/pages/home/shared/logs.ts b/src/renderer/pages/home/shared/logs.ts index c9b603b..9066f1d 100644 --- a/src/renderer/pages/home/shared/logs.ts +++ b/src/renderer/pages/home/shared/logs.ts @@ -393,7 +393,7 @@ export function formatLogDateTime(value: string): string { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${offset}`; } -export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) => string): string { +export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) => string, locale?: Intl.LocalesArgument): string { if ( entry.totalTokens === 0 && entry.inputTokens === 0 && @@ -405,18 +405,18 @@ export function formatLogTokenSummary(entry: RequestLogEntry, t: (value: string) return "-"; } const values = [ - `${formatCompactNumber(entry.inputTokens)} ${t("入")}`, - `${formatCompactNumber(entry.outputTokens)} ${t("出")}` + `${formatCompactNumber(entry.inputTokens, locale)} ${t("入")}`, + `${formatCompactNumber(entry.outputTokens, locale)} ${t("出")}` ]; if (entry.cacheReadTokens > 0) { - values.push(`${formatCompactNumber(entry.cacheReadTokens)} ${t("Cache")}`); + values.push(`${formatCompactNumber(entry.cacheReadTokens, locale)} ${t("Cache")}`); } if (entry.cacheWriteTokens > 0) { - values.push(`${formatCompactNumber(entry.cacheWriteTokens)} ${t("Cache write")}`); + values.push(`${formatCompactNumber(entry.cacheWriteTokens, locale)} ${t("Cache write")}`); } if (entry.reasoningTokens > 0) { - values.push(`${formatCompactNumber(entry.reasoningTokens)} ${t("Thinking")}`); + values.push(`${formatCompactNumber(entry.reasoningTokens, locale)} ${t("Thinking")}`); } return values.join(" "); diff --git a/src/renderer/pages/home/shared/usage.ts b/src/renderer/pages/home/shared/usage.ts index dd01129..76cc6c6 100644 --- a/src/renderer/pages/home/shared/usage.ts +++ b/src/renderer/pages/home/shared/usage.ts @@ -137,8 +137,8 @@ export function emptyUsageTotals(): UsageTotals { }; } -export function formatCompactNumber(value: number): string { - return new Intl.NumberFormat(undefined, { +export function formatCompactNumber(value: number, locale?: Intl.LocalesArgument): string { + return new Intl.NumberFormat(locale, { maximumFractionDigits: value >= 1000 ? 1 : 0, notation: value >= 10000 ? "compact" : "standard" }).format(value); diff --git a/src/renderer/pages/tray/components/account-panel.tsx b/src/renderer/pages/tray/components/account-panel.tsx index 75b17f4..10ad8f6 100644 --- a/src/renderer/pages/tray/components/account-panel.tsx +++ b/src/renderer/pages/tray/components/account-panel.tsx @@ -1,5 +1,5 @@ import { - accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, accountStatusClass, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue, + accountMetersForDisplay, accountProgressClass, accountProgressColor, accountSnapshotLabel, compareAccountSnapshots, formatAccountMeterTitle, formatAccountMeterValue, LoaderCircle, meterProgress, meterRemainingRatio, ProviderAccountMeter, ProviderAccountSnapshot, RefreshCw, TrayComponentVariants, useTrayText } from "../shared"; @@ -38,7 +38,7 @@ export function AccountSummaryPanel({

{accountSnapshotLabel(snapshot)}

))} @@ -436,6 +450,7 @@ function OverviewWidgetProperties({ onChangeBreakdownData, onChangeCategory, onChangeMetric, + onChangeShareData, onChangeSize, onChangeVariant, onRemove @@ -447,6 +462,7 @@ function OverviewWidgetProperties({ onChangeBreakdownData: (type: "model-distribution" | "token-mix") => void; onChangeCategory: (category: OverviewWidgetCategory) => void; onChangeMetric: (metric: OverviewMetricKind) => void; + onChangeShareData: (type: ShareOverviewWidgetType) => void; onChangeSize: (size: OverviewWidgetSize) => void; onChangeVariant: (variant: OverviewWidgetVariant) => void; onRemove: () => void; @@ -480,6 +496,9 @@ function OverviewWidgetProperties({ if (category === "breakdown") { onChangeBreakdownData(value as "model-distribution" | "token-mix"); } + if (category === "share-card") { + onChangeShareData(value as ShareOverviewWidgetType); + } }; return ( @@ -847,6 +866,8 @@ function OverviewWidgetRenderer({ content = ; } else if (widget.type === "client-analysis") { content = ; + } else if (isShareOverviewWidgetType(widget.type)) { + content = ; } else { content = ; } @@ -1464,11 +1485,19 @@ function overviewWidgetTemplates(): OverviewWidgetConfig[] { { enabled: true, id: "usage-trend", size: "3:2", type: "usage-trend", variant: "composed" }, { enabled: true, id: "token-activity", size: "4:2", type: "token-activity", variant: "heatmap" }, { enabled: true, id: "token-mix", size: "1:2", type: "token-mix", variant: "bars" }, - { enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" } + { enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" }, + { enabled: true, id: "share-usage-wrapped", size: "1:4", type: "share-usage-wrapped", variant: "card" }, + { enabled: true, id: "share-route-map", size: "1:4", type: "share-route-map", variant: "card" }, + { enabled: true, id: "share-model-leaderboard", size: "1:4", type: "share-model-leaderboard", variant: "card" }, + { enabled: true, id: "share-fuel-cockpit", size: "1:4", type: "share-fuel-cockpit", variant: "card" }, + { enabled: true, id: "share-token-calendar", size: "1:4", type: "share-token-calendar", variant: "card" }, + { enabled: true, id: "share-spend-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" } ]; } -type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "system-status" | "usage-trend"; +type ShareOverviewWidgetType = Extract; + +type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "share-card" | "system-status" | "usage-trend"; function overviewWidgetCategoryOptions(): Array<{ label: string; value: OverviewWidgetCategory }> { return [ @@ -1478,7 +1507,8 @@ function overviewWidgetCategoryOptions(): Array<{ label: string; value: Overview "usage-trend", "activity", "breakdown", - "analysis" + "analysis", + "share-card" ].map((category) => ({ label: overviewWidgetCategoryLabel(category as OverviewWidgetCategory), value: category as OverviewWidgetCategory @@ -1499,6 +1529,17 @@ function overviewBreakdownDataOptions(): Array<{ label: string; value: "model-di ]; } +function overviewShareCardDataOptions(): Array<{ label: string; value: ShareOverviewWidgetType }> { + return [ + { label: "AI Usage Wrapped", value: "share-usage-wrapped" }, + { label: "CCR Route Map", value: "share-route-map" }, + { label: "Model Leaderboard", value: "share-model-leaderboard" }, + { label: "AI Fuel Cockpit", value: "share-fuel-cockpit" }, + { label: "Token Calendar Poster", value: "share-token-calendar" }, + { label: "Spend Receipt", value: "share-spend-receipt" } + ]; +} + function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccounts: ProviderAccountSnapshot[]): Array<{ label: string; value: string }> { const category = overviewWidgetCategory(widget.type); if (category === "metric") { @@ -1526,6 +1567,9 @@ function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccount if (category === "breakdown") { return overviewBreakdownDataOptions(); } + if (category === "share-card") { + return overviewShareCardDataOptions(); + } return [{ label: "Usage over time", value: "usage-trend" }]; } @@ -1546,6 +1590,9 @@ function overviewWidgetDataValue(widget: OverviewWidgetConfig): string { if (category === "activity") { return "token-activity"; } + if (category === "share-card") { + return widget.type; + } return category; } @@ -1559,6 +1606,9 @@ function overviewWidgetCategory(type: OverviewWidgetType): OverviewWidgetCategor if (type === "token-activity") { return "activity"; } + if (isShareOverviewWidgetType(type)) { + return "share-card"; + } return type; } @@ -1572,10 +1622,16 @@ function overviewWidgetTypeForCategory(category: OverviewWidgetCategory, current if (category === "activity") { return "token-activity"; } + if (category === "share-card") { + return isShareOverviewWidgetType(currentType) ? currentType : "share-usage-wrapped"; + } return category; } function overviewWidgetTemplateKey(widget: OverviewWidgetConfig): string { + if (isShareOverviewWidgetType(widget.type)) { + return widget.type; + } return overviewWidgetCategory(widget.type); } @@ -1584,6 +1640,7 @@ function overviewWidgetCategoryLabel(category: OverviewWidgetCategory): string { if (category === "analysis") return "Analysis component"; if (category === "activity") return "Activity component"; if (category === "metric") return "Metric component"; + if (category === "share-card") return "Share card"; if (category === "system-status") return "Status component"; if (category === "breakdown") return "Breakdown component"; return "Trend component"; @@ -1594,11 +1651,24 @@ function overviewWidgetCategoryDescription(category: OverviewWidgetCategory): st if (category === "analysis") return "Client or provider"; if (category === "activity") return "Token activity heatmap"; if (category === "metric") return "Requests, tokens, cost"; + if (category === "share-card") return "Social media PNG cards"; if (category === "system-status") return "Status timeline"; if (category === "breakdown") return "Token or model distribution"; return "Usage over time"; } +function overviewWidgetPaletteTitle(widget: OverviewWidgetConfig): string { + return isShareOverviewWidgetType(widget.type) + ? overviewWidgetTypeLabel(widget.type) + : overviewWidgetCategoryLabel(overviewWidgetCategory(widget.type)); +} + +function overviewWidgetPaletteDescription(widget: OverviewWidgetConfig): string { + return isShareOverviewWidgetType(widget.type) + ? overviewWidgetCategoryLabel("share-card") + : overviewWidgetCategoryDescription(overviewWidgetCategory(widget.type)); +} + function overviewWidgetTitle(widget: OverviewWidgetConfig, translate: (value: string) => string): string { if (widget.type === "metric") { return translate(overviewMetricLabel(widget.metric ?? "requests")); @@ -1612,6 +1682,12 @@ function overviewWidgetTypeLabel(type: OverviewWidgetType): string { if (type === "metric") return "Metric"; if (type === "model-distribution") return "Model Distribution"; if (type === "provider-analysis") return "Provider Analysis"; + if (type === "share-fuel-cockpit") return "AI Fuel Cockpit"; + if (type === "share-model-leaderboard") return "Model Leaderboard"; + if (type === "share-route-map") return "CCR Route Map"; + if (type === "share-spend-receipt") return "Spend Receipt"; + if (type === "share-token-calendar") return "Token Calendar Poster"; + if (type === "share-usage-wrapped") return "AI Usage Wrapped"; if (type === "system-status") return "System status"; if (type === "token-activity") return "Activity"; if (type === "token-mix") return "Token Mix"; @@ -1665,12 +1741,26 @@ function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{ label: { label: "Compact", value: "compact" } ]; } + if (isShareOverviewWidgetType(type)) { + return [ + { label: "Card", value: "card" } + ]; + } return [ { label: "Table", value: "table" }, { label: "Compact", value: "compact" } ]; } +function isShareOverviewWidgetType(type: OverviewWidgetType): type is ShareOverviewWidgetType { + return type === "share-fuel-cockpit" || + type === "share-model-leaderboard" || + type === "share-route-map" || + type === "share-spend-receipt" || + type === "share-token-calendar" || + type === "share-usage-wrapped"; +} + function overviewWidgetSizeClass(size: OverviewWidgetSize): string { const { height, width } = overviewWidgetDimensions(size); return cn(overviewWidgetWidthClass(width), overviewWidgetHeightClass(height)); diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index 4cd8d89..fa2ee33 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -2,7 +2,7 @@ import { AddProviderDraft, AnimatedDisclosure, AnimatedIconSwap, AnimatedListItem, AnimatedPopover, AnimatePresence, AppConfig, Badge, Box, Braces, Button, Card, CardContent, CardHeader, CardTitle, Check, Checkbox, ChevronDown, ChevronRight, CircleAlert, cn, - compareProviderAccountSnapshots, Copy, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft, createProviderInstallLinkFromDraft, + compareProviderAccountSnapshots, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft, customProviderPresetId, defaultProviderAccountConfigForPreset, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, ExternalLink, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig, GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label, @@ -624,7 +624,7 @@ function ProviderPresetCombobox({ const [query, setQuery] = useState(""); const rootRef = useRef(null); const inputRef = useRef(null); - const selected = options.find((option) => option.value === value) ?? options[0]; + const selected = options.find((option) => option.value === value) ?? options.find((option) => option.value === ""); const normalizedQuery = query.trim().toLowerCase(); const filteredOptions = normalizedQuery ? options.filter((option) => providerPresetOptionMatchesQuery(option, normalizedQuery)) @@ -1232,9 +1232,9 @@ export function AddProviderForm({ const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl); const localAgentImport = draft.providerPlugins.length > 0; const providerPresetOptions = [ + { iconUrl: draft.icon, label: t("Other / custom API endpoint"), value: customProviderPresetId }, { label: t("Select preset provider"), value: "" }, - ...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id })), - { iconUrl: draft.icon, label: t("Other / custom API endpoint"), value: customProviderPresetId } + ...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id })) ]; const selectableProtocols = providerSelectableProtocolsFromProbe(probe); const hasConnectivityCheckInputs = Boolean( @@ -1800,7 +1800,6 @@ function ProviderUsageSettings({ const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState(); const [testError, setTestError] = useState(""); - const [copiedLink, setCopiedLink] = useState(false); const modeOptions = translateOptions(providerAccountModeOptions, t); useEffect(() => { @@ -1808,14 +1807,6 @@ function ProviderUsageSettings({ setTestError(""); }, [draft.accountMode, draft.usageRequestUrl, draft.usageRequestMethod]); - useEffect(() => { - if (!copiedLink) { - return; - } - const timer = window.setTimeout(() => setCopiedLink(false), 1300); - return () => window.clearTimeout(timer); - }, [copiedLink]); - async function testUsageRequest() { if (!window.ccr?.testProviderAccountConnector) { setTestError(t("Request failed.")); @@ -1855,17 +1846,6 @@ function ProviderUsageSettings({ } } - async function copyProviderPluginLink() { - const link = createProviderInstallLinkFromDraft(draft, probe); - if (typeof link === "string" && link.startsWith("ccr://")) { - await copyTextToClipboard(link); - setCopiedLink(true); - setTestError(""); - return; - } - setTestError(formatError(new Error(link))); - } - function selectPath(target: ProviderUsageFieldTarget, path: string) { onChange(providerUsageFieldPatch(target, path)); } @@ -1880,12 +1860,6 @@ function ProviderUsageSettings({ /> {t("Fetch usage")} -

{draft.accountEnabled ? ( diff --git a/src/renderer/pages/home/components/share-cards.tsx b/src/renderer/pages/home/components/share-cards.tsx new file mode 100644 index 0000000..3a68aba --- /dev/null +++ b/src/renderer/pages/home/components/share-cards.tsx @@ -0,0 +1,775 @@ +import { + Button, cn, Download, formatCompactNumber, formatDuration, formatPercent, formatProviderAccountMeterTitle, + formatProviderAccountMeterValue, formatUsdCost, LoaderCircle, primaryProviderAccountMeter, providerAccountMeterRemainingRatio, + providerAccountSnapshotKey, ReactNode, UsageStatsRange, UsageStatsSnapshot, useRef, useState, + useAppText, type OverviewWidgetType, type ProviderAccountMeter, type ProviderAccountSnapshot +} from "../shared"; +import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity"; + +type ShareCardTone = "amber" | "blue" | "emerald" | "indigo" | "rose" | "slate" | "teal"; + +type ShareCardWidgetProps = { + providerAccounts: ProviderAccountSnapshot[]; + type: OverviewWidgetType; + usageRange: UsageStatsRange; + usageStats: UsageStatsSnapshot; +}; + +type ShareCardPngExportOptions = { + exportId?: string; + output?: { + height: number; + width: number; + }; +}; + +type ShareCardPreparedExportTarget = { + canceled: boolean; + exportId?: string; +}; + +const shareCardExportCssWidth = 540; +const shareCardExportCssHeight = 960; +const shareCardExportPixelWidth = 1080; +const shareCardExportPixelHeight = 1920; + +const shareCardTones: Record = { + amber: { + accent: "#f59e0b", + background: "linear-gradient(135deg, #fffbeb 0%, #fef3c7 42%, #fff7ed 100%)", + border: "border-amber-200", + glow: "bg-amber-300/28", + muted: "text-amber-900/62", + text: "text-amber-950" + }, + blue: { + accent: "#2563eb", + background: "linear-gradient(135deg, #eff6ff 0%, #dbeafe 44%, #ecfeff 100%)", + border: "border-blue-200", + glow: "bg-blue-300/30", + muted: "text-blue-950/62", + text: "text-blue-950" + }, + emerald: { + accent: "#059669", + background: "linear-gradient(135deg, #ecfdf5 0%, #d1fae5 42%, #f0fdfa 100%)", + border: "border-emerald-200", + glow: "bg-emerald-300/28", + muted: "text-emerald-950/62", + text: "text-emerald-950" + }, + indigo: { + accent: "#4f46e5", + background: "linear-gradient(135deg, #eef2ff 0%, #e0e7ff 46%, #f5f3ff 100%)", + border: "border-indigo-200", + glow: "bg-indigo-300/30", + muted: "text-indigo-950/62", + text: "text-indigo-950" + }, + rose: { + accent: "#e11d48", + background: "linear-gradient(135deg, #fff1f2 0%, #ffe4e6 45%, #fff7ed 100%)", + border: "border-rose-200", + glow: "bg-rose-300/28", + muted: "text-rose-950/62", + text: "text-rose-950" + }, + slate: { + accent: "#475569", + background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 48%, #f1f5f9 100%)", + border: "border-slate-200", + glow: "bg-slate-300/32", + muted: "text-slate-700", + text: "text-slate-950" + }, + teal: { + accent: "#0f766e", + background: "linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 42%, #ecfeff 100%)", + border: "border-teal-200", + glow: "bg-teal-300/30", + muted: "text-teal-950/62", + text: "text-teal-950" + } +}; + +export function ShareCardWidget({ + providerAccounts, + type, + usageRange, + usageStats +}: ShareCardWidgetProps) { + const t = useAppText(); + const definition = shareCardDefinition(type); + + if (!definition) { + return null; + } + + return ( + + {type === "share-usage-wrapped" ? : null} + {type === "share-route-map" ? : null} + {type === "share-model-leaderboard" ? : null} + {type === "share-fuel-cockpit" ? : null} + {type === "share-token-calendar" ? : null} + {type === "share-spend-receipt" ? : null} + + ); +} + +function shareCardDefinition(type: OverviewWidgetType): { fileName: string; title: string } | undefined { + if (type === "share-usage-wrapped") return { fileName: "ccr-ai-usage-wrapped.png", title: "AI Usage Wrapped" }; + if (type === "share-route-map") return { fileName: "ccr-route-map.png", title: "CCR Route Map" }; + if (type === "share-model-leaderboard") return { fileName: "ccr-model-leaderboard.png", title: "Model Leaderboard" }; + if (type === "share-fuel-cockpit") return { fileName: "ccr-ai-fuel-cockpit.png", title: "AI Fuel Cockpit" }; + if (type === "share-token-calendar") return { fileName: "ccr-token-calendar-poster.png", title: "Token Calendar Poster" }; + if (type === "share-spend-receipt") return { fileName: "ccr-spend-receipt.png", title: "Spend Receipt" }; + return undefined; +} + +function ShareCardShell({ + children, + fileName, + title +}: { + children: ReactNode; + fileName: string; + title: string; +}) { + const t = useAppText(); + const cardRef = useRef(null); + const exportCardRef = useRef(null); + const [exporting, setExporting] = useState(false); + const [status, setStatus] = useState<"error" | "idle" | "saving">("idle"); + + async function saveImage() { + if (status === "saving") { + return; + } + setStatus("saving"); + let exportTarget: ShareCardPreparedExportTarget | undefined; + try { + if (window.ccr?.prepareImageExportTarget) { + exportTarget = await window.ccr.prepareImageExportTarget({ fileName }); + if (exportTarget.canceled) { + setStatus("idle"); + return; + } + } + setExporting(true); + await nextFrame(); + await nextFrame(); + const target = exportCardRef.current ?? cardRef.current; + if (!target) { + throw new Error("Export card is unavailable."); + } + await saveElementAsPng(target, fileName, { + exportId: exportTarget?.exportId, + output: { + height: shareCardExportPixelHeight, + width: shareCardExportPixelWidth + } + }); + setStatus("idle"); + } catch (error) { + console.error(error); + setStatus("error"); + } finally { + setExporting(false); + } + } + + return ( +
+
+
{title}
+
+ {status === "error" ? {t("Export failed")} : null} + +
+
+
+ {children} +
+ {exporting ? ( + + ) : null} +
+ ); +} + +function UsageWrappedCard({ usageStats }: { usageStats: UsageStatsSnapshot }) { + const t = useAppText(); + const totals = usageStats.totals; + const topModel = usageStats.models[0]; + const topProvider = usageStats.providerModels[0]; + const bestDay = [...usageStats.series].sort((a, b) => b.totalTokens - a.totalTokens)[0]; + const activity = buildTokenActivity(usageStats.series, { maxWeeks: 12, minWeeks: 8 }); + + return ( + + +
+
{formatCompactNumber(totals.totalTokens)}
+
{t("tokens routed through CCR")}
+
+
+ + + + +
+
+ + + +
+ +
+ ); +} + +function RouteMapCard({ usageStats }: { usageStats: UsageStatsSnapshot }) { + const t = useAppText(); + const routes = buildRouteRows(usageStats, t); + const total = routes.reduce((sum, row) => sum + row.value, 0) || 1; + + return ( + + +
+
+ {t("Client")} + CCR + {t("Model")} +
+
+ {routes.length > 0 ? routes.slice(0, 5).map((route, index) => { + const share = Math.max(8, (route.value / total) * 100); + return ( +
+ +
+ + + {index + 1} +
+ +
+ ); + }) : ( +
+ {t("No route activity")} +
+ )} +
+
+
+ row.client)))} /> + row.provider)))} /> + row.model)))} /> +
+ +
+ ); +} + +function ModelLeaderboardCard({ usageStats }: { usageStats: UsageStatsSnapshot }) { + const t = useAppText(); + const rows = usageStats.models.filter((row) => row.totalTokens > 0).slice(0, 5); + const max = Math.max(...rows.map((row) => row.totalTokens), 1); + + return ( + + +
+ {rows.length > 0 ? rows.map((row, index) => ( +
+
+ {index + 1} +
+
{row.label}
+
{row.provider ?? t("All providers")}
+
+
+
{formatPercent(row.maxShare)}
+
{formatCompactNumber(row.totalTokens)}
+
+
+
+
+
+
+ )) : ( + + )} +
+ + + ); +} + +function FuelCockpitCard({ providerAccounts }: { providerAccounts: ProviderAccountSnapshot[] }) { + const t = useAppText(); + const accountRows = providerAccounts + .filter((account) => account.meters.length > 0) + .map((account) => ({ + account, + meter: fuelMeter(account) + })) + .filter((row): row is { account: ProviderAccountSnapshot; meter: ProviderAccountMeter } => Boolean(row.meter)) + .slice(0, 3); + + return ( + + +
+ {accountRows.length > 0 ? accountRows.map(({ account, meter }, index) => { + const ratio = providerAccountMeterRemainingRatio(meter) ?? (meter.kind === "balance" ? 1 : 0); + return ( +
+ +
+
{safeProviderName(account.provider, t)}
+
{formatProviderAccountMeterTitle(meter, t)}
+
{formatProviderAccountMeterValue(meter)}
+
+
+ ); + }) : ( + + )} +
+ +
+ ); +} + +function TokenCalendarPosterCard({ usageStats }: { usageStats: UsageStatsSnapshot }) { + const t = useAppText(); + const activity = buildTokenActivity(usageStats.series, { maxWeeks: 26, minWeeks: 18 }); + + return ( + + +
+
+ {activity.cells.map((cell) => ( + + ))} +
+
+
+ + + +
+
+ {t("Less")} + {[0, 1, 2, 3, 4].map((intensity) => ( + + ))} + {t("More")} +
+ +
+ ); +} + +function SpendReceiptCard({ + usageRange, + usageStats +}: { + usageRange: UsageStatsRange; + usageStats: UsageStatsSnapshot; +}) { + const t = useAppText(); + const totals = usageStats.totals; + const rows = [ + { label: t("Range"), value: rangeLabel(usageRange, t) }, + { label: t("Requests"), value: formatCompactNumber(totals.requestCount) }, + { label: t("Input tokens"), value: formatCompactNumber(totals.inputTokens) }, + { label: t("Output tokens"), value: formatCompactNumber(totals.outputTokens) }, + { label: t("Cache tokens"), value: formatCompactNumber(totals.cacheTokens) }, + { label: t("Average latency"), value: formatDuration(totals.avgDurationMs) }, + { label: t("Success rate"), value: formatPercent(totals.successRate) } + ]; + + return ( + + +
+
+
{formatUsdCost(totals.costUsd)}
+
{t("Estimated cost")}
+
+
+ {rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+
{t("Total tokens")}
+
{formatCompactNumber(totals.totalTokens)}
+
+
+ +
+ ); +} + +function SharePoster({ + children, + tone +}: { + children: ReactNode; + tone: ShareCardTone; +}) { + const theme = shareCardTones[tone]; + return ( +
+ + +
+ {children} +
+
+ ); +} + +function SharePosterHeader({ + title, + tone +}: { + title: string; + tone: ShareCardTone; +}) { + const theme = shareCardTones[tone]; + return ( +
+
+

{title}

+
+
+ CCR +
+
+ ); +} + +function SharePosterFooter({ tone }: { tone: ShareCardTone }) { + const t = useAppText(); + const theme = shareCardTones[tone]; + return ( +
+ {t("Generated by CCR")} + {new Date().getFullYear()} +
+ ); +} + +function PosterStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function PosterHighlight({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function EmptyPosterState({ label, tone }: { label: string; tone: ShareCardTone }) { + const theme = shareCardTones[tone]; + return ( +
+ {label} +
+ ); +} + +function RoutePill({ align = "left", label }: { align?: "left" | "right"; label: string }) { + return ( +
+
{label}
+
+ ); +} + +function MiniRouteMetric({ label, value }: { label: string; value: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function CircularGauge({ color, ratio, value }: { color: string; ratio: number; value: string }) { + const radius = 41; + const circumference = 2 * Math.PI * radius; + const clamped = Math.max(0, Math.min(1, ratio)); + + return ( + + + + + {value} + + ); +} + +function buildRouteRows(usageStats: UsageStatsSnapshot, translate: (value: string) => string): Array<{ client: string; model: string; provider: string; value: number }> { + const rows = usageStats.clientModels + .filter((row) => row.totalTokens > 0) + .map((row) => ({ + client: row.client || row.label || translate("Agent"), + model: row.model || modelNameFromLabel(row.label), + provider: row.provider || translate("Provider"), + value: row.totalTokens + })); + + if (rows.length > 0) { + return rows.sort((a, b) => b.value - a.value); + } + + return usageStats.models + .filter((row) => row.totalTokens > 0) + .map((row) => ({ + client: translate("Agent"), + model: row.model || row.label, + provider: row.provider || translate("Provider"), + value: row.totalTokens + })) + .sort((a, b) => b.value - a.value); +} + +function modelNameFromLabel(label: string): string { + const parts = label.split("/"); + return parts[parts.length - 1]?.trim() || label; +} + +function fuelMeter(account: ProviderAccountSnapshot): ProviderAccountMeter | undefined { + return [...account.meters] + .filter((meter) => providerAccountMeterRemainingRatio(meter) !== undefined) + .sort((a, b) => (providerAccountMeterRemainingRatio(a) ?? 1) - (providerAccountMeterRemainingRatio(b) ?? 1))[0] ?? primaryProviderAccountMeter(account); +} + +function safeProviderName(value: string, translate: (value: string) => string): string { + return value.trim() || translate("Provider"); +} + +function uniqueCount(values: string[]): number { + return new Set(values.filter(Boolean)).size; +} + +function rangeLabel(range: UsageStatsRange, translate: (value: string) => string): string { + if (range === "today") { + return translate("Today"); + } + return translate(range); +} + +function calendarColor(intensity: TokenActivityCell["intensity"], inRange: boolean): string { + if (!inRange) return "rgba(225,29,72,.08)"; + if (intensity === 0) return "rgba(225,29,72,.13)"; + if (intensity === 1) return "rgba(225,29,72,.30)"; + if (intensity === 2) return "rgba(225,29,72,.50)"; + if (intensity === 3) return "rgba(225,29,72,.72)"; + return "rgba(225,29,72,.94)"; +} + +async function saveElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions = {}): Promise { + if (window.ccr?.captureElementPng) { + await nativeCaptureElementAsPng(element, fileName, options); + return; + } + await browserExportElementAsPng(element, fileName); +} + +async function nativeCaptureElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions): Promise { + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + await nextFrame(); + const rect = element.getBoundingClientRect(); + const result = await window.ccr?.captureElementPng?.({ + borderRadius: exportBorderRadius(element), + exportId: options.exportId, + fileName, + output: options.output, + rect: { + height: rect.height, + width: rect.width, + x: rect.x, + y: rect.y + } + }); + if (!result || result.canceled) { + return; + } +} + +function exportBorderRadius(element: HTMLElement): number | undefined { + const target = element.firstElementChild instanceof HTMLElement ? element.firstElementChild : element; + const styles = window.getComputedStyle(target); + const radii = [ + styles.borderTopLeftRadius, + styles.borderTopRightRadius, + styles.borderBottomRightRadius, + styles.borderBottomLeftRadius + ].map((value) => Number.parseFloat(value)).filter(Number.isFinite); + const radius = Math.max(0, ...radii); + return radius > 0 ? radius : undefined; +} + +function nextFrame(): Promise { + return new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +async function browserExportElementAsPng(element: HTMLElement, fileName: string): Promise { + const rect = element.getBoundingClientRect(); + const width = Math.ceil(rect.width); + const height = Math.ceil(rect.height); + if (width <= 0 || height <= 0) { + throw new Error("Cannot export an empty element."); + } + + const clone = element.cloneNode(true) as HTMLElement; + clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + clone.style.width = `${width}px`; + clone.style.height = `${height}px`; + inlineComputedStyles(element, clone); + + const serialized = new XMLSerializer().serializeToString(clone); + const svg = [ + ``, + `${serialized}`, + "" + ].join(""); + const svgUrl = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" })); + + try { + const image = await loadImage(svgUrl); + const scale = 3; + const canvas = document.createElement("canvas"); + canvas.width = width * scale; + canvas.height = height * scale; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Canvas rendering is unavailable."); + } + context.scale(scale, scale); + context.drawImage(image, 0, 0, width, height); + const pngBlob = await canvasToBlob(canvas); + const pngUrl = URL.createObjectURL(pngBlob); + try { + const link = document.createElement("a"); + link.href = pngUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(pngUrl); + } + } finally { + URL.revokeObjectURL(svgUrl); + } +} + +function inlineComputedStyles(source: Element, target: Element): void { + if (target instanceof HTMLElement || target instanceof SVGElement) { + const computed = window.getComputedStyle(source); + for (const property of Array.from(computed)) { + target.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property)); + } + } + + const sourceChildren = Array.from(source.children); + const targetChildren = Array.from(target.children); + sourceChildren.forEach((child, index) => { + const targetChild = targetChildren[index]; + if (targetChild) { + inlineComputedStyles(child, targetChild); + } + }); +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to render export image.")); + image.src = url; + }); +} + +function canvasToBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to encode PNG image.")); + } + }, "image/png"); + }); +} diff --git a/src/renderer/pages/home/shared/common.ts b/src/renderer/pages/home/shared/common.ts index f287bba..246a6fa 100644 --- a/src/renderer/pages/home/shared/common.ts +++ b/src/renderer/pages/home/shared/common.ts @@ -674,7 +674,7 @@ export function normalizeOverviewWidget(value: unknown): OverviewWidgetConfig | } export function normalizeOverviewWidgetType(value: unknown): OverviewWidgetType | undefined { - return typeof value === "string" && ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "system-status", "token-activity", "token-mix", "usage-trend"].includes(value) + return typeof value === "string" && ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "share-fuel-cockpit", "share-model-leaderboard", "share-route-map", "share-spend-receipt", "share-token-calendar", "share-usage-wrapped", "system-status", "token-activity", "token-mix", "usage-trend"].includes(value) ? value as OverviewWidgetType : undefined; } @@ -754,6 +754,11 @@ export function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{ { label: "Compact", value: "compact" } ]; } + if (isShareOverviewWidgetType(type)) { + return [ + { label: "Card", value: "card" } + ]; + } return [ { label: "Table", value: "table" }, { label: "Compact", value: "compact" } @@ -775,6 +780,7 @@ export function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWid if (type === "client-analysis" || type === "provider-analysis") return "2:2"; if (type === "usage-trend") return "3:2"; if (type === "system-status") return "4:1"; + if (isShareOverviewWidgetType(type)) return "1:4"; return "4:2"; } @@ -786,6 +792,7 @@ export function defaultOverviewWidgetVariant(type: OverviewWidgetType): Overview if (type === "token-activity") return "heatmap"; if (type === "usage-trend") return "composed"; if (type === "system-status") return "timeline"; + if (isShareOverviewWidgetType(type)) return "card"; return "table"; } @@ -795,12 +802,24 @@ export function constrainOverviewWidgetSize( variant: OverviewWidgetVariant, accountProvider?: string ): OverviewWidgetSize { + if (isShareOverviewWidgetType(type)) { + return overviewWidgetSizeAtLeast(size, 1, 4); + } if (type !== "account-balance" || variant !== "compact" || accountProvider?.trim()) { return size; } return overviewWidgetSizeAtLeast(size, 2, 2); } +function isShareOverviewWidgetType(type: OverviewWidgetType): boolean { + return type === "share-fuel-cockpit" || + type === "share-model-leaderboard" || + type === "share-route-map" || + type === "share-spend-receipt" || + type === "share-token-calendar" || + type === "share-usage-wrapped"; +} + function overviewWidgetSizeAtLeast(size: OverviewWidgetSize, minWidth: 1 | 2 | 3 | 4, minHeight: 1 | 2 | 3 | 4): OverviewWidgetSize { const [widthText, heightText] = size.split(":"); const width = overviewWidgetDimensionAtLeast(widthText, minWidth); diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index 7de0745..a0ba92e 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -279,6 +279,7 @@ export const appCopy: Record = { "Bar": "Bar", "Bars": "Bars", "Breakdown component": "Breakdown component", + "Card": "Card", "Cards": "Cards", "Change widget type": "Change widget type", "Client Analysis": "Client Analysis", @@ -340,6 +341,27 @@ export const appCopy: Record = { "Widget": "Widget", "Widget size": "Widget size", "Wide": "Wide", + "Share cards": "Share cards", + "Share card": "Share card", + "Save social-ready cards as PNG images": "Save social-ready cards as PNG images", + "Social media PNG cards": "Social media PNG cards", + "PNG export": "PNG export", + "Save image": "Save image", + "Saving": "Saving", + "Export failed": "Export failed", + "AI Usage Wrapped": "AI Usage Wrapped", + "CCR Route Map": "CCR Route Map", + "Model Leaderboard": "Model Leaderboard", + "AI Fuel Cockpit": "AI Fuel Cockpit", + "Token Calendar Poster": "Token Calendar Poster", + "Spend Receipt": "Spend Receipt", + "tokens routed through CCR": "tokens routed through CCR", + "Top model": "Top model", + "Top provider": "Top provider", + "No provider activity": "No provider activity", + "Peak day": "Peak day", + "Generated by CCR": "Generated by CCR", + "Range": "Range", "Virtual model": "Fusion", "Virtual Models": "Fusion", "Add Virtual Model": "Add Fusion", @@ -967,6 +989,7 @@ export const appCopy: Record = { "Bar": "柱状图", "Bars": "横条", "Breakdown component": "构成组件", + "Card": "卡片", "Cards": "卡片", "Change widget type": "切换组件类型", "Client or provider": "客户端或供应商", @@ -1034,6 +1057,27 @@ export const appCopy: Record = { "Widget": "组件", "Widget size": "组件大小", "Wide": "宽", + "Share cards": "分享卡片", + "Share card": "分享卡片", + "Save social-ready cards as PNG images": "将适合社交媒体发布的卡片保存为 PNG 图片", + "Social media PNG cards": "适合社交媒体发布的 PNG 卡片", + "PNG export": "PNG 导出", + "Save image": "保存图片", + "Saving": "正在保存", + "Export failed": "导出失败", + "AI Usage Wrapped": "AI 用量年报", + "CCR Route Map": "CCR 路由图", + "Model Leaderboard": "模型排行榜", + "AI Fuel Cockpit": "AI 燃料仪表", + "Token Calendar Poster": "Token 日历海报", + "Spend Receipt": "消费小票", + "tokens routed through CCR": "通过 CCR 路由的令牌", + "Top model": "最高频模型", + "Top provider": "最高频供应商", + "No provider activity": "暂无供应商活动", + "Peak day": "峰值日期", + "Generated by CCR": "由 CCR 生成", + "Range": "范围", "W": "三", "Yes": "是", "Set as default provider": "设为默认供应商", @@ -1123,6 +1167,7 @@ export const appCopy: Record = { "Base model mode": "基础模型模式", "Client tools": "客户端工具", "Client-visible": "客户端可见", + "Clients": "客户端", "Command": "命令", "Command is required.": "命令不能为空。", "Decorate only": "仅装饰", diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 3ac4e52..b9be193 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -6,8 +6,12 @@ import type { AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, AppConfig, + AppCaptureElementPngRequest, + AppCaptureElementPngResult, AppDataExportResult, AppInfo, + AppImageExportTargetRequest, + AppImageExportTargetResult, AppSaveConfigOptions, AppUpdateStatus, ApiKeyConfig, @@ -73,6 +77,7 @@ declare global { applyClaudeAppGateway: (config?: AppConfig) => Promise; applyProfile: () => Promise; cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => Promise; + captureElementPng?: (request: AppCaptureElementPngRequest) => Promise; checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => Promise; closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise; clearProxyNetworkCaptures: () => Promise; @@ -108,6 +113,7 @@ declare global { openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => Promise; openExternal: (url: string) => Promise; openProfile: (request: ProfileOpenRequest) => Promise; + prepareImageExportTarget?: (request: AppImageExportTargetRequest) => Promise; probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => Promise; probeProvider: (request: GatewayProviderProbeRequest) => Promise; quitApp: () => Promise; diff --git a/src/shared/app.ts b/src/shared/app.ts index 2e83828..94ac0b5 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -18,6 +18,37 @@ export type AppDataExportResult = { file?: string; }; +export type AppCaptureElementPngRequest = { + borderRadius?: number; + exportId?: string; + fileName: string; + output?: { + height: number; + width: number; + }; + rect: { + height: number; + width: number; + x: number; + y: number; + }; +}; + +export type AppCaptureElementPngResult = { + canceled: boolean; + file?: string; +}; + +export type AppImageExportTargetRequest = { + fileName: string; +}; + +export type AppImageExportTargetResult = { + canceled: boolean; + exportId?: string; + file?: string; +}; + export type AppUpdateState = | "idle" | "checking" @@ -851,6 +882,12 @@ export type OverviewWidgetType = | "metric" | "model-distribution" | "provider-analysis" + | "share-fuel-cockpit" + | "share-model-leaderboard" + | "share-route-map" + | "share-spend-receipt" + | "share-token-calendar" + | "share-usage-wrapped" | "system-status" | "token-activity" | "token-mix" diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 1bfe296..872fdaa 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -1,5 +1,6 @@ export const IPC_CHANNELS = { appBeforeQuit: "ccr:app:before-quit", + appCaptureElementPng: "ccr:app:capture-element-png", appCloseTray: "ccr:app:close-tray", appDetectProviderIcon: "ccr:app:detect-provider-icon", appExportData: "ccr:app:export-data", @@ -46,6 +47,7 @@ export const IPC_CHANNELS = { appProbeProviderCandidates: "ccr:app:probe-provider-candidates", appProviderDeepLink: "ccr:app:provider-deep-link", appGetPluginMarketplace: "ccr:app:get-plugin-marketplace", + appPrepareImageExportTarget: "ccr:app:prepare-image-export-target", appQuit: "ccr:app:quit", appRevealProxyCertificate: "ccr:app:reveal-proxy-certificate", appRestartProxy: "ccr:app:restart-proxy", diff --git a/tests/renderer/overview-components.test.tsx b/tests/renderer/overview-components.test.tsx index b8e5b84..4d8ee12 100644 --- a/tests/renderer/overview-components.test.tsx +++ b/tests/renderer/overview-components.test.tsx @@ -20,7 +20,13 @@ test("OverviewView renders every overview widget type", () => { { enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "stacked" }, { enabled: true, id: "models", size: "2:2", type: "model-distribution", variant: "donut" }, { enabled: true, id: "clients", size: "4:2", type: "client-analysis", variant: "table" }, - { enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" } + { enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" }, + { enabled: true, id: "share-usage", size: "1:4", type: "share-usage-wrapped", variant: "card" }, + { enabled: true, id: "share-routes", size: "1:4", type: "share-route-map", variant: "card" }, + { enabled: true, id: "share-models", size: "1:4", type: "share-model-leaderboard", variant: "card" }, + { enabled: true, id: "share-fuel", size: "1:4", type: "share-fuel-cockpit", variant: "card" }, + { enabled: true, id: "share-calendar", size: "1:4", type: "share-token-calendar", variant: "card" }, + { enabled: true, id: "share-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" } ]; const html = renderToStaticMarkup( @@ -51,6 +57,13 @@ test("OverviewView renders every overview widget type", () => { assert.match(html, /Provider Analysis/); assert.match(html, /claude-code/); assert.match(html, /openai/); + assert.match(html, /Save image/); + assert.match(html, /AI Usage Wrapped/); + assert.match(html, /CCR Route Map/); + assert.match(html, /Model Leaderboard/); + assert.match(html, /AI Fuel Cockpit/); + assert.match(html, /Token Calendar Poster/); + assert.match(html, /Spend Receipt/); }); test("OverviewView renders the empty widget layout state", () => { From 98375b7f4e939b3a55f92273efe9e74ddab74c8e Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 30 Jun 2026 17:45:19 +0800 Subject: [PATCH 016/101] Add Claude App profile opening and HTML PNG export --- src/main/cli.ts | 58 ++++++-- src/main/ipc.ts | 132 ++++++++++++++++-- src/main/preload.ts | 3 + src/main/profile-launch-core.ts | 12 +- .../pages/home/components/share-cards.tsx | 98 ++++++++++--- src/renderer/pages/home/shared/profiles.ts | 2 +- src/renderer/types/electron.d.ts | 3 + src/shared/app.ts | 20 +++ src/shared/ipc-channels.ts | 1 + tests/main/profile-launch-core.test.mjs | 17 ++- 10 files changed, 302 insertions(+), 44 deletions(-) diff --git a/src/main/cli.ts b/src/main/cli.ts index 0053a34..8964d4f 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process"; import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { assertAvailableGatewayModels, type ProfileOpenSurface } from "../shared/app"; +import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; +import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch"; import { loadAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service"; import { ensureProfileGateway } from "./profile-launch-service"; -import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core"; +import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core"; import { startWebManagementServer } from "./web-management-server"; type ProfileCliOptions = { @@ -93,30 +95,50 @@ async function main(): Promise { assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, profileOptions.profileRef); - const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); + const surface = profileOptions.surface ?? defaultProfileOpenSurface(profile); const resolvedSurface = resolveProfileOpenSurface(profile, surface); - const launchConfig = resolvedSurface === "cli" - ? await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { reuseExisting: true, startIfMissing: false }) - : config; + if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { + throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); + } + if (profile.agent === "claude-code" && resolvedSurface === "app" && profileOptions.agentArgs.length > 0) { + throw new Error("Claude App profiles do not support agent arguments."); + } + + const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", { + reuseExisting: true, + startIfMissing: false + }); if (resolvedSurface === "cli") { const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); if (!runtimeResult.ok) { throw new Error(runtimeResult.message); } } - if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { - throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); + if (profile.agent === "claude-code" && resolvedSurface === "app") { + applyClaudeAppGatewayConfig(launchConfig); + applyClaudeAppGatewayConfig(launchConfig, { + backup: false, + dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile), + refreshModelDiscoveryCache: true + }); + const launch = await launchClaudeAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open Claude App: ${spawnError}`); + } + process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`); + return; } if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) { if (profile.agent === "zcode") { - const launch = launchZcodeAppProfile(configDir, profile, config); + const launch = launchZcodeAppProfile(configDir, profile, launchConfig); const spawnError = await waitForImmediateSpawnError(launch.child, 500); if (spawnError) { throw new Error(`Failed to open ZCode App: ${spawnError}`); } process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`); } else { - const launch = launchCodexAppProfile(configDir, profile, config); + const launch = launchCodexAppProfile(configDir, profile, launchConfig); const spawnError = await waitForImmediateSpawnError(launch.child, 500); if (spawnError) { throw new Error(`Failed to open Codex App: ${spawnError}`); @@ -197,6 +219,16 @@ function parseArgs(args: string[]): CliOptions { return options; } +function profileAppName(profile: Pick): string { + if (profile.agent === "claude-code") { + return "Claude App"; + } + if (profile.agent === "zcode") { + return "ZCode App"; + } + return "Codex App"; +} + function parseStopArgs(args: string[]): StopCliOptions { const options: StopCliOptions = { command: "stop", @@ -376,13 +408,13 @@ function printHelp(exitCode: number): void { "Usage:", " ccr start [--host ] [--port ] [--open] [--no-gateway]", " ccr stop", - " ccr [-- ]", + " ccr [cli|app] [-- ]", "", "Examples:", " ccr start", " ccr stop", - " ccr Codex cli", - " ccr default-codex cli -- --model gpt-5-codex", + " ccr Codex", + " ccr default-codex -- --model gpt-5-codex", " ccr default-codex app" ].join("\n"); const stream = exitCode === 0 ? process.stdout : process.stderr; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4d55e3f..bdf8cd2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron"; import { randomUUID } from "node:crypto"; -import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import net from "node:net"; import path from "node:path"; import { deflateSync, inflateSync } from "node:zlib"; @@ -31,7 +31,7 @@ import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "./usage-store"; import windowsManager from "./windows"; -import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; +import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; const pluginMarketplace: PluginMarketplaceEntry[] = [ { @@ -79,6 +79,9 @@ ipcMain.handle(IPC_CHANNELS.appCaptureElementPng, async (event, request: AppCapt ipcMain.handle(IPC_CHANNELS.appPrepareImageExportTarget, async (event, request: AppImageExportTargetRequest): Promise => { return prepareImageExportTarget(BrowserWindow.fromWebContents(event.sender), request); }); +ipcMain.handle(IPC_CHANNELS.appRenderHtmlPng, async (event, request: AppRenderHtmlPngRequest): Promise => { + return renderHtmlPng(BrowserWindow.fromWebContents(event.sender), request); +}); ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig()); ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => { @@ -554,10 +557,7 @@ async function captureElementPng(window: BrowserWindow | null, request: AppCaptu } const rect = sanitizeCaptureRect(request.rect); - const targetFile = request.exportId ? consumeImageExportTarget(request.exportId) : undefined; - const result = targetFile - ? { canceled: false, filePath: targetFile } - : await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName)); + const result = await imageExportFile(window, request.fileName, request.exportId); if (result.canceled || !result.filePath) { return { canceled: true }; } @@ -568,6 +568,62 @@ async function captureElementPng(window: BrowserWindow | null, request: AppCaptu return { canceled: false, file: result.filePath }; } +async function renderHtmlPng(window: BrowserWindow | null, request: AppRenderHtmlPngRequest): Promise { + const html = typeof request.html === "string" ? request.html : ""; + if (!html.trim()) { + throw new Error("Export HTML is empty."); + } + + const size = sanitizeRenderSize(request.size); + const result = await imageExportFile(window, request.fileName, request.exportId); + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const renderWindow = new BrowserWindow({ + backgroundColor: "#00000000", + frame: false, + height: size.height, + paintWhenInitiallyHidden: true, + resizable: false, + show: false, + skipTaskbar: true, + transparent: true, + useContentSize: true, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + offscreen: true, + sandbox: true + }, + width: size.width + }); + + const tempDir = mkdtempSync(path.join(app.getPath("temp"), "ccr-export-")); + const tempHtmlFile = path.join(tempDir, "export.html"); + writeFileSync(tempHtmlFile, html, { encoding: "utf8", mode: 0o600 }); + + try { + await renderWindow.loadFile(tempHtmlFile); + await waitForExportWindowPaint(renderWindow); + const image = await renderWindow.webContents.capturePage({ + height: size.height, + width: size.width, + x: 0, + y: 0 + }); + const png = image.toPNG(); + writeFileSync(result.filePath, pngWithExportProcessing(png, request, size.width), { mode: 0o600 }); + return { canceled: false, file: result.filePath }; + } finally { + if (!renderWindow.isDestroyed()) { + renderWindow.destroy(); + } + rmSync(tempDir, { force: true, recursive: true }); + } +} + async function prepareImageExportTarget(window: BrowserWindow | null, request: AppImageExportTargetRequest): Promise { const result = window ? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName)) @@ -585,6 +641,15 @@ async function prepareImageExportTarget(window: BrowserWindow | null, request: A }; } +async function imageExportFile(window: BrowserWindow | null, fileName: string, exportId?: string): Promise<{ canceled: boolean; filePath?: string }> { + const targetFile = exportId ? consumeImageExportTarget(exportId) : undefined; + return targetFile + ? { canceled: false, filePath: targetFile } + : window + ? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(fileName)) + : await dialog.showSaveDialog(shareCardSaveDialogOptions(fileName)); +} + function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions { return { buttonLabel: "Export", @@ -623,6 +688,29 @@ function sanitizeCaptureRect(rect: AppCaptureElementPngRequest["rect"]): Rectang }; } +function sanitizeRenderSize(size: AppRenderHtmlPngRequest["size"]): { height: number; width: number } { + const width = finiteNumber(size?.width, "render width"); + const height = finiteNumber(size?.height, "render height"); + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + throw new Error("Render size is out of range."); + } + return { + height: Math.ceil(height), + width: Math.ceil(width) + }; +} + +async function waitForExportWindowPaint(window: BrowserWindow): Promise { + await window.webContents.executeJavaScript(` + new Promise((resolve) => { + const fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready.catch(() => undefined) : Promise.resolve(); + fontsReady.then(() => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))); + }); + }); + `); +} + function finiteNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`Invalid ${label}.`); @@ -647,6 +735,14 @@ function consumeImageExportTarget(exportId: string): string { const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +type PngExportProcessingRequest = { + borderRadius?: number; + output?: { + height: number; + width: number; + }; +}; + type DecodedPngPixels = { bitDepth: number; colorType: 2 | 6; @@ -655,7 +751,7 @@ type DecodedPngPixels = { width: number; }; -function pngWithExportProcessing(png: Buffer, request: AppCaptureElementPngRequest, cssWidth: number): Buffer { +function pngWithExportProcessing(png: Buffer, request: PngExportProcessingRequest, cssWidth: number): Buffer { const radius = typeof request.borderRadius === "number" && Number.isFinite(request.borderRadius) ? Math.max(0, request.borderRadius) : 0; const outputWidth = sanitizePngOutputDimension(request.output?.width); const outputHeight = sanitizePngOutputDimension(request.output?.height); @@ -680,10 +776,30 @@ function pngWithExportProcessing(png: Buffer, request: AppCaptureElementPngReque return encodeRgbaPng(width, height, rgba); } catch (error) { console.warn(`[export] Failed to process exported PNG: ${formatError(error)}`); + const resized = resizePngWithNativeImage(png, outputWidth, outputHeight); + if (resized) { + return resized; + } return png; } } +function resizePngWithNativeImage(png: Buffer, width?: number, height?: number): Buffer | undefined { + if (!width || !height) { + return undefined; + } + try { + const image = nativeImage.createFromBuffer(png); + if (image.isEmpty()) { + return undefined; + } + return image.resize({ height, width }).toPNG(); + } catch (error) { + console.warn(`[export] Failed to resize exported PNG fallback: ${formatError(error)}`); + return undefined; + } +} + function sanitizePngOutputDimension(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; diff --git a/src/main/preload.ts b/src/main/preload.ts index f5f40d9..812d823 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -13,6 +13,8 @@ import type { AppInfo, AppImageExportTargetRequest, AppImageExportTargetResult, + AppRenderHtmlPngRequest, + AppRenderHtmlPngResult, AppSaveConfigOptions, AppUpdateStatus, ApiKeyConfig, @@ -131,6 +133,7 @@ contextBridge.exposeInMainWorld("ccr", { probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise, quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise, revealProxyCertificate: () => invoke(IPC_CHANNELS.appRevealProxyCertificate) as Promise, + renderHtmlPng: (request: AppRenderHtmlPngRequest) => invoke(IPC_CHANNELS.appRenderHtmlPng, request) as Promise, restartGateway: () => invoke(IPC_CHANNELS.appRestartGateway) as Promise, restartProxy: () => invoke(IPC_CHANNELS.appRestartProxy) as Promise, saveApiKeys: (apiKeys: ApiKeyConfig[]) => invoke(IPC_CHANNELS.appSaveApiKeys, apiKeys) as Promise, diff --git a/src/main/profile-launch-core.ts b/src/main/profile-launch-core.ts index 5f50af2..e737784 100644 --- a/src/main/profile-launch-core.ts +++ b/src/main/profile-launch-core.ts @@ -69,14 +69,22 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof return surfaces[0]; } +export function defaultProfileOpenSurface(profile: Pick): ProfileOpenSurface { + return profile.agent === "zcode" ? "app" : "cli"; +} + export function profileOpenCommand( profile: ProfileConfig, - surface: ProfileOpenSurface = profile.agent === "zcode" ? "app" : "cli", + surface: ProfileOpenSurface = defaultProfileOpenSurface(profile), command = "ccr", profileRef = profile.name?.trim() || profile.id ): string { const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote; - return [quote(command), quote(profileRef), surface].join(" "); + const parts = [quote(command), quote(profileRef)]; + if (surface === "app") { + parts.push(surface); + } + return parts.join(" "); } export function buildProfileLaunchPlan( diff --git a/src/renderer/pages/home/components/share-cards.tsx b/src/renderer/pages/home/components/share-cards.tsx index 3a68aba..b356f20 100644 --- a/src/renderer/pages/home/components/share-cards.tsx +++ b/src/renderer/pages/home/components/share-cards.tsx @@ -29,9 +29,9 @@ type ShareCardPreparedExportTarget = { }; const shareCardExportCssWidth = 540; -const shareCardExportCssHeight = 960; +const shareCardExportCssHeight = 675; const shareCardExportPixelWidth = 1080; -const shareCardExportPixelHeight = 1920; +const shareCardExportPixelHeight = 1350; const shareCardTones: Record = { amber: { @@ -159,7 +159,7 @@ function ShareCardShell({ setExporting(true); await nextFrame(); await nextFrame(); - const target = exportCardRef.current ?? cardRef.current; + const target = exportCardRef.current; if (!target) { throw new Error("Export card is unavailable."); } @@ -185,9 +185,16 @@ function ShareCardShell({
{title}
{status === "error" ? {t("Export failed")} : null} -
@@ -197,10 +204,11 @@ function ShareCardShell({ {exporting ? (
@@ -350,11 +343,6 @@ export function ConfigureClaudeDesignDialog({ onChangeRule(index, { pattern: event.target.value })} /> ) : null} - {rule.type === "long-context" ? ( - - onChangeRule(index, { threshold: event.target.value })} /> - - ) : null} {isClaudeDesignStaticRuleType(rule.type) ? (
{t(claudeDesignRouteRuleTypeLabel(rule.type))}
) : null} diff --git a/src/renderer/pages/home/components/network-logs.tsx b/src/renderer/pages/home/components/network-logs.tsx index 48117dc..c92aa02 100644 --- a/src/renderer/pages/home/components/network-logs.tsx +++ b/src/renderer/pages/home/components/network-logs.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { Maximize2, X } from "lucide-react"; import { AnimatedIconSwap, Check, ChevronDown, ChevronLeft, ChevronRight, clampNumber, clientInitial, cn, Copy, copyTextToClipboard, @@ -21,6 +22,26 @@ const logJsonAutoExpandEntryLimit = 60; const logJsonContainerPreviewLimit = 80; const logJsonAutoExpandTextLimit = 160 * 1024; const logBodyViewCache = new Map>(); +type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration"; +type LogTableColumn = { + id: LogTableColumnId; + minWidth: number; +}; +type LogTableColumnWidths = Partial>; +type LogTableGridStyle = { + gridTemplateColumns: string; + minWidth: string; +}; + +const baseLogTableColumns: LogTableColumn[] = [ + { id: "time", minWidth: 150 }, + { id: "status", minWidth: 116 }, + { id: "stream", minWidth: 108 }, + { id: "model", minWidth: 180 }, + { id: "tokens", minWidth: 140 }, + { id: "duration", minWidth: 92 } +]; +const credentialLogTableColumn: LogTableColumn = { id: "credential", minWidth: 128 }; export function NetworkingView({ clearCaptures, @@ -287,14 +308,21 @@ export function LogsView({ const [detailById, setDetailById] = useState>({}); const [detailErrorById, setDetailErrorById] = useState>({}); const [detailLoadingId, setDetailLoadingId] = useState(); + const [logColumnWidths, setLogColumnWidths] = useState({}); + const logTableHeaderRef = useRef(null); const firstItem = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1; const lastItem = Math.min(page.total, page.page * page.pageSize); const hasAnyCredentialInfo = Boolean(filter.credential) || page.options.credentials.length > 0 || page.items.some(logHasCredentialInfo); + const visibleLogColumns = useMemo(() => getLogTableColumns(hasAnyCredentialInfo), [hasAnyCredentialInfo]); const logTableGridClass = hasAnyCredentialInfo ? "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.78fr)_minmax(120px,0.42fr)_minmax(0,0.68fr)_82px]" : "grid-cols-[minmax(0,0.8fr)_minmax(92px,0.38fr)_minmax(98px,0.4fr)_minmax(0,0.9fr)_minmax(0,0.74fr)_82px]"; + const logTableGridStyle = useMemo( + () => createLogTableGridStyle(visibleLogColumns, logColumnWidths), + [logColumnWidths, visibleLogColumns] + ); const loadLogDetail = useCallback((id: number) => { if (detailById[id] || detailLoadingId === id || !window.ccr?.getRequestLogDetail) { return; @@ -333,6 +361,55 @@ export function LogsView({ setExpandedId(undefined); }, [expandedId, page.items]); + function startLogColumnResize(columnIndex: number, event: ReactPointerEvent) { + const header = logTableHeaderRef.current; + const leftColumn = visibleLogColumns[columnIndex]; + const rightColumn = visibleLogColumns[columnIndex + 1]; + if (!header || !leftColumn || !rightColumn) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const measuredWidths: LogTableColumnWidths = {}; + visibleLogColumns.forEach((column, index) => { + const width = header.children[index]?.getBoundingClientRect().width ?? column.minWidth; + measuredWidths[column.id] = Math.round(clampNumber(width, column.minWidth, Number.MAX_SAFE_INTEGER)); + }); + + const startX = event.clientX; + const startLeftWidth = measuredWidths[leftColumn.id] ?? leftColumn.minWidth; + const startRightWidth = measuredWidths[rightColumn.id] ?? rightColumn.minWidth; + const minDelta = leftColumn.minWidth - startLeftWidth; + const maxDelta = startRightWidth - rightColumn.minWidth; + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const update = (pointerEvent: PointerEvent) => { + const delta = clampNumber(pointerEvent.clientX - startX, minDelta, maxDelta); + setLogColumnWidths((current) => ({ + ...current, + ...measuredWidths, + [leftColumn.id]: Math.round(startLeftWidth + delta), + [rightColumn.id]: Math.round(startRightWidth - delta) + })); + }; + const stop = () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + window.removeEventListener("pointermove", update); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + + window.addEventListener("pointermove", update); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + } + return (
-
- - - - - {hasAnyCredentialInfo ? : null} - - +
+ {visibleLogColumns.map((column, index) => ( + startLogColumnResize(index, event) : undefined} + resizeLabel={t("Resize column width")} + /> + ))}
{page.items.length === 0 ? ( @@ -458,6 +540,7 @@ export function LogsView({ item={expandedId === item.id ? detailById[item.id] ?? item : item} key={item.id} logTableGridClass={logTableGridClass} + logTableGridStyle={logTableGridStyle} onToggle={toggleExpandedLog} /> ))} @@ -469,6 +552,51 @@ export function LogsView({ ); } +function getLogTableColumns(hasCredentialColumn: boolean): LogTableColumn[] { + if (!hasCredentialColumn) { + return baseLogTableColumns; + } + return [ + ...baseLogTableColumns.slice(0, 4), + credentialLogTableColumn, + ...baseLogTableColumns.slice(4) + ]; +} + +function createLogTableGridStyle(columns: LogTableColumn[], widths: LogTableColumnWidths): LogTableGridStyle | undefined { + const columnWidths = columns.map((column) => widths[column.id]); + if (columnWidths.some((width) => typeof width !== "number")) { + return undefined; + } + + return { + gridTemplateColumns: columns.map((column, index) => { + const width = Math.max(column.minWidth, Math.round(columnWidths[index] ?? column.minWidth)); + return `minmax(${column.minWidth}px, ${width}fr)`; + }).join(" "), + minWidth: `${columns.reduce((total, column) => total + column.minWidth, 0)}px` + }; +} + +function logTableColumnLabel(columnId: LogTableColumnId, t: (value: string) => string): string { + switch (columnId) { + case "time": + return t("时间"); + case "status": + return t("状态"); + case "stream": + return t("Stream"); + case "model": + return t("模型"); + case "credential": + return t("Credential"); + case "tokens": + return t("令牌"); + case "duration": + return t("持续时间"); + } +} + const LogRow = memo(function LogRow({ detailError, detailLoading, @@ -477,6 +605,7 @@ const LogRow = memo(function LogRow({ index, item, logTableGridClass, + logTableGridStyle, onToggle }: { detailError?: string; @@ -486,6 +615,7 @@ const LogRow = memo(function LogRow({ index: number; item: RequestLogEntry; logTableGridClass: string; + logTableGridStyle?: LogTableGridStyle; onToggle: (id: number) => void; }) { const t = useAppText(); @@ -504,6 +634,7 @@ const LogRow = memo(function LogRow({ expanded && "network-row-selected" )} onClick={() => onToggle(item.id)} + style={logTableGridStyle} type="button" >
@@ -707,6 +838,7 @@ function LogJsonPanel({ const t = useAppText(); const [selectedTab, setSelectedTab] = useState("body"); const [preferTextBody, setPreferTextBody] = useState(false); + const [fullscreenOpen, setFullscreenOpen] = useState(false); const [query, setQuery] = useState(""); const bodyKey = logBodyCacheKey(body); const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]); @@ -721,6 +853,19 @@ function LogJsonPanel({ setPreferTextBody(false); }, [bodyKey]); + useEffect(() => { + if (!fullscreenOpen) { + return; + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setFullscreenOpen(false); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [fullscreenOpen]); + function toggleJsonPath(path: string) { setExpandedJsonPaths((current) => { const next = new Set(current); @@ -757,36 +902,49 @@ function LogJsonPanel({
{selectedTab === "body" ? ( <> -
-
- - setQuery(event.target.value)} - placeholder={t("筛选 JSON...")} - value={query} - /> -
- {bodyView.json !== undefined && query.trim() === "" ? ( - - ) : null} - {body?.contentType ? {body.contentType} : null} - {body?.truncated ? {t("truncated")} : null} -
- - {showJsonTree ? ( - - ) : ( -
{visible}
- )} + setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + title={title} + /> + setFullscreenOpen(true)} + > + + {fullscreenOpen ? ( + setFullscreenOpen(false)} + onQueryChange={setQuery} + onToggleJsonPath={toggleJsonPath} + onToggleTextBody={() => setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + showJsonTree={showJsonTree} + subtitle={subtitle} + title={title} + visible={visible} + value={bodyView.json} + /> + ) : null} ) : (
@@ -798,6 +956,153 @@ function LogJsonPanel({ ); } +function LogJsonBodyToolbar({ + body, + bodyView, + onQueryChange, + onToggleTextBody, + preferTextBody, + query, + title +}: { + body?: RequestLogBody; + bodyView: ReturnType; + onQueryChange: (value: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + title: string; +}) { + const t = useAppText(); + + return ( +
+
+ + onQueryChange(event.target.value)} + placeholder={t("筛选 JSON...")} + value={query} + /> +
+ {bodyView.json !== undefined && query.trim() === "" ? ( + + ) : null} + {body?.contentType ? {body.contentType} : null} + {body?.truncated ? {t("truncated")} : null} +
+ ); +} + +function LogJsonBodyContent({ + expandedJsonPaths, + onToggleJsonPath, + showJsonTree, + value, + visible +}: { + expandedJsonPaths: Set; + onToggleJsonPath: (path: string) => void; + showJsonTree: boolean; + value: unknown; + visible: string; +}) { + return showJsonTree ? ( + + ) : ( +
{visible}
+ ); +} + +function LogJsonFullscreenViewer({ + body, + bodyView, + copyLabel, + copyText, + expandedJsonPaths, + onClose, + onQueryChange, + onToggleJsonPath, + onToggleTextBody, + preferTextBody, + query, + showJsonTree, + subtitle, + title, + value, + visible +}: { + body?: RequestLogBody; + bodyView: ReturnType; + copyLabel: string; + copyText: string; + expandedJsonPaths: Set; + onClose: () => void; + onQueryChange: (value: string) => void; + onToggleJsonPath: (path: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + showJsonTree: boolean; + subtitle?: string; + title: string; + value: unknown; + visible: string; +}) { + const t = useAppText(); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} + +
+ +
+ + + +
+
+ ); +} + function logBodyCacheKey(body: RequestLogBody | undefined): string { if (!body) { return "missing"; @@ -901,11 +1206,15 @@ function jsonContainerHiddenSummary(value: Record | unknown[], function LogBodyViewer({ children, copyLabel, - copyText + copyText, + fullscreenLabel, + onFullscreen }: { children: ReactNode; copyLabel: string; copyText: string; + fullscreenLabel?: string; + onFullscreen?: () => void; }) { const t = useAppText(); const [copied, setCopied] = useState(false); @@ -925,20 +1234,33 @@ function LogBodyViewer({ return (
- +
+ {onFullscreen ? ( + + ) : null} + +
{children}
); @@ -954,7 +1276,7 @@ function LogJsonTree({ value: unknown; }) { return ( -
+
); @@ -1091,10 +1413,27 @@ function JsonPrimitiveValue({ value }: { value: unknown }) { return {String(value)}; } -function NetworkHeaderCell({ label }: { label: string }) { +function NetworkHeaderCell({ + label, + onResizeStart, + resizeLabel +}: { + label: string; + onResizeStart?: (event: ReactPointerEvent) => void; + resizeLabel?: string; +}) { return ( -
- {label} +
+ {label} + {onResizeStart ? ( +
); } diff --git a/src/renderer/pages/home/shared/extensions.ts b/src/renderer/pages/home/shared/extensions.ts index 865da24..0c51be2 100644 --- a/src/renderer/pages/home/shared/extensions.ts +++ b/src/renderer/pages/home/shared/extensions.ts @@ -697,12 +697,12 @@ export function wrapperPluginCapability(item: Record): string { if (isClaudeDesignPluginConfig(item)) { const routing = readClaudeDesignRoutingConfig(item.config); - const routeCount = routing.rules.length + (routing.defaultTarget ? 1 : 0); + const routeCount = routing.rules.length; capabilities.push(routeCount > 0 ? `${routeCount} model ${routeCount === 1 ? "route" : "routes"}` : "Configurable routing"); } if (isCursorProxyPluginConfig(item)) { const routing = readClaudeDesignRoutingConfig(item.config); - const routeCount = routing.rules.length + (routing.defaultTarget ? 1 : 0); + const routeCount = routing.rules.length; capabilities.push(routeCount > 0 ? `${routeCount} model ${routeCount === 1 ? "route" : "routes"}` : "Configurable routing"); } diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index a0ba92e..6637543 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -320,6 +320,10 @@ export const appCopy: Record = { "Requests, tokens, cost": "Requests, tokens, cost", "Remove widget": "Remove widget", "Reset layout": "Reset layout", + "Close fullscreen JSON viewer": "Close fullscreen JSON viewer", + "Fullscreen JSON viewer": "Fullscreen JSON viewer", + "Open fullscreen JSON viewer": "Open fullscreen JSON viewer", + "Resize column width": "Resize column width", "Resize widget height": "Resize widget height", "Resize widget size": "Resize widget size", "Resize widget width": "Resize widget width", @@ -1033,6 +1037,10 @@ export const appCopy: Record = { "Requests, tokens, cost": "请求、Token、成本", "Result": "结果", "Reset layout": "重置布局", + "Close fullscreen JSON viewer": "关闭全屏 JSON 查看器", + "Fullscreen JSON viewer": "全屏 JSON 查看器", + "Open fullscreen JSON viewer": "全屏查看 JSON", + "Resize column width": "调整列宽", "Resize widget height": "调整组件高度", "Resize widget size": "调整组件大小", "Resize widget width": "调整组件宽度", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index e0578e2..e10caa3 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -65,7 +65,7 @@ type ProviderAccountDraftMode = "standard" | "http-json" | "raw"; type ApiKeyLimitMetric = "images" | "requests" | "tokens"; type ApiKeyExpirationPreset = "7d" | "30d" | "90d" | "custom" | "never"; type LimitWindowPreset = "day" | "hour" | "minute"; -type ClaudeDesignRouteRuleType = "always" | "image" | "long-context" | "model" | "model-prefix" | "thinking" | "web-search"; +type ClaudeDesignRouteRuleType = "always" | "model" | "model-prefix"; type VirtualModelClientToolsPolicy = "allow" | "deny"; type VirtualModelMatchMode = "alias" | "prefix" | "suffix"; export type AgentFilterValue = AgentKind | "all"; @@ -211,12 +211,7 @@ export const routerRewriteOperationOptions: Array<{ label: string; value: Router ]; export const legacyRouterRuleTypes: RouterRuleType[] = [ - "image", - "long-context", - "model-prefix", - "subagent", - "thinking", - "web-search" + "model-prefix" ]; export const routerFallbackModeOptions: Array<{ label: string; value: RouterFallbackMode }> = [ @@ -236,10 +231,6 @@ export const removedLegacyRouterRuleIds = new Set([ export const claudeDesignRouteRuleTypeOptions: Array<{ label: string; value: ClaudeDesignRouteRuleType }> = [ { label: "Exact model", value: "model" }, { label: "Model prefix", value: "model-prefix" }, - { label: "Long context", value: "long-context" }, - { label: "Thinking", value: "thinking" }, - { label: "Web search", value: "web-search" }, - { label: "Image content", value: "image" }, { label: "Always", value: "always" } ]; diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index 71864da..2e434f7 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -506,13 +506,6 @@ export function routerRuleConditionFromRule(rule: RouterRule, config?: AppConfig if (rule.type === "condition") { return undefined; } - if (rule.type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(rule.threshold ?? config?.Router.longContextThreshold ?? "200000") - }; - } if (rule.type === "model-prefix") { return { left: "request.body.model", @@ -520,34 +513,6 @@ export function routerRuleConditionFromRule(rule: RouterRule, config?: AppConfig right: rule.pattern ?? "" }; } - if (rule.type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (rule.type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (rule.type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } - if (rule.type === "subagent") { - return { - left: "request.body.system.1.text", - operator: "==", - right: "" - }; - } return undefined; } diff --git a/src/renderer/pages/home/shared/routing.ts b/src/renderer/pages/home/shared/routing.ts index c985363..ce8c909 100644 --- a/src/renderer/pages/home/shared/routing.ts +++ b/src/renderer/pages/home/shared/routing.ts @@ -442,8 +442,7 @@ export function normalizeRouterRules(value: unknown): RouterRule[] | undefined { const target = stringValue(item.target); const threshold = Number(item.threshold); const condition = normalizeRouterRuleCondition(item.condition ?? item) ?? routerRuleConditionFromLegacy(type, { - pattern, - threshold: Number.isFinite(threshold) && threshold > 0 ? Math.trunc(threshold) : undefined + pattern }); const rewrites = normalizeRouterRuleRewrites(item); const rawFallback = item.fallback ?? item.failureFallback ?? item.fallbackStrategy; @@ -459,7 +458,7 @@ export function normalizeRouterRules(value: unknown): RouterRule[] | undefined { ...(rewrites.length > 0 ? { rewrites } : {}), ...(target ? { target } : {}), ...(Number.isFinite(threshold) && threshold > 0 ? { threshold: Math.trunc(threshold) } : {}), - type: condition && type !== "subagent" ? "condition" : type + type: condition ? "condition" : type }; }) .filter((item): item is RouterRule => Boolean(item)); @@ -503,15 +502,8 @@ export function parseRouterRuleOperator(value: unknown): RouterRuleOperator | un function routerRuleConditionFromLegacy( type: RouterRuleType, - input: { pattern?: string; threshold?: number } + input: { pattern?: string } ): RouterRuleCondition | undefined { - if (type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(input.threshold ?? "200000") - }; - } if (type === "model-prefix" && input.pattern) { return { left: "request.body.model", @@ -519,27 +511,6 @@ function routerRuleConditionFromLegacy( right: input.pattern }; } - if (type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } return undefined; } @@ -719,7 +690,12 @@ export function normalizeClaudeDesignRoutingRuleDraft(value: unknown, index: num if (!isPlainRecord(value)) { return undefined; } - const type = parseClaudeDesignRouteRuleType(value.type) ?? "model"; + const rawType = stringValue(value.type); + const parsedType = parseClaudeDesignRouteRuleType(value.type); + if (rawType && !parsedType) { + return undefined; + } + const type = parsedType ?? "model"; const target = stringValue(value.target) || composeRouteTargetValue(value.targetProvider, value.targetModel) || @@ -783,9 +759,6 @@ export function normalizeClaudeDesignRuleTypeChange( if (type === "model-prefix" && !rule.pattern.trim()) { patch.pattern = defaults.pattern; } - if (type === "long-context" && !rule.threshold.trim()) { - patch.threshold = "200000"; - } return patch; } @@ -806,16 +779,12 @@ export function isClaudeDesignRoutingDraftValid(draft: ClaudeDesignRoutingDraft) if (rule.type === "model-prefix") { return Boolean(rule.pattern.trim()); } - if (rule.type === "long-context") { - return numberValue(rule.threshold) > 0; - } return true; }); } export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDraft): Record { return { - ...(draft.defaultTarget.trim() ? { default: draft.defaultTarget.trim() } : {}), enabled: draft.enabled, rules: draft.rules.map((rule) => { const output: Record = { @@ -831,9 +800,6 @@ export function claudeDesignRoutingConfigFromDraft(draft: ClaudeDesignRoutingDra if (rule.type === "model-prefix") { output.pattern = rule.pattern.trim(); } - if (rule.type === "long-context") { - output.threshold = numberValue(rule.threshold); - } return output; }) }; @@ -863,21 +829,6 @@ export function buildPluginRoutingRows(plugin: AppConfig["plugins"][number], plu const routing = readClaudeDesignRoutingConfig(plugin.config); const baseEnabled = plugin.enabled !== false && routing.enabled; const rows: RoutingRuleRow[] = []; - if (routing.defaultTarget) { - rows.push({ - condition: "always", - enabled: baseEnabled, - key: `plugin-${pluginIndex}-${pluginName}-default`, - name: "Default", - pluginIndex, - readonly: true, - ruleCount: 0, - ruleId: "default", - sourceLabel: `Plugin: ${pluginName}`, - target: routing.defaultTarget, - typeLabel: "Always" - }); - } routing.rules.forEach((rule, ruleIndex) => { rows.push({ condition: formatClaudeDesignRoutingRuleCondition(rule), @@ -915,18 +866,6 @@ export function formatClaudeDesignRoutingRuleCondition(rule: ClaudeDesignRouting if (rule.type === "model-prefix") { return rule.pattern ? `starts with ${rule.pattern}` : "prefix unset"; } - if (rule.type === "long-context") { - return `>${rule.threshold || "threshold"} tokens`; - } - if (rule.type === "thinking") { - return "thinking enabled"; - } - if (rule.type === "web-search") { - return "web_search tool"; - } - if (rule.type === "image") { - return "image content"; - } return "always"; } @@ -940,7 +879,7 @@ export function isClaudeDesignRouteRuleType(value: string): value is ClaudeDesig } export function isClaudeDesignStaticRuleType(type: ClaudeDesignRouteRuleType): boolean { - return type === "always" || type === "image" || type === "thinking" || type === "web-search"; + return type === "always"; } export function claudeDesignRouteRuleTypeLabel(type: ClaudeDesignRouteRuleType): string { diff --git a/src/renderer/pages/home/shared/types.ts b/src/renderer/pages/home/shared/types.ts index 8e1d68e..11fc81c 100644 --- a/src/renderer/pages/home/shared/types.ts +++ b/src/renderer/pages/home/shared/types.ts @@ -564,7 +564,7 @@ export type RoutingRewriteDraftRow = { value: string; }; -export type ClaudeDesignRouteRuleType = "always" | "image" | "long-context" | "model" | "model-prefix" | "thinking" | "web-search"; +export type ClaudeDesignRouteRuleType = "always" | "model" | "model-prefix"; export type ClaudeDesignRoutingRuleDraft = { enabled: boolean; diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index b6b4bcf..c3792ee 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -556,11 +556,23 @@ .network-table-scroll, .network-detail, + .network-json-fullscreen, + .network-json-fullscreen-body, .network-pane-body { background: var(--network-panel); border-color: var(--network-border); } + .network-json-fullscreen { + color: var(--network-text); + } + + .network-json-fullscreen-header { + background: var(--network-panel-alt); + border-color: var(--network-border); + box-shadow: var(--network-shadow); + } + .network-table-header, .network-kv-header, .network-body-meta { @@ -573,6 +585,41 @@ border-color: var(--network-border-strong); } + .network-column-resize-handle { + appearance: none; + background: transparent; + border: 0; + bottom: 0; + cursor: col-resize; + margin: 0; + outline: none; + padding: 0; + position: absolute; + right: -4px; + top: 0; + width: 8px; + z-index: 1; + } + + .network-column-resize-handle::after { + background: var(--network-border-strong); + bottom: 6px; + content: ""; + left: 50%; + opacity: .62; + position: absolute; + top: 6px; + transform: translateX(-50%); + width: 1px; + } + + .network-column-resize-handle:hover::after, + .network-column-resize-handle:focus-visible::after { + background: var(--network-accent); + opacity: 1; + width: 2px; + } + .network-empty { background: var(--network-panel); color: var(--network-text-subtle); diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index ab47884..99888a4 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -60,7 +60,7 @@ export class ClaudeCodeRouterPlugin { }; const customModel = await this.resolveCustomRoute(request); - const configuredDecision = resolveConfiguredRouteDecision(request, this.config, tokenCount); + const configuredDecision = resolveConfiguredRouteDecision(request, this.config); if (customModel) { body.model = customModel; } else { @@ -177,8 +177,7 @@ function isPathInside(file: string, root: string): boolean { function resolveConfiguredRouteDecision( request: MutableRequestLike, - config: AppConfig, - tokenCount: number + config: AppConfig ): ConfiguredRouteDecision { const requestedModel = readString(request.body.model); const explicitModel = normalizeRouteSelector(requestedModel); @@ -189,7 +188,7 @@ function resolveConfiguredRouteDecision( const router = config.Router; const rules = router.rules ?? []; for (const rule of rules) { - const decision = resolveRouterRule(rule, request, tokenCount, router); + const decision = resolveRouterRule(rule, request, router); if (decision) { return decision; } @@ -201,7 +200,6 @@ function resolveConfiguredRouteDecision( function resolveRouterRule( rule: RouterRule, request: MutableRequestLike, - tokenCount: number, router: RouterConfig ): ConfiguredRouteDecision | undefined { if (!rule.enabled) { @@ -209,11 +207,6 @@ function resolveRouterRule( } const fallback = rule.fallback ?? router.fallback; - if (rule.type === "subagent") { - const subagentModel = extractSubagentModel(request.body.system); - return subagentModel ? { fallback, model: normalizeRouteSelector(subagentModel), reason: "subagent" } : undefined; - } - const rewrites = routerRuleRewritesFromRule(rule); if (rewrites.length === 0) { return undefined; @@ -225,11 +218,6 @@ function resolveRouterRule( : undefined; } - if (rule.type === "long-context") { - const threshold = rule.threshold || router.longContextThreshold || 200000; - return tokenCount > threshold ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - if (rule.type === "model-prefix") { const pattern = readString(rule.pattern); const requestedModel = readString(request.body.model); @@ -238,18 +226,6 @@ function resolveRouterRule( : undefined; } - if (rule.type === "thinking") { - return request.body.thinking ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - - if (rule.type === "web-search") { - return hasWebSearchTool(request.body.tools) ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - - if (rule.type === "image") { - return hasImageContent(request.body.messages) ? routerRuleRewriteDecision(rule, rewrites, fallback) : undefined; - } - return undefined; } @@ -731,36 +707,6 @@ function estimateTextTokens(text: string): number { return Math.max(1, Math.ceil((asciiWords + cjkChars) * 1.15)); } -function extractSubagentModel(system: unknown): string | undefined { - if (!Array.isArray(system) || system.length < 2) { - return undefined; - } - const second = system[1]; - if (!isRecord(second) || typeof second.text !== "string") { - return undefined; - } - - const match = second.text.match(/(.*?)<\/CCR-SUBAGENT-MODEL>/s); - if (!match?.[1]) { - return undefined; - } - - second.text = second.text.replace(match[0], ""); - return match[1].trim(); -} - -function hasWebSearchTool(tools: unknown): boolean { - return Array.isArray(tools) && tools.some((tool) => isRecord(tool) && readString(tool.type)?.startsWith("web_search")); -} - -function hasImageContent(messages: unknown): boolean { - if (!Array.isArray(messages)) { - return false; - } - - return messages.some((message) => JSON.stringify(message).includes("\"image\"")); -} - function resolveSessionId(body: Record, headers: Record): string | undefined { const fromHeader = readHeader(headers["x-claude-code-session-id"]) || readHeader(headers["x-claude-session-id"]); if (fromHeader) { diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 7e0e16d..0e768be 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -882,10 +882,10 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v ...pluginService.getCoreProviderPlugins() ]); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); - const virtualModelProfiles = withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ + const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ ...(config.virtualModelProfiles ?? []), ...pluginService.getVirtualModelProfiles() - ]))); + ]))), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint); const providers = [ @@ -957,6 +957,98 @@ function writePrivateTextFile(file: string, content: string): void { } } +export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { + return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); +} + +function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppConfig): unknown { + if (!isRecord(profile)) { + return profile; + } + + let nextProfile: Record | undefined; + const baseModel = isRecord(profile.baseModel) ? profile.baseModel : undefined; + const fixedModel = stringValue(baseModel?.fixedModel); + const rewrittenFixedModel = fixedModel + ? rewriteModelSelectorForCoreGatewayProfile(fixedModel, config, "anthropic_messages") + : undefined; + if (baseModel && rewrittenFixedModel && rewrittenFixedModel !== fixedModel) { + nextProfile = { + ...profile, + baseModel: { + ...baseModel, + fixedModel: rewrittenFixedModel + } + }; + } + + const sourceProfile = nextProfile ?? profile; + const metadata = isRecord(sourceProfile.metadata) ? sourceProfile.metadata : undefined; + const fusionVision = isRecord(metadata?.fusionVision) ? metadata.fusionVision : undefined; + const visionBaseUrl = stringValue(fusionVision?.baseUrl); + const visionSelectorField = stringValue(fusionVision?.modelSelector) ? "modelSelector" : stringValue(fusionVision?.model) ? "model" : undefined; + const visionSelector = visionSelectorField ? stringValue(fusionVision?.[visionSelectorField]) : undefined; + const rewrittenVisionSelector = fusionVision && !visionBaseUrl && visionSelector + ? rewriteModelSelectorForCoreGatewayProfile(visionSelector, config, "openai_chat_completions") + : undefined; + + if (metadata && fusionVision && visionSelectorField && rewrittenVisionSelector && rewrittenVisionSelector !== visionSelector) { + nextProfile = { + ...sourceProfile, + metadata: { + ...metadata, + fusionVision: { + ...fusionVision, + [visionSelectorField]: rewrittenVisionSelector + } + } + }; + } + + return nextProfile ?? profile; +} + +function rewriteModelSelectorForCoreGatewayProfile( + model: string, + config: AppConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return undefined; + } + + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + if (!selector) { + return publicModel; + } + + const providerName = coreGatewayProviderSelectorName(selector.provider, clientProtocol); + return providerName ? `${providerName}/${selector.model}` : publicModel; +} + +function coreGatewayProviderSelectorName( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + const explicitCapabilities = normalizedProviderCapabilities(provider); + const protocol = capability?.type ?? (explicitCapabilities.length === 0 ? providerProtocolForClientProtocol(provider, clientProtocol) : undefined); + if (!protocol) { + return undefined; + } + + const credentials = sortProviderCredentialsForConfig(activeProviderCredentials(provider)); + if (credentials.length > 0) { + return providerCredentialInternalName(provider, protocol, credentials[0]); + } + + return capability ? providerCapabilityInternalName(provider, protocol) : providerRuntimeId(provider); +} + function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { const codexAuth = readCodexAuth(); return providerPlugins.map((plugin) => { diff --git a/src/shared/app.ts b/src/shared/app.ts index 6788072..b3578c7 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -461,12 +461,7 @@ export type GatewayProviderConnectivityCheckReport = { export type RouterRuleType = | "condition" - | "image" - | "long-context" - | "model-prefix" - | "subagent" - | "thinking" - | "web-search"; + | "model-prefix"; export type RouterRuleOperator = | "==" diff --git a/tests/main/gateway-virtual-models.test.mjs b/tests/main/gateway-virtual-models.test.mjs new file mode 100644 index 0000000..4931a1f --- /dev/null +++ b/tests/main/gateway-virtual-models.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeCoreGatewayVirtualModelProfiles } from "../../src/server/gateway/service.ts"; + +test("gateway config rewrites Fusion fixed base and vision models to core provider selectors", () => { + const providerName = "Zhipu AI (China) - Coding Plan"; + const config = { + Providers: [ + { + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + capabilities: [ + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "openai_chat_completions" }, + { baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", type: "anthropic_messages" } + ], + credentials: [{ apiKey: "test-key", id: "test-1" }], + models: ["glm-5.2", "glm-5v-turbo"], + name: providerName, + type: "openai_chat_completions" + } + ], + Router: { fallback: { mode: "off", models: [], retryCount: 0 } }, + gateway: {} + }; + const profiles = [ + { + baseModel: { fixedModel: `${providerName}/glm-5.2`, mode: "fixed" }, + displayName: "GLM Fusion", + enabled: true, + execution: { + clientToolsPolicy: "allow", + maxToolCalls: 8, + maxTurns: 6, + mode: "tool_loop", + streamMode: "optimistic" + }, + id: "glm-fusion", + key: "glm-fusion", + match: { exactAliases: ["glm-fusion"], prefixes: [], suffixes: [] }, + materialization: { enabled: true, includeInGatewayModels: true }, + metadata: { + fusionVision: { + modelSelector: `${providerName}/glm-5v-turbo`, + toolName: "vision_understand_glm_fusion" + } + }, + tools: [{ name: "vision_understand_glm_fusion", visibility: "internal" }] + } + ]; + + const [profile] = normalizeCoreGatewayVirtualModelProfiles(profiles, config); + + assert.match( + profile.baseModel.fixedModel, + /^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::anthropic_messages::cred:test-1\/glm-5\.2$/ + ); + assert.match( + profile.metadata.fusionVision.modelSelector, + /^provider-zhipu-ai-china---coding-plan-[a-f0-9]{10}::openai_chat_completions::cred:test-1\/glm-5v-turbo$/ + ); + assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`); +}); From 451bdb572c68a6a93f0f0542c7ddb03c857d1ee5 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 30 Jun 2026 22:27:16 +0800 Subject: [PATCH 018/101] Expand configuration docs for dashboard, keys, and providers --- docs/src/content/docs/en/configuration.md | 8 +- .../content/docs/en/configuration/api-keys.md | 52 ++++ .../content/docs/en/configuration/overview.md | 59 ++++ .../docs/en/configuration/providers.md | 134 +++++++- .../content/docs/en/configuration/server.md | 32 ++ .../src/content/docs/en/configuration/tray.md | 45 +++ docs/src/content/docs/en/index.md | 4 +- docs/src/content/docs/zh/configuration.md | 8 +- .../content/docs/zh/configuration/api-keys.md | 52 ++++ .../content/docs/zh/configuration/overview.md | 59 ++++ .../content/docs/zh/configuration/provider.md | 134 +++++++- .../content/docs/zh/configuration/server.md | 32 ++ .../src/content/docs/zh/configuration/tray.md | 45 +++ docs/src/content/docs/zh/index.md | 4 +- docs/src/i18n/content.ts | 20 +- docs/src/pages/configuration.astro | 17 +- docs/src/pages/configuration/[slug].astro | 4 + docs/src/pages/en/configuration.astro | 17 +- docs/src/pages/en/configuration/[slug].astro | 4 + .../pages/home/components/providers.tsx | 2 +- .../pages/home/components/virtual-models.tsx | 285 +++++++++++++++--- src/renderer/pages/home/shared/i18n.tsx | 6 + .../pages/home/shared/virtual-models.ts | 137 ++++++--- tests/renderer/virtual-models.test.ts | 47 +++ 24 files changed, 1080 insertions(+), 127 deletions(-) create mode 100644 docs/src/content/docs/en/configuration/api-keys.md create mode 100644 docs/src/content/docs/en/configuration/overview.md create mode 100644 docs/src/content/docs/en/configuration/server.md create mode 100644 docs/src/content/docs/en/configuration/tray.md create mode 100644 docs/src/content/docs/zh/configuration/api-keys.md create mode 100644 docs/src/content/docs/zh/configuration/overview.md create mode 100644 docs/src/content/docs/zh/configuration/server.md create mode 100644 docs/src/content/docs/zh/configuration/tray.md create mode 100644 tests/renderer/virtual-models.test.ts diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index dd828f8..36f1c24 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -2,7 +2,7 @@ title: Claude Code Router Detailed Configuration pageTitle: Detailed Configuration eyebrow: Detailed Configuration -lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config database location in detail. +lead: Configure the overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and the config database location in detail. --- ## Page Structure @@ -11,6 +11,9 @@ Detailed configuration docs are split into standalone pages. Every left-sidebar | Page | Covers | | --- | --- | +| Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards | +| API Keys | Client access keys, expiration, and local limits | +| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate | | Provider Config | Upstream services, protocol, Base URL, model list, and credentials | | One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries | | Routing Config | Default routing, conditional rules, fallback, and request rewrites | @@ -19,8 +22,9 @@ Detailed configuration docs are split into standalone pages. Every left-sidebar | Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding | | Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging | | Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages | +| Tray Configuration | Tray icon, balance progress, and tray window widgets | | Config Database Location | SQLite config database location maintained by the desktop app | ## Content Relationships -Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. +Overview Dashboard shows system status and usage. API Keys control client access to CCR. Server controls the local gateway listener and proxy features. Provider Config and One click import cover how upstream model services enter CCR. Routing determines where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Extension Mechanism covers local plugin creation, installation, and debugging. Bots cover IM platform relay. Tray Configuration covers the menu bar icon and tray window. diff --git a/docs/src/content/docs/en/configuration/api-keys.md b/docs/src/content/docs/en/configuration/api-keys.md new file mode 100644 index 0000000..766e588 --- /dev/null +++ b/docs/src/content/docs/en/configuration/api-keys.md @@ -0,0 +1,52 @@ +--- +title: API Keys +pageTitle: API Keys +eyebrow: Detailed Configuration +lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits. +--- + +## Basic Concept + +The API Keys page manages client access keys for CCR. These keys are different from provider `API key` values and provider credential pools: API Keys control who can call CCR; provider keys control how CCR calls upstream providers. + +When API keys are configured, clients should send `Authorization: Bearer ` or `x-api-key: `. + +## List Fields + +| Field | Capability | +| --- | --- | +| Search API keys | Filters the list by key name or key value. | +| Add API key | Opens the create dialog and generates a new client access key. | +| Name | Display name for the key. Use it to identify a client, team, purpose, or automation. | +| Key | Masked access key. Use `Copy API key` to copy the full key. | +| Expires | Expiration time. After expiration, clients can no longer use the key to access CCR. | +| Limits | Local limit summary. Shows `No limits configured` when no limits are set. | +| Edit API key | Edits expiration and limits. The key value itself is not shown again. | +| Remove API key | Deletes the client access key. Deleted keys stop working immediately. | + +## Create And Edit + +| Field | Capability | +| --- | --- | +| Name | Display name for the new key. Examples: `Claude Code - laptop`, `CI`, or a team name. | +| Expiration | Selects the validity period: `Never`, `7 days`, `30 days`, `90 days`, or `Custom`. | +| Expires at | Appears for `Custom` expiration and sets the exact date and time. | +| API key created | Confirmation dialog after creation. It displays the full key. | +| Copy this key now. It may not be shown again. | Reminder to copy the key immediately because CCR will not show it again after the dialog closes. | + +## Advanced Settings + +`Advanced settings` adds local limits to a client key. When a limit is reached, requests using that key are rejected or limited; provider-side quota is not changed. + +| Field | Capability | +| --- | --- | +| Advanced settings | Expands or collapses limit editing. | +| No limits configured | The key has no local limits. | +| Requests | Limits by request count. | +| Tokens | Limits by token count. | +| Images | Limits by image count. | +| per minute | Uses a 1-minute limit window. | +| per hour | Uses a 1-hour limit window. | +| per day | Uses a 1-day limit window. | +| Add limit | Adds one limit rule. | +| Remove limit | Removes the current limit rule. | diff --git a/docs/src/content/docs/en/configuration/overview.md b/docs/src/content/docs/en/configuration/overview.md new file mode 100644 index 0000000..9f07d50 --- /dev/null +++ b/docs/src/content/docs/en/configuration/overview.md @@ -0,0 +1,59 @@ +--- +title: Overview Dashboard +pageTitle: Overview Dashboard +eyebrow: Detailed Configuration +lead: Customize the CCR home dashboard for system status, account balance, requests, tokens, cost, model distribution, and share cards. +--- + +## Basic Concept + +Overview is the CCR dashboard. It turns gateway status, provider accounts, request usage, and analysis results into draggable widgets. You can switch the time range, edit widgets, resize them, change styles, and generate shareable cards from selected data. + +Overview depends on request logs, usage stats, and provider account usage. Some widgets require Request logs or Agent observability to be enabled in Settings. Account balance widgets require `Fetch usage` on providers. + +## Top Controls + +| Field | Capability | +| --- | --- | +| Usage over time | Changes the statistics range used by the dashboard. | +| Today / 24h / 7d / 30d | Available time windows. Widgets recalculate requests, tokens, cost, and trends for the selected range. | +| Edit widgets | Enters layout editing mode. | +| Reset layout | Restores the default overview layout. | +| Done | Leaves editing mode and keeps the current widget configuration. | + +## Widget Editing + +In editing mode, the left `Components` panel adds widgets, the middle `Preview` panel shows the current layout, and the right `Component properties` panel edits the selected widget. + +| Field | Capability | +| --- | --- | +| Components | List of widgets that can be added. | +| Preview | Current dashboard layout. Widgets can be dragged to reorder. | +| Component properties | Configuration for the selected widget. | +| Component category | Changes the widget category, such as status, account, metric, trend, activity, breakdown, analysis, or share card. | +| Data | Selects the data shown by the widget, such as requests, tokens, cost, account, client analysis, or provider analysis. | +| Widget size | Controls the widget's grid width and height. | +| Style | Changes visual style, such as cards, compact, bar, line, ring, and more. | +| Remove widget | Removes the selected widget from the overview. | + +## Widget Types + +| Widget | Capability | +| --- | --- | +| Status component | Shows a system status timeline for recent gateway health. | +| Account component | Shows provider account balance, quota, or usage. Requires provider `Fetch usage`. | +| Metric component | Shows requests, total tokens, input tokens, output tokens, cache tokens, cache ratio, estimated cost, success rate, errors, or average latency. | +| Trend component | Shows usage trend over time. | +| Activity component | Shows token activity as a heatmap. | +| Breakdown component | Shows Token mix or Model distribution. | +| Analysis component | Shows Client Analysis or Provider Analysis. | +| Share card | Generates shareable PNG cards such as AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, and Spend Receipt. | + +## Data Sources + +| Data | Source | +| --- | --- | +| Requests, tokens, cost, success rate, latency | Request logs and usage stats. | +| Account balance, quota, status messages | Provider `Fetch usage` configuration. | +| Client analysis, provider analysis, model distribution | Client, provider, model, and token data from request logs. | +| Agent analysis data | Agent observability settings and agent execution traces. | diff --git a/docs/src/content/docs/en/configuration/providers.md b/docs/src/content/docs/en/configuration/providers.md index 4a7830b..2a6716c 100644 --- a/docs/src/content/docs/en/configuration/providers.md +++ b/docs/src/content/docs/en/configuration/providers.md @@ -7,20 +7,130 @@ lead: Configure upstream model services, credentials, protocols, base URLs, and ## Basic Concept -A provider is an upstream model service. CCR needs at least one provider with a valid protocol, Base URL, model list, and credential. +A provider is an upstream model service. One provider config describes the upstream address, protocol capabilities, model list, authentication, optional multi-key rotation, and optional account usage fetching. + +Each provider needs at least a name, API endpoint, usable protocol, model list, and one valid credential. The add/edit dialog changes which fields are visible depending on preset providers, custom endpoints, local agent login import, and advanced settings. + +Preset providers fill common endpoints, protocols, models, and usage-fetching settings, so they are the recommended starting point. For custom providers, enter the endpoint manually. CCR uses the endpoint and API key to probe protocol compatibility and help identify whether the upstream works with OpenAI Chat, OpenAI Responses, Anthropic Messages, or Gemini Generate. + +## Main Fields + +| Field | Capability | +| --- | --- | +| Select preset provider | Applies a built-in provider template, including default endpoint, supported protocols, default models, icon, provider website, and sometimes account usage settings. Choose `Other / custom API endpoint` for any OpenAI, Anthropic, or Gemini compatible upstream. | +| Import local agent login | Appears while adding a provider if CCR finds usable Claude Code, Codex, or ZCode login state on this computer. Importing creates a provider and provider plugin that reuse the local login credential instead of a normal pasted API key. | +| Name | Internal CCR display name. It is also used by routing, model selectors, logs, and config references. Names must be unique. | +| API endpoint | Upstream API base URL. It controls where requests are sent, and is also used for protocol probing, model discovery, icon detection, and safety checks. Preset providers hide it by default while adding, but it can be overridden in Advanced settings. Custom providers must provide it. | +| API key | Default provider credential. When the credential pool is empty, model requests use this key. Protocol probing, model discovery, connection checks, and default usage fetching also use it. Only use a key issued for the selected endpoint. | +| Models | Model IDs exposed by CCR. Routing rules, profile model selectors, the model catalog, and client `/models` responses all use this list. | +| Search models / All / Clear | When CCR can discover models from the upstream or catalog, you can search, select all, clear, and choose models. Selected models are saved to the provider. | +| Custom models | Manually adds model IDs that discovery did not return. Use this when the provider lacks a `/models` endpoint or a new model is not in the catalog yet. | +| Check Connection | Sends real test requests with the current endpoint, API key, protocol, and selected models. It verifies key, model name, and protocol usability. | +| Models to check | Model selection inside the connection-check confirmation dialog. Use it to test only some models. | +| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Passing results do not automatically add models; the main model selection remains authoritative. | + +## Connectivity Checks + +`Check Connection` sends real model requests for the models you select. It verifies whether the endpoint, API key, protocol, and model IDs are usable. The check limits generated output, but it can still create extra token usage or count against provider-side request limits. + +If the provider bills by request, input tokens, or output tokens, select only the models you need to verify instead of checking every model at once. Check results are diagnostic only; they do not automatically change the model list or usage-fetching settings. ## Credentials -Credentials store API keys. Multiple credentials can rotate by priority and weight, and can switch when a key hits a limit or fails. +`API key` is the simplest single-key setup. For multiple upstream keys, expand `Credential pool` in Advanced settings. -Use recognizable labels so failed keys are easy to identify in Logs. - -## Provider Options - -| Field | Description | +| Field | Capability | | --- | --- | -| Name | Internal display name in CCR; keep it short and recognizable | -| Base URL | Upstream service address; custom providers should include the correct API path | -| Protocol | OpenAI, Anthropic, Gemini, or compatible protocol | -| Models | Model list exposed to CCR selectors | -| Request headers | Extra headers required by some compatible services | +| Show credential settings | Expands or collapses credential pool editing. Collapsing does not remove saved credentials. | +| Import JSON | Imports credentials from a JSON file. CCR accepts a top-level array, or an object with a `credentials`, `keys`, or `apiKeys` array. | +| Add key | Adds one upstream API key row. | +| Enable | Controls whether this credential participates in request forwarding and usage fetching. Disabled credentials are kept but not selected. | +| Name | Display name for the credential. It appears in account usage, logs, and diagnostics, so use a recognizable purpose or quota source. | +| API key | The actual key sent to the upstream for this credential. When a credential pool is configured, CCR expands enabled credentials into internal upstream targets and prefers the pool over the main form API key for model requests. | +| Remove | Deletes the credential row. | +| Advanced key options | Expands per-key scheduling and limit fields. | +| Priority | Credential priority. Lower numbers are tried first. If omitted, the row order is used. | +| Weight | Tie-break weight among credentials with the same priority and similar usage. Higher numbers are preferred. Defaults to `1`. | +| Limits JSON | Local limit rules for this key. CCR tracks request, token, or image usage windows and skips a key once it would exceed its limit, then tries another key on the same provider. | + +Common `Limits JSON` fields: + +| Field | Meaning | +| --- | --- | +| `rpm` / `rph` / `rpd` | Max requests per minute / hour / day | +| `tpm` / `tph` / `tpd` | Max tokens per minute / hour / day | +| `ipm` / `iph` / `ipd` | Max images per minute / hour / day | +| `maxRequests` + `windowMs` | Max requests in a custom time window | +| `maxTokens` + `quotaWindowMs` | Max tokens in a custom time window | + +Example: + +```json +{ + "rpm": 60, + "tpm": 100000 +} +``` + +The credential pool is an upstream provider key pool. It is separate from the client access keys configured on the API Keys page. + +## Usage Fetching + +`Fetch usage` lets CCR show balance, subscription quota, status, and messages in the provider list, tray, and account panels. It does not affect whether models can be requested. + +| Field | Capability | +| --- | --- | +| Fetch usage | Enables or disables account usage fetching for this provider. | +| Usage mode | Usage connector mode. `Standard usage endpoint` uses CCR standard account endpoints; `HTTP JSON request` maps a custom JSON endpoint; `Raw connector JSON` edits the connector array directly. | +| Refresh interval ms | Usage refresh interval in milliseconds. Empty uses the default interval. The minimum effective interval is 30000ms. | + +### Standard Usage Endpoint + +This mode tries provider-hosted CCR account endpoints such as `/.well-known/ccr/account` and `/v1/account/limits`. It is best for providers or presets that already implement CCR's standard account format. + +### HTTP JSON Request + +Use this mode when the provider has a balance or quota JSON endpoint that does not match CCR's standard account format. + +| Field | Capability | +| --- | --- | +| Method | Usage request method, `GET` or `POST`. | +| Usage request URL | Usage endpoint URL. It can be a full URL. The request includes the provider API key unless changed through raw connector JSON. | +| Headers | Extra headers for the usage endpoint. Avoid hard-coding sensitive auth headers here; prefer provider API key auth. | +| Body | `POST` request body. Must be valid JSON. | +| Balance remaining field | JSON path for remaining balance. | +| Balance total field | JSON path for total balance or total credits. | +| Balance used field | JSON path for used balance. | +| Balance unit | Balance unit, such as `USD`, `CNY`, or `%`. | +| Subscription remaining field | JSON path for remaining subscription, token, quota, or package amount. | +| Subscription limit field | JSON path for subscription, token, quota, or package limit. | +| Subscription reset field | JSON path for reset time. It may resolve to an ISO string, seconds timestamp, or milliseconds timestamp. | +| Subscription unit | Subscription unit, such as `tokens`, `requests`, or `hours`. | +| Status field | JSON path for account status. Supported values are `ok`, `warning`, `critical`, `error`, and `unsupported`. | +| Message field | JSON path for account message. Useful for provider errors, plan notes, or risk-control messages. | +| Test usage request | Requests and parses the usage endpoint before saving. | +| Response fields | Lists selectable paths from the response. Buttons such as `Balance rem`, `Balance total`, `Balance used`, `Sub rem`, `Sub limit`, and `Reset` fill the matching field. | + +Field paths use CCR's lightweight JSONPath syntax: + +| Syntax | Meaning | +| --- | --- | +| `$` | Whole response object | +| `$.balance.remaining` | Object field | +| `$.items[0].value` | Array index | +| `$["weird-key"]` | Field name with special characters | +| `$.limits[?(@.type=="TOKENS")].remaining` | First array item matching simple equality filters | +| `100 - $.data.percentage` | Numeric subtraction expression, often used to convert used percent into remaining percent | + +### Raw Connector JSON + +`Connectors JSON` edits the `account.connectors` array directly for more complex provider setups. + +| Connector type | Capability | +| --- | --- | +| `standard` | Uses CCR standard account endpoints. | +| `http-json` | Requests a JSON endpoint and maps balance, subscription, status, and message fields. | +| `plugin` | Calls an account usage connector registered by an installed plugin. | +| `local-estimate` | Shows estimated quota from local time-window config without a remote request. | + +`Insert example` fills an example connector array containing `standard`, `http-json`, `plugin`, and `local-estimate` connectors. diff --git a/docs/src/content/docs/en/configuration/server.md b/docs/src/content/docs/en/configuration/server.md new file mode 100644 index 0000000..8742de9 --- /dev/null +++ b/docs/src/content/docs/en/configuration/server.md @@ -0,0 +1,32 @@ +--- +title: Server +pageTitle: Server +eyebrow: Detailed Configuration +lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR. +--- + +## Basic Concept + +The Server page controls how the local CCR gateway listens for requests. Normal model requests usually need only `Host` and `Port`. + +## Main Fields + +| Field | Capability | +| --- | --- | +| Host | Host address the CCR gateway listens on. Common values are `127.0.0.1` and `0.0.0.0`. | +| Port | Gateway listening port. Clients should point their API base URL to this port. | + +## Proxy Mode + +Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HTTPS traffic to CCR. CCR uses MITM interception to identify and decrypt HTTPS requests, then proxies supported model requests into the CCR gateway path. + +| Field | Capability | +| --- | --- | +| Proxy mode | Enables Proxy mode. CCR can receive client traffic as an HTTP/HTTPS proxy and use MITM interception to proxy model requests into CCR. | +| System proxy | Points the system proxy at CCR so apps that honor system proxy settings can go through CCR automatically. | +| Capture network | Stores network requests that pass through proxy mode so the Networking page can show request and response details. | +| CA certificate | Trust status of the current proxy CA certificate. | +| Install CA | Installs the CCR proxy CA into the system or user trust store. Installation differs by OS. | +| Check Trust | Checks again whether the proxy CA is trusted by the system. | +| Proxy status | Shows whether the proxy service is running. | +| Restart Proxy | Restarts the proxy service when proxy mode is enabled. | diff --git a/docs/src/content/docs/en/configuration/tray.md b/docs/src/content/docs/en/configuration/tray.md new file mode 100644 index 0000000..c856666 --- /dev/null +++ b/docs/src/content/docs/en/configuration/tray.md @@ -0,0 +1,45 @@ +--- +title: Tray Configuration +pageTitle: Tray Configuration +eyebrow: Detailed Configuration +lead: Configure the CCR system tray icon, balance progress, and tray window widgets. +--- + +## Basic Concept + +Tray settings live under Settings. They control the system tray icon and the tray window. The tray window can show providers, account balance, token trends, activity, metrics, and model share so you can check CCR status without opening the main window. + +## Top Fields + +| Field | Capability | +| --- | --- | +| Tray mascot | Selects the tray icon style. Options include `Random`, `Auralis`, `Solara`, `Vesper`, and `Balance progress`. | +| Balance progress | Uses provider account usage as tray icon progress. Requires `Fetch usage` on a provider first. | +| Account | Selects the provider account used for balance progress. | +| Data | Selects the balance, subscription, or quota meter used as the progress source. | + +If no account data is available, the page shows `No account data is available. Enable account monitoring on a provider first.` This usually means no provider has `Fetch usage` enabled, or usage fetching has not succeeded yet. + +## Tray Window Layout + +| Area | Capability | +| --- | --- | +| Components | Left-side component palette for adding or enabling tray window widgets. | +| Preview | Middle preview area showing the current tray window layout. Widgets can be dragged to reorder. | +| Component properties | Right-side editor for the selected widget's `Style`, or for removing the widget. | + +## Component Types + +| Component | Capability | +| --- | --- | +| Provider component | Shows `Provider tabs` for switching provider data in the tray window. It is a singleton component and can be enabled only once. | +| Header component | Shows `Title and status`. It is a singleton component and can be enabled only once. | +| Account component | Shows `Account balance`. Multiple account components can be added with different styles. | +| Trend component | Shows `Token flow chart`. | +| Activity component | Shows `Token activity`. | +| Metric component | Shows `Token stats`. | +| Breakdown component | Shows `Token mix`, `Circular metrics`, or `Model share`. | + +## Styles + +Different components support different `Style` options. Common styles include `Cards`, `Compact`, `List`, `Pills`, `Line`, `Area`, `Bar`, `Ring`, `Donut`, `Gauges`, `Sparkline`, and `Stacked`. Style changes only affect the tray display; they do not change routing, providers, or usage stats. diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index a628432..97ac403 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -26,7 +26,7 @@ The top navigation is split into four standalone pages: | --- | --- | | [Documentation](./) | Product positioning, architecture overview, and reading path | | [Quick Start](guides/) | From installation and provider setup to connecting an agent | -| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config database location | +| [Detailed Configuration](configuration/providers/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location | | [Q&A](troubleshooting/) | Request logs, observability panel, and common questions | Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently. @@ -37,5 +37,5 @@ If this is your first time using CCR: 1. Start with [Quick Start](guides/) to connect a provider and Agent Config. 2. Use the app's request logs to confirm whether requests are passing through CCR. -3. Open [Detailed Configuration](configuration/) for vision, web search, MCP tools, and IM relay. +3. Open [Detailed Configuration](configuration/providers/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay. 4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions. diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index 8cc3c8e..26df10a 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -2,7 +2,7 @@ title: Claude Code Router 详细配置 pageTitle: 详细配置 eyebrow: 详细配置 -lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置数据库位置。这里是按功能查字段和扩展能力的地方。 +lead: 深入配置概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置。这里是按功能查字段和扩展能力的地方。 --- ## 页面结构 @@ -11,6 +11,9 @@ lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置数 | 页面 | 内容 | | --- | --- | +| 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 | +| API 密钥 | 客户端访问 Key、过期时间和本地限额 | +| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 | | 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 | | 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 | | 路由配置 | 默认路由、条件规则、fallback 和请求改写 | @@ -19,8 +22,9 @@ lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置数 | Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 | | 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 | | Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 | +| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 | | 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 | ## 内容关系 -供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力。 +概览仪表盘用于查看系统状态和用量;API 密钥控制客户端访问 CCR;服务配置控制本地网关监听和代理能力;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;扩展机制页面覆盖本地插件的创建、安装和调试;Bot 页面覆盖 IM 平台接力;托盘配置覆盖菜单栏图标和托盘窗口。 diff --git a/docs/src/content/docs/zh/configuration/api-keys.md b/docs/src/content/docs/zh/configuration/api-keys.md new file mode 100644 index 0000000..70e61ff --- /dev/null +++ b/docs/src/content/docs/zh/configuration/api-keys.md @@ -0,0 +1,52 @@ +--- +title: API 密钥 +pageTitle: API 密钥 +eyebrow: 详细配置 +lead: 管理客户端访问 CCR 网关时使用的 API Key,并为每个 Key 设置过期时间和本地限额。 +--- + +## 基本概念 + +“API 密钥”页面管理的是客户端访问 CCR 时使用的访问 Key。它不同于供应商配置里的 `API 密钥` 或 `凭据池`:这里的 Key 用来控制谁能调用 CCR;供应商 Key 用来控制 CCR 调用上游供应商。 + +如果配置了 API Key,客户端请求需要带 `Authorization: Bearer ` 或 `x-api-key: `。 + +## 列表字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 搜索 API 密钥 | 按名称或 Key 内容过滤列表。 | +| 添加 API 密钥 | 打开创建弹窗,生成新的客户端访问 Key。 | +| 名称 | API Key 的显示名,用于区分客户端、团队、用途或自动化来源。 | +| Key | 脱敏后的访问 Key。可以点击 `复制 API 密钥` 复制完整 Key。 | +| 过期 | 当前 Key 的过期时间。过期后客户端不能继续使用这个 Key 访问 CCR。 | +| 限制 | 当前 Key 的本地限额摘要。没有配置限额时显示“未配置限制”。 | +| 编辑 API 密钥 | 修改过期时间和限额。出于安全原因,已创建的 Key 本身不会重新明文展示。 | +| 移除 API 密钥 | 删除当前客户端访问 Key。删除后立即不能再用于请求。 | + +## 创建和编辑 + +| 字段 | 代表的能力 | +| --- | --- | +| 名称 | 新 Key 的显示名。建议写成客户端或用途,例如 `Claude Code - laptop`、`CI` 或团队名。 | +| 过期时间 | 选择 Key 的有效期:`永不`、`7 天`、`30 天`、`90 天` 或 `自定义`。 | +| 过期于 | 选择 `自定义` 时出现,用于填写精确的过期日期和时间。 | +| API 密钥已创建 | 创建成功后的确认弹窗。这里会显示完整 Key。 | +| 请现在复制保存这个密钥,之后可能不会再次完整显示。 | 提醒你立即复制 Key。关闭弹窗后,CCR 不会再次展示完整 Key。 | + +## 高级设置 + +`高级设置` 用于给单个客户端 Key 添加本地限额。限额命中后,使用这个 Key 的客户端请求会被拒绝或限制;它不会修改供应商侧额度。 + +| 字段 | 代表的能力 | +| --- | --- | +| 高级设置 | 展开或收起限额编辑区。 | +| 未配置限制 | 当前 Key 没有任何本地限额。 | +| 请求 | 按请求次数限制。 | +| 令牌 | 按 token 数量限制。 | +| 图片 | 按图片数量限制。 | +| 每分钟 | 限额窗口为 1 分钟。 | +| 每小时 | 限额窗口为 1 小时。 | +| 每天 | 限额窗口为 1 天。 | +| 添加限制 | 新增一条限额规则。 | +| 移除限制 | 删除当前限额规则。 | diff --git a/docs/src/content/docs/zh/configuration/overview.md b/docs/src/content/docs/zh/configuration/overview.md new file mode 100644 index 0000000..24a188b --- /dev/null +++ b/docs/src/content/docs/zh/configuration/overview.md @@ -0,0 +1,59 @@ +--- +title: 概览仪表盘 +pageTitle: 概览仪表盘 +eyebrow: 详细配置 +lead: 自定义 CCR 首页组件,查看系统状态、账户余额、请求量、tokens、成本、模型分布和可分享卡片。 +--- + +## 基本概念 + +“概览”是 CCR 的仪表盘页面。它把网关运行状态、供应商账户、请求用量和分析结果做成可拖拽组件。你可以切换时间范围、编辑组件、调整大小、切换样式,也可以把部分数据生成分享卡片。 + +概览依赖请求日志、用量统计和供应商账户用量。某些组件需要先在“设置”里开启请求日志或 Agent 观测,账户余额组件需要在供应商配置中开启“获取用量”。 + +## 顶部控件 + +| 字段 | 代表的能力 | +| --- | --- | +| 按时间查看用量 | 切换仪表盘使用的统计时间范围。 | +| 今天 / 24 小时 / 7 天 / 30 天 | 可选统计窗口。不同组件会根据这个窗口重新计算请求、tokens、成本和趋势。 | +| 编辑组件 | 进入布局编辑模式。 | +| 重置布局 | 恢复默认概览布局。 | +| 完成 | 退出布局编辑模式并保留当前组件配置。 | + +## 组件区 + +编辑模式下,左侧“组件”用于添加组件,中间“预览”展示当前布局,右侧“组件属性”编辑选中组件。 + +| 字段 | 代表的能力 | +| --- | --- | +| 组件 | 可添加组件列表。 | +| 预览 | 当前仪表盘布局。组件可以拖拽排序。 | +| 组件属性 | 当前选中组件的配置区域。 | +| 组件类型 | 切换组件大类,例如状态、账户、指标、趋势、活跃度、构成、分析或分享卡片。 | +| 数据 | 选择组件展示的数据,例如请求数、tokens、成本、账户、客户端分析或供应商分析。 | +| 组件大小 | 控制组件占用的网格宽高。 | +| 样式 | 切换组件展示样式,例如卡片、紧凑、柱状图、折线图、圆环等。 | +| 移除组件 | 从概览中移除当前组件。 | + +## 组件类型 + +| 组件 | 代表的能力 | +| --- | --- | +| 状态组件 | 展示系统状态时间线,帮助判断网关近期是否正常。 | +| 账户组件 | 展示供应商账户余额、套餐额度或用量。需要供应商开启“获取用量”。 | +| 指标组件 | 展示请求数、总 tokens、输入 tokens、输出 tokens、缓存 tokens、缓存比例、估算成本、成功率、错误数或平均延迟。 | +| 趋势组件 | 展示按时间聚合的用量趋势。 | +| 活跃度组件 | 展示 token 活跃度热力图。 | +| 构成组件 | 展示 Token 构成或模型分布。 | +| 分析组件 | 展示客户端分析或供应商分析。 | +| 分享卡片 | 生成适合分享的 PNG 卡片,例如 AI Usage Wrapped、CCR Route Map、Model Leaderboard、AI Fuel Cockpit、Token Calendar Poster 和 Spend Receipt。 | + +## 数据来源 + +| 数据 | 来源 | +| --- | --- | +| 请求数、tokens、成本、成功率、延迟 | 请求日志和用量统计。 | +| 账户余额、套餐额度、状态消息 | 供应商“获取用量”配置。 | +| 客户端分析、供应商分析、模型分布 | 请求日志中的客户端、供应商、模型和 token 信息。 | +| Agent 分析相关数据 | 设置中的“Agent 观测”和 Agent 执行链路数据。 | diff --git a/docs/src/content/docs/zh/configuration/provider.md b/docs/src/content/docs/zh/configuration/provider.md index ded9a4b..af3cbdb 100644 --- a/docs/src/content/docs/zh/configuration/provider.md +++ b/docs/src/content/docs/zh/configuration/provider.md @@ -7,20 +7,130 @@ lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列 ## 基本概念 -供应商是 CCR 转发请求的上游模型服务。每个供应商至少需要协议、基础 URL、模型列表和一条可用凭据。 +供应商是 CCR 转发请求的上游模型服务。一个供应商配置同时描述了上游地址、协议能力、模型列表、认证方式、可选的多 Key 轮换,以及可选的账号用量读取能力。 + +每个供应商至少需要名称、API 地址、可用协议、模型列表和一条可用凭据。添加或编辑供应商时,弹窗中的字段会根据“预设供应商 / 自定义 API 地址 / 本机 Agent 登录态导入 / 高级设置”动态显示。 + +预设供应商会自动填入常见 API 地址、协议、模型和用量读取配置,适合优先使用。自定义供应商需要手动填写 API 地址,CCR 会根据 API 地址和 API 密钥做协议探测,帮助判断当前上游是否兼容 OpenAI Chat、OpenAI Responses、Anthropic Messages 或 Gemini Generate。 + +## 主字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 选择 预设供应商 | 套用 CCR 内置供应商模板,包括默认 API 地址、可用协议、默认模型、图标、官网链接和部分供应商的用量读取配置。选择 `其他 / 自定义 API 地址` 时,可以接入任意兼容 OpenAI、Anthropic 或 Gemini 协议的上游服务。 | +| 导入本机 Agent 登录态 | 添加供应商时,如果 CCR 在本机发现 Claude Code、Codex 或 ZCode 的可用登录状态,会显示导入入口。导入后 CCR 会生成供应商和对应 provider plugin,用本机已有登录凭据访问上游,不需要手动粘贴常规 API Key。 | +| 名称 | CCR 内部显示名,也是路由、模型选择、日志和配置中识别供应商的名字。名称必须唯一,建议短且稳定。 | +| API 地址 | 上游 API Base URL。它决定请求实际发往哪里,也用于协议探测、模型列表探测、图标探测和安全校验。预设供应商添加时默认隐藏该字段,可在高级设置里覆盖。自定义供应商必须填写。 | +| API 密钥 | 默认供应商凭据。没有配置凭据池时,模型请求会使用这条 Key;协议探测、模型探测、连通性检测和默认用量读取也会使用它。只填写由当前 API 地址对应供应商签发的 Key。 | +| 模型 | 暴露给 CCR 的模型 ID 列表。路由规则、Profile 模型选择、模型目录和客户端 `/models` 响应都会基于这里的模型。 | +| 搜索模型 / 全部 / 清除 | 当 CCR 能从上游或模型目录拿到模型列表时,可以搜索、全选、清除并勾选模型。勾选结果会保存到供应商的模型列表。 | +| 自定义模型 | 手动添加没有被探测出来的模型 ID。适合供应商没有 `/models` 接口,或新模型还未进入模型目录的情况。 | +| 检测连通性 | 用当前 API 地址、API 密钥、协议和所选模型发送真实测试请求。它可以验证 Key、模型名和协议是否真的可调用。 | +| 要检测的模型 | 连接检查确认弹窗中的模型选择。用于控制只测试部分模型,避免一次性检查全部模型造成额外消耗。 | +| 检测结果 | 展示每个模型是否可用、命中的协议和上游返回的诊断信息。可用结果不会自动增加模型,仍以弹窗主表单中的模型选择为准。 | + +## 连通性检测 + +`检测连通性` 会对你选择的模型发送真实的模型请求,用来确认 API 地址、API 密钥、协议和模型 ID 是否可用。检测请求会限制输出长度,但仍然可能产生额外 token 消耗或计入供应商侧请求次数。 + +如果供应商按请求、输入 token 或输出 token 计费,建议只勾选需要确认的模型,不要一次性检查全部模型。检测结果只用于诊断连通性,不会自动修改模型列表或用量读取配置。 ## 凭据 -凭据用来保存 API Key。多条凭据可以按优先级和权重轮换,也可以在某条 Key 触发限额或失败时自动切换。 +`API 密钥` 是最简单的单 Key 配置。需要管理多条上游 Key 时,展开“高级设置”中的“凭据池”。 -建议给每条 Key 设置容易识别的名称,方便在请求日志里定位问题。 - -## 供应商选项 - -| 字段 | 说明 | +| 字段 | 代表的能力 | | --- | --- | -| 名称 | CCR 内部显示名,建议短且可识别 | -| 基础 URL | 上游服务地址,自定义供应商要确认包含正确 API 路径 | -| 协议 | OpenAI / Anthropic / Gemini 等协议 | -| 模型 | 暴露给 CCR 的模型列表 | -| 请求头 | 少数兼容服务需要的额外请求头 | +| 显示凭据配置 | 展开或收起凭据池配置。未展开不影响已保存的凭据,只是隐藏编辑区域。 | +| 导入 JSON | 从 JSON 文件批量导入凭据。支持顶层数组,或对象中的 `credentials`、`keys`、`apiKeys` 数组。 | +| 添加 Key | 新增一条上游 API Key。 | +| 启用 | 控制单条凭据是否参与请求转发和用量读取。关闭后保留配置,但不会被选中。 | +| 名称 | 单条凭据的显示名。会出现在账号用量、日志和内部诊断里,建议写成可识别的用途或额度来源。 | +| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池,而不是主表单的默认 `API 密钥`。 | +| 移除 | 删除当前凭据行。 | +| Key 高级选项 | 展开单条凭据的调度和限额字段。 | +| 优先级 | 凭据优先级,数字越小越优先。未填写时按凭据行顺序作为优先级。 | +| 权重 | 同优先级、相近使用率下的排序权重,数字越大越优先。未填写时为 `1`。 | +| 限制 JSON | 该 Key 的本地限额规则。CCR 会按请求、tokens 或图片数量统计窗口使用量,达到上限后自动跳过该 Key,尝试同供应商的其他可用 Key。 | + +`限制 JSON` 支持的常用字段: + +| 字段 | 含义 | +| --- | --- | +| `rpm` / `rph` / `rpd` | 每分钟 / 每小时 / 每天最多请求数 | +| `tpm` / `tph` / `tpd` | 每分钟 / 每小时 / 每天最多 tokens | +| `ipm` / `iph` / `ipd` | 每分钟 / 每小时 / 每天最多图片数 | +| `maxRequests` + `windowMs` | 自定义时间窗口内最多请求数 | +| `maxTokens` + `quotaWindowMs` | 自定义时间窗口内最多 tokens | + +示例: + +```json +{ + "rpm": 60, + "tpm": 100000 +} +``` + +凭据池的作用是“上游 Key 池”,不同于“API 密钥”页面里的 CCR 客户端访问 Key。前者控制 CCR 调用供应商时使用哪个 Key,后者控制客户端访问 CCR 时使用哪个 Key。 + +## 用量读取 + +“获取用量”用于让 CCR 在供应商列表、托盘或账号面板中展示余额、套餐额度、状态和错误信息。它不会影响模型是否能请求,只影响账号用量展示。 + +| 字段 | 代表的能力 | +| --- | --- | +| 获取用量 | 启用或关闭该供应商的账号用量读取。关闭后不请求用量接口。 | +| 用量模式 | 用量读取方式。`标准用量端点` 使用 CCR 标准账号端点;`HTTP JSON 请求` 手动配置一个 JSON 接口;`原始连接器 JSON` 直接编辑 connector 数组。 | +| 刷新间隔(毫秒) | 用量刷新间隔,单位毫秒。未填写时使用默认刷新间隔,最小有效间隔为 30000ms。 | + +### 标准用量端点 + +该模式会尝试供应商侧的 CCR 标准账号端点,例如 `/.well-known/ccr/account` 和 `/v1/account/limits`。适合已经适配 CCR 标准格式的供应商或内置预设。 + +### HTTP JSON 请求 + +该模式适合供应商已有自己的余额或额度接口,但返回格式不是 CCR 标准格式的情况。 + +| 字段 | 代表的能力 | +| --- | --- | +| 方法 | 用量请求方法,支持 `GET` 或 `POST`。 | +| 用量请求 URL | 用量接口地址。可以是完整 URL。请求会附带供应商 API Key,除非在 raw connector 中改成其他认证方式。 | +| 请求头 | 用量接口需要的额外请求头。不要在这里写固定的敏感认证头,优先使用供应商 API Key 认证。 | +| 请求体 | `POST` 请求体,必须是合法 JSON。 | +| 余额剩余字段 | 余额剩余值在响应 JSON 中的路径。 | +| 余额总额字段 | 余额总量或充值总额在响应 JSON 中的路径。 | +| 余额已用字段 | 已用余额在响应 JSON 中的路径。 | +| 余额单位 | 余额单位,例如 `USD`、`CNY` 或 `%`。 | +| 订阅剩余字段 | 套餐、订阅、tokens 或配额剩余量路径。 | +| 订阅上限字段 | 套餐、订阅、tokens 或配额总量路径。 | +| 订阅重置字段 | 套餐重置时间路径。可以返回 ISO 时间,也可以返回秒级或毫秒级时间戳。 | +| 订阅单位 | 套餐单位,例如 `tokens`、`requests`、`hours`。 | +| 状态字段 | 账号状态路径。支持 `ok`、`warning`、`critical`、`error`、`unsupported`。 | +| 消息字段 | 账号提示信息路径。适合展示供应商返回的错误、套餐说明或风控提示。 | +| 测试用量请求 | 立即请求用量接口并解析映射结果,方便在保存前验证字段路径。 | +| 响应字段 | 测试后列出响应 JSON 中可选字段。点击 `余额剩余`、`余额总额`、`余额已用`、`订阅剩余`、`订阅上限`、`重置时间` 可以把该路径快速填入对应字段。 | + +字段路径支持 CCR 的轻量 JSONPath 语法: + +| 写法 | 说明 | +| --- | --- | +| `$` | 整个响应对象 | +| `$.balance.remaining` | 读取对象字段 | +| `$.items[0].value` | 读取数组下标 | +| `$["weird-key"]` | 读取包含特殊字符的字段名 | +| `$.limits[?(@.type=="TOKENS")].remaining` | 在数组中查找第一个满足简单等值条件的对象 | +| `100 - $.data.percentage` | 数值字段支持简单减法表达式,常用于把“已用百分比”转换成“剩余百分比” | + +### 原始连接器 JSON + +`连接器 JSON` 允许直接编辑 `account.connectors` 数组,适合需要更复杂能力的供应商。 + +| 连接器类型 | 能力 | +| --- | --- | +| `standard` | 使用 CCR 标准账号端点。 | +| `http-json` | 请求一个 JSON 接口,并用 mapping 字段映射余额、套餐、状态和消息。 | +| `plugin` | 调用已安装插件注册的账号用量 connector。 | +| `local-estimate` | 不请求远程接口,基于本地窗口配置展示估算额度。 | + +点击 `插入示例` 会填入一个包含 `standard`、`http-json`、`plugin` 和 `local-estimate` 的示例 connector 数组。 diff --git a/docs/src/content/docs/zh/configuration/server.md b/docs/src/content/docs/zh/configuration/server.md new file mode 100644 index 0000000..3ec3d25 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/server.md @@ -0,0 +1,32 @@ +--- +title: 服务配置 +pageTitle: 服务配置 +eyebrow: 详细配置 +lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。 +--- + +## 基本概念 + +“服务”页面控制 CCR 本地网关如何监听请求。普通模型请求通常只需要配置 `Host` 和 `Port`。 + +## 主字段 + +| 字段 | 代表的能力 | +| --- | --- | +| Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 | +| Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 | + +## 代理模式 + +代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。 + +| 字段 | 代表的能力 | +| --- | --- | +| 代理模式 | 启用本地代理能力。开启后 CCR 可以作为 HTTP/HTTPS 代理接收客户端流量,并通过 MITM 劫持把模型请求代理到 CCR。 | +| 系统代理 | 将系统代理指向 CCR。适合让支持系统代理的应用自动经过 CCR。 | +| 捕获网络 | 保存代理模式下经过 CCR 的网络请求,用于“网络”页面查看请求和响应详情。 | +| CA 证书 | 当前代理 CA 证书的信任状态。 | +| 安装 CA | 将 CCR 代理 CA 安装到系统或当前用户信任存储中。不同系统的安装方式不同。 | +| 检查信任 | 重新检测代理 CA 是否已被系统信任。 | +| 代理状态 | 显示代理服务当前是否运行。 | +| 重启代理 | 代理模式开启时,重新启动代理服务。 | diff --git a/docs/src/content/docs/zh/configuration/tray.md b/docs/src/content/docs/zh/configuration/tray.md new file mode 100644 index 0000000..f2bc0c1 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/tray.md @@ -0,0 +1,45 @@ +--- +title: 托盘配置 +pageTitle: 托盘配置 +eyebrow: 详细配置 +lead: 配置 CCR 系统托盘图标、余额进度条和托盘窗口组件。 +--- + +## 基本概念 + +“托盘”位于“设置”里,用于配置系统托盘图标和托盘窗口。托盘窗口可以展示供应商、账户余额、token 趋势、活跃度、指标和模型占比等信息,适合在不打开主窗口时快速查看 CCR 状态。 + +## 顶部字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 托盘小精灵 | 选择托盘图标样式。可选“随机”“晴岚”“暖阳”“星澜”和“余额进度条”。 | +| 余额进度条 | 使用供应商账户用量作为托盘图标进度。需要先为供应商启用“获取用量”。 | +| 账户 | 选择用于余额进度条的供应商账户。 | +| 数据 | 选择账户里的余额、套餐或额度数据作为进度来源。 | + +如果没有可用账户数据,页面会提示“暂无可用账户数据,请先为供应商启用账户监控。”这通常意味着还没有供应商开启“获取用量”,或用量读取尚未成功。 + +## 托盘窗口布局 + +| 区域 | 代表的能力 | +| --- | --- | +| 组件区 | 左侧组件库,用于添加或启用托盘窗口组件。 | +| 预览 | 中间预览区,展示当前托盘窗口布局。组件可以拖拽排序。 | +| 组件属性 | 右侧配置区,用于调整选中组件的“样式”,或移除当前组件。 | + +## 组件类型 + +| 组件 | 代表的能力 | +| --- | --- | +| 供应商组件 | 显示“供应商切换”,用于在托盘窗口中按供应商查看数据。该组件是单例组件,只能启用一次。 | +| 标题组件 | 显示“标题和状态”。该组件是单例组件,只能启用一次。 | +| 账户组件 | 显示“账户余额”。可以添加多个账户组件,并选择不同样式。 | +| 趋势组件 | 显示“Token 趋势图”。 | +| 活跃度组件 | 显示“Token 活跃度”。 | +| 指标组件 | 显示“Token 指标”。 | +| 构成组件 | 显示“Token 构成”“环形指标”或“模型占比”。 | + +## 样式 + +不同组件支持不同“样式”。常见样式包括“卡片”“紧凑”“列表”“胶囊”“折线图”“面积图”“柱状图”“圆环”“环形图”“仪表盘”“迷你折线”“堆叠”等。样式只影响托盘窗口展示方式,不改变请求路由、供应商配置或用量统计。 diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 00aef30..7ea35a1 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -26,7 +26,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个 | --- | --- | | [文档](./) | 产品定位、架构概览、阅读路径 | | [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 | -| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置数据库位置 | +| [详细配置](configuration/provider/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 | | [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 | Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。 @@ -37,7 +37,7 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独 1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。 2. App 的请求日志页面展示请求是否经过 CCR。 -3. [详细配置](configuration/) 覆盖图像、联网搜索、MCP 工具和 IM 接力。 +3. [详细配置](configuration/provider/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。 4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。 这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index be1739b..731f868 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -15,7 +15,7 @@ export const docsContent = { navItems: [ { label: "文档", href: "/", pageKey: "documentation" }, { label: "快速开始", href: "/guides/", pageKey: "guides" }, - { label: "详细配置", href: "/configuration/", pageKey: "configuration" }, + { label: "详细配置", href: "/configuration/provider/", pageKey: "configuration" }, { label: "Q&A", href: "/troubleshooting/", pageKey: "troubleshooting" }, ], pages: { @@ -63,12 +63,16 @@ export const docsContent = { label: "详细配置", icon: "wand", items: [ + "概览仪表盘", "供应商配置", "一键导入供应商", "路由配置", "日志&观测", "Fusion 组合模型", "Agent配置", + "API 密钥", + "服务配置", + "托盘配置", "扩展机制", "Bot 与 IM 接力 Agent", "配置数据库位置", @@ -92,6 +96,7 @@ export const docsContent = { ], }, sidebarLinks: { + 概览仪表盘: "/configuration/overview/", 供应商配置: "/configuration/provider/", "一键导入供应商": "/configuration/provider-deeplink/", 路由配置: "/configuration/routing/", @@ -101,6 +106,9 @@ export const docsContent = { 内置联网搜索: "/configuration/fusion-web-search/", "自定义 MCP 工具": "/configuration/fusion-mcp-tool/", Agent配置: "/configuration/profile/", + "API 密钥": "/configuration/api-keys/", + 服务配置: "/configuration/server/", + 托盘配置: "/configuration/tray/", 扩展机制: "/configuration/extensions/", "Bot 与 IM 接力 Agent": "/configuration/bot-relay/", 配置步骤: "/configuration/bot-setup/", @@ -155,7 +163,7 @@ export const docsContent = { navItems: [ { label: "Documentation", href: "/en/", pageKey: "documentation" }, { label: "Quick Start", href: "/en/guides/", pageKey: "guides" }, - { label: "Detailed Configuration", href: "/en/configuration/", pageKey: "configuration" }, + { label: "Detailed Configuration", href: "/en/configuration/providers/", pageKey: "configuration" }, { label: "Q&A", href: "/en/troubleshooting/", pageKey: "troubleshooting" }, ], pages: { @@ -203,12 +211,16 @@ export const docsContent = { label: "Detailed Configuration", icon: "wand", items: [ + "Overview Dashboard", "Provider Config", "One click import", "Routing Config", "Logs & Observability", "Fusion Models", "Agent Config", + "API Keys", + "Server", + "Tray Configuration", "Extension Mechanism", "Bots And IM Agent Relay", "Config Database Location", @@ -222,6 +234,7 @@ export const docsContent = { "Bots And IM Agent Relay": ["Setup", "Slack", "Discord", "Telegram", "LINE", "Weixin", "WeCom", "Feishu", "DingTalk"], }, sidebarLinks: { + "Overview Dashboard": "/en/configuration/overview/", "Provider Config": "/en/configuration/providers/", "One click import": "/en/configuration/provider-deeplink/", "Routing Config": "/en/configuration/routing/", @@ -231,6 +244,9 @@ export const docsContent = { "Built-In Web Search": "/en/configuration/fusion-web-search/", "Custom MCP Tool": "/en/configuration/fusion-mcp-tool/", "Agent Config": "/en/configuration/profiles/", + "API Keys": "/en/configuration/api-keys/", + Server: "/en/configuration/server/", + "Tray Configuration": "/en/configuration/tray/", "Extension Mechanism": "/en/configuration/extensions/", "Bots And IM Agent Relay": "/en/configuration/bots/", Setup: "/en/configuration/bot-setup/", diff --git a/docs/src/pages/configuration.astro b/docs/src/pages/configuration.astro index e241173..1ad9fba 100644 --- a/docs/src/pages/configuration.astro +++ b/docs/src/pages/configuration.astro @@ -1,6 +1,17 @@ --- -import DocPage from "../components/DocPage.astro"; -import * as doc from "../content/docs/zh/configuration.md"; +const target = "/configuration/provider/"; --- - + + + + + + + + + 供应商配置 + + diff --git a/docs/src/pages/configuration/[slug].astro b/docs/src/pages/configuration/[slug].astro index 9253864..3dda58c 100644 --- a/docs/src/pages/configuration/[slug].astro +++ b/docs/src/pages/configuration/[slug].astro @@ -4,6 +4,8 @@ import { configurationSlugFromPath, zhConfigurationDocs } from "../../configurat export function getStaticPaths() { const activeLabels: Record = { + "api-keys": "API 密钥", + overview: "概览仪表盘", provider: "供应商配置", "provider-deeplink": "一键导入供应商", routing: "路由配置", @@ -14,6 +16,8 @@ export function getStaticPaths() { "fusion-web-search": "内置联网搜索", "fusion-mcp-tool": "自定义 MCP 工具", extensions: "扩展机制", + server: "服务配置", + tray: "托盘配置", "bot-relay": "Bot 与 IM 接力 Agent", "bot-setup": "配置步骤", "config-file": "配置数据库位置", diff --git a/docs/src/pages/en/configuration.astro b/docs/src/pages/en/configuration.astro index c2ce75b..01088a9 100644 --- a/docs/src/pages/en/configuration.astro +++ b/docs/src/pages/en/configuration.astro @@ -1,6 +1,17 @@ --- -import DocPage from "../../components/DocPage.astro"; -import * as doc from "../../content/docs/en/configuration.md"; +const target = "/en/configuration/providers/"; --- - + + + + + + + + +
Provider Config + + diff --git a/docs/src/pages/en/configuration/[slug].astro b/docs/src/pages/en/configuration/[slug].astro index 4cd9324..cc2fc90 100644 --- a/docs/src/pages/en/configuration/[slug].astro +++ b/docs/src/pages/en/configuration/[slug].astro @@ -4,6 +4,8 @@ import { configurationSlugFromPath, enConfigurationDocs } from "../../../configu export function getStaticPaths() { const activeLabels: Record = { + "api-keys": "API Keys", + overview: "Overview Dashboard", providers: "Provider Config", "provider-deeplink": "One click import", routing: "Routing Config", @@ -14,6 +16,8 @@ export function getStaticPaths() { "fusion-web-search": "Built-In Web Search", "fusion-mcp-tool": "Custom MCP Tool", extensions: "Extension Mechanism", + server: "Server", + tray: "Tray Configuration", bots: "Bots And IM Agent Relay", "bot-setup": "Setup", "configuration-file": "Config Database Location", diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index fa2ee33..200701e 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -1364,7 +1364,7 @@ export function AddProviderForm({
)} - + onChange({ name: event.target.value })} /> {showBaseUrl ? ( diff --git a/src/renderer/pages/home/components/virtual-models.tsx b/src/renderer/pages/home/components/virtual-models.tsx index ee75dff..453a957 100644 --- a/src/renderer/pages/home/components/virtual-models.tsx +++ b/src/renderer/pages/home/components/virtual-models.tsx @@ -3,11 +3,11 @@ import { Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, ChevronRight, cn, createMcpServerDraftFromConfig, createRouteModelOptions, defaultFusionWebSearchProvider, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, ExtensionInstallDraft, Field, FolderOpen, formatPluginDependencies, - createFusionWebSearchEnvRows, customFusionToolName, fusionToolExecutionFlags, fusionToolOptions, - fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, + createFusionWebSearchEnvRows, customFusionToolName, fusionToolExecutionFlagsFromTools, fusionToolOptions, + fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, isBuiltInFusionToolName, isFusionVisionToolName, isFusionWebSearchToolName, KeyValueRowsControl, LoaderCircle, mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, motion, normalizeFusionToolName, Pencil, - PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolName, + PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolNames, SelectControl, Toggle, Trash2, translateOptions, useAppErrorText, useAppText, useEffect, useLayoutEffect, useMemo, useRef, useState, validateMcpServerDraft, virtualModelBaseModelSummary, VirtualModelDraft, virtualModelMatchesQuery, virtualModelMatchSummary, VirtualModelProfileConfig, virtualModelToolSummary, X @@ -16,6 +16,25 @@ import { const virtualModelTableGridClass = "grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px]"; const virtualModelTableMinWidthClass = "min-w-[1100px]"; +function uniqueFusionTools(tools: string[]): string[] { + const seen = new Set(); + const values: string[] = []; + for (const tool of tools) { + const normalized = normalizeFusionToolName(tool); + if (!normalized || seen.has(normalized)) { + continue; + } + values.push(normalized); + seen.add(normalized); + } + return values; +} + +function visibleFusionToolOption(toolName: string, currentValue: string, excludedValues: Set): boolean { + const normalized = normalizeFusionToolName(toolName); + return Boolean(normalized) && (normalized === currentValue || !excludedValues.has(normalized)); +} + export function VirtualModelsView({ addVirtualModel, editVirtualModel, @@ -157,11 +176,11 @@ export function VirtualModelDialog({ const t = useAppText(); const formatError = useAppErrorText(); const modelOptions = useMemo(() => createRouteModelOptions(providers), [providers]); - const selectedTool = selectedFusionToolName(draft.toolsText); - const selectedToolFlags = fusionToolExecutionFlags(selectedTool); + const selectedTools = selectedFusionToolNames(draft.toolsText); const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false); const [customMcpDialogDraft, setCustomMcpDialogDraft] = useState(draft.customMcpServer); const [customMcpDialogError, setCustomMcpDialogError] = useState(""); + const [addingFusionTool, setAddingFusionTool] = useState(false); const [mcpToolStateByServer, setMcpToolStateByServer] = useState isBuiltInFusionToolName(tool) || tool === selectedCustomTool) + : nextTools + ); + const flags = fusionToolExecutionFlagsFromTools(normalizedTools); + const customTool = normalizedTools.find((tool) => !isBuiltInFusionToolName(tool)); onChange({ ...(flags.matchWebSearch && draft.webSearchEnvRows.length === 0 ? { webSearchEnvRows: createFusionWebSearchEnvRows(draft.webSearchProvider) } : {}), - ...(!flags.matchMultimodal && !flags.matchWebSearch ? { + ...(customTool ? { ...(server ? { customMcpServer: createMcpServerDraftFromConfig(server) } : {}), - customToolName: nextTool || draft.customToolName || customFusionToolName + customToolName: customTool || draft.customToolName || customFusionToolName } : {}), - toolsText: nextTool, + toolsText: normalizedTools.join(", "), ...flags }); } + function appendFusionTool(toolName: string, server?: GatewayMcpServerConfig) { + const nextTool = normalizeFusionToolName(toolName); + if (!nextTool) { + return; + } + applyFusionTools([...selectedFusionToolNames(draft.toolsText), nextTool], server, server ? nextTool : undefined); + setAddingFusionTool(false); + } + + function updateFusionTool(index: number, toolName: string, server?: GatewayMcpServerConfig) { + const nextTool = normalizeFusionToolName(toolName); + if (!nextTool) { + return; + } + const currentTools = selectedFusionToolNames(draft.toolsText); + const nextTools = currentTools.map((tool, toolIndex) => toolIndex === index ? nextTool : tool); + applyFusionTools(nextTools, server, server ? nextTool : undefined); + } + + function removeFusionTool(index: number) { + applyFusionTools(selectedFusionToolNames(draft.toolsText).filter((_, toolIndex) => toolIndex !== index)); + } + function openCustomMcpDialog() { setCustomMcpDialogDraft(draft.customMcpServer); setCustomMcpDialogError(""); @@ -289,12 +337,9 @@ export function VirtualModelDialog({ const tools = await discoverMcpServerTools(server, true); const nextTool = normalizeFusionToolName(tools[0]?.name || ""); if (nextTool) { - onChange({ - customMcpServer: createMcpServerDraftFromConfig(server), - customToolName: nextTool, - toolsText: nextTool, - ...fusionToolExecutionFlags(nextTool) - }); + const currentTools = selectedFusionToolNames(draft.toolsText); + applyFusionTools(currentTools.includes(nextTool) ? currentTools : [...currentTools, nextTool], server, nextTool); + setAddingFusionTool(false); } } @@ -322,11 +367,18 @@ export function VirtualModelDialog({
+
- setAddingFusionTool(true)} + onAppendTool={appendFusionTool} + onCancelAddTool={() => setAddingFusionTool(false)} + onChange={onChange} + onChangeTool={updateFusionTool} onDiscoverMcpTools={(server, force) => { if (server) { void discoverMcpServerTools(server, force); @@ -334,28 +386,13 @@ export function VirtualModelDialog({ } discoverVisibleMcpServers(); }} + onRemoveTool={removeFusionTool} selectedMcpServerName={draft.customMcpServer.name} - value={selectedTool} + values={selectedTools} />
- {selectedToolFlags.matchMultimodal ? ( -
-
-
{t("Vision tool configuration")}
-
{t("Choose a configured gateway model for image understanding.")}
-
- - onChange({ visionModel })} value={draft.visionModel} /> - -
- ) : null} - - {selectedToolFlags.matchWebSearch ? ( - - ) : null} - {error ? (
{t(error)}
) : null} @@ -412,9 +449,6 @@ function WebSearchToolConfigurationPanel({ return (
-
-
{t("Web search configuration")}
-
@@ -549,7 +583,166 @@ function CustomMcpToolDialog({ ); } +function FusionToolConfigurationPanel({ + draft, + modelOptions, + onChange, + toolName +}: { + draft: VirtualModelDraft; + modelOptions: ReturnType; + onChange: (patch: Partial) => void; + toolName: string; +}) { + if (isFusionVisionToolName(toolName)) { + return ; + } + if (isFusionWebSearchToolName(toolName)) { + return ; + } + return null; +} + +function VisionToolConfigurationPanel({ + draft, + modelOptions, + onChange +}: { + draft: VirtualModelDraft; + modelOptions: ReturnType; + onChange: (patch: Partial) => void; +}) { + const t = useAppText(); + + return ( +
+ + onChange({ visionModel })} value={draft.visionModel} /> + +
+ ); +} + +function FusionToolsListControl({ + adding, + draft, + mcpServers, + mcpToolStateByServer, + modelOptions, + onAddCustomMcpTool, + onAddTool, + onAppendTool, + onCancelAddTool, + onChange, + onChangeTool, + onDiscoverMcpTools, + onRemoveTool, + selectedMcpServerName, + values +}: { + adding: boolean; + draft: VirtualModelDraft; + mcpServers: GatewayMcpServerConfig[]; + mcpToolStateByServer: Record; + modelOptions: ReturnType; + onAddCustomMcpTool: () => void; + onAddTool: () => void; + onAppendTool: (value: string, server?: GatewayMcpServerConfig) => void; + onCancelAddTool: () => void; + onChange: (patch: Partial) => void; + onChangeTool: (index: number, value: string, server?: GatewayMcpServerConfig) => void; + onDiscoverMcpTools: (server?: GatewayMcpServerConfig, force?: boolean) => void; + onRemoveTool: (index: number) => void; + selectedMcpServerName: string; + values: string[]; +}) { + const t = useAppText(); + const selectedToolValues = values.map(normalizeFusionToolName).filter(Boolean); + + return ( +
+ {values.map((value, index) => ( +
+
+
+ toolIndex !== index)} + mcpServers={mcpServers} + mcpToolStateByServer={mcpToolStateByServer} + onAddCustomMcpTool={onAddCustomMcpTool} + onChange={(toolName, server) => onChangeTool(index, toolName, server)} + onDiscoverMcpTools={onDiscoverMcpTools} + selectedMcpServerName={selectedMcpServerName} + value={value} + /> +
+ +
+ +
+ ))} + + {adding ? ( +
+
+ +
+ +
+ ) : null} + + +
+ ); +} + function FusionToolSelectControl({ + excludedValues, mcpServers, mcpToolStateByServer, onAddCustomMcpTool, @@ -558,6 +751,7 @@ function FusionToolSelectControl({ selectedMcpServerName, value }: { + excludedValues?: string[]; mcpServers: GatewayMcpServerConfig[]; mcpToolStateByServer: Record(); const rootRef = useRef(null); const normalizedValue = normalizeFusionToolName(value); + const excludedValueSet = new Set((excludedValues ?? []).map(normalizeFusionToolName).filter(Boolean)); const selected = fusionToolOptions.find((option) => option.value === normalizedValue); const selectedServer = selectedMcpServerName ? mcpServers.find((server) => server.name === selectedMcpServerName) @@ -703,7 +898,7 @@ function FusionToolSelectControl({ role="listbox" style={{ maxHeight: `${popoverLayout?.maxHeight ?? 360}px` }} > - {fusionToolOptions.map((option) => { + {fusionToolOptions.filter((option) => visibleFusionToolOption(option.value, normalizedValue, excludedValueSet)).map((option) => { const selectedOption = option.value === selected?.value; return ( -
- -
- - - +
+
+ {title} + {subtitle ? {subtitle} : null} + +
+ +
+ + + +
); diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index 200701e..bde3e90 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -250,8 +250,22 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not ); } -export function ModelsView({ config }: { config: AppConfig }) { +export function ModelsView({ + config, + updateModelDescription +}: { + config: AppConfig; + updateModelDescription: (providerIndex: number, model: string, description: string) => void; +}) { const t = useAppText(); + const [descriptionDraft, setDescriptionDraft] = useState(""); + const [descriptionTarget, setDescriptionTarget] = useState<{ + description: string; + displayName?: string; + model: string; + providerIndex: number; + providerName?: string; + }>(); const [query, setQuery] = useState(""); const rows = useMemo(() => createModelCatalogItems(config), [config]); const normalizedQuery = query.trim().toLowerCase(); @@ -260,6 +274,34 @@ export function ModelsView({ config }: { config: AppConfig }) { [normalizedQuery, rows] ); + function openDescriptionDialog(row: (typeof rows)[number]) { + if (row.providerIndex === undefined) { + return; + } + const description = row.description ?? ""; + setDescriptionDraft(description); + setDescriptionTarget({ + description, + displayName: row.displayName, + model: row.model, + providerIndex: row.providerIndex, + providerName: row.providerName + }); + } + + function closeDescriptionDialog() { + setDescriptionDraft(""); + setDescriptionTarget(undefined); + } + + function saveDescriptionDialog() { + if (!descriptionTarget) { + return; + } + updateModelDescription(descriptionTarget.providerIndex, descriptionTarget.model, descriptionDraft); + closeDescriptionDialog(); + } + return ( 0 ? (
-
-
+
+
{t("Model")}
+
{t("Description")}
{visibleRows.map((row) => (
{row.displayName ?? row.model}
+
+ {row.providerName ? `${row.providerName}/${row.model}` : row.model} +
+ {row.providerName ? ( + + {row.providerName} + + ) : null} +
+
+ {row.providerIndex !== undefined ? ( +
+
+
+ {row.description || "-"} +
+
+ +
+ ) : ( +
+ {row.description || "-"} +
+ )}
))} @@ -322,10 +398,74 @@ export function ModelsView({ config }: { config: AppConfig }) { ) : null} + ); } +function ModelCatalogDescriptionDialog({ + draft, + onChange, + onClose, + onSave, + target +}: { + draft: string; + onChange: (value: string) => void; + onClose: () => void; + onSave: () => void; + target?: { + description: string; + displayName?: string; + model: string; + providerName?: string; + }; +}) { + const t = useAppText(); + const open = Boolean(target); + const title = target?.displayName || target?.model || t("Model"); + const subtitle = target?.providerName ? `${target.providerName}/${target.model}` : target?.model; + + return ( + { if (!nextOpen) onClose(); }}> + + + {t("Edit description")} + + +
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ + + + +
+ + + + diff --git a/extension/chrome/popup.js b/extension/chrome/popup.js new file mode 100644 index 0000000..4b12be1 --- /dev/null +++ b/extension/chrome/popup.js @@ -0,0 +1,297 @@ +const importUrlInput = document.getElementById("import-url"); +const importButton = document.getElementById("import-button"); +const statusElement = document.getElementById("status"); + +document.addEventListener("DOMContentLoaded", () => { + void restoreLastImportUrl(); +}); + +importButton.addEventListener("click", () => { + void runImport(); +}); + +async function restoreLastImportUrl() { + const stored = await chrome.storage.local.get(["lastImportUrl"]); + if (typeof stored.lastImportUrl === "string") { + importUrlInput.value = stored.lastImportUrl; + } +} + +async function runImport() { + const importUrl = normalizeImportUrl(importUrlInput.value); + if (!importUrl) { + setStatus("Paste the import URL copied from CCR.", "error"); + return; + } + + importButton.disabled = true; + try { + await chrome.storage.local.set({ lastImportUrl: importUrl }); + setStatus("Reading CCR import job..."); + const job = await fetchImportJob(importUrl); + const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : []; + if (domains.length === 0) { + throw new Error("CCR import job does not include any domains."); + } + + await ensureHostPermissions(domains); + + setStatus(`Reading cookies for ${domains.join(", ")}...`); + const cookies = await readCookiesForDomains(domains); + setStatus(`Reading localStorage for ${domains.join(", ")}...`); + const localStorageEntries = await readLocalStorageForDomains(domains, cookies); + + if (cookies.length === 0 && localStorageEntries.length === 0) { + throw new Error("No cookies or localStorage entries were found for the selected domains."); + } + + setStatus(`Sending ${cookies.length} cookies and ${localStorageItemCount(localStorageEntries)} localStorage items to CCR...`); + const result = await submitCookies(importUrl, cookies, localStorageEntries, domains); + setStatus( + `Imported ${result.cookieImported ?? 0} cookies and ${result.localStorageImported ?? 0} localStorage items. Skipped ${result.skipped ?? 0}.`, + "ok" + ); + } catch (error) { + setStatus(formatError(error), "error"); + } finally { + importButton.disabled = false; + } +} + +async function fetchImportJob(importUrl) { + const response = await fetch(importUrl, { + headers: { + "x-ccr-login-import": "chrome-extension" + } + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`); + } + if (!body.job || body.job.status !== "pending") { + throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`); + } + return body.job; +} + +async function submitCookies(importUrl, cookies, localStorage, domains) { + const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, { + body: JSON.stringify({ + cookies, + domains, + localStorage, + source: { + browser: "chrome", + extension: chrome.runtime.getManifest().version + } + }), + headers: { + "content-type": "application/json", + "x-ccr-login-import": "chrome-extension" + }, + method: "POST" + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR cookie import failed (${response.status}).`); + } + return body.result || {}; +} + +async function readCookiesForDomains(domains) { + const cookies = []; + for (const domain of domains) { + cookies.push(...await chrome.cookies.getAll({ domain })); + } + return dedupeCookies(cookies); +} + +async function readLocalStorageForDomains(domains, cookies) { + const origins = localStorageOriginsForDomains(domains, cookies); + const entries = []; + for (const origin of origins) { + const entry = await readLocalStorageForOrigin(origin).catch(() => undefined); + if (entry && Object.keys(entry.items).length > 0) { + entries.push(entry); + } + } + return entries; +} + +async function readLocalStorageForOrigin(origin) { + const tab = await chrome.tabs.create({ + active: false, + url: `${origin}/` + }); + try { + await waitForTabLoad(tab.id, 15000); + const [frameResult] = await chrome.scripting.executeScript({ + func: () => { + const items = {}; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key !== null) { + items[key] = window.localStorage.getItem(key) || ""; + } + } + return { + items, + origin: window.location.origin + }; + }, + target: { tabId: tab.id }, + world: "MAIN" + }); + const result = frameResult?.result; + if (!result || !allowedLocalStorageOrigin(result.origin, origin)) { + return undefined; + } + return result; + } finally { + if (tab.id !== undefined) { + await chrome.tabs.remove(tab.id).catch(() => undefined); + } + } +} + +function waitForTabLoad(tabId, timeoutMs) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) { + return; + } + done = true; + chrome.tabs.onUpdated.removeListener(listener); + window.clearTimeout(timeout); + resolve(); + }; + const listener = (updatedTabId, changeInfo) => { + if (updatedTabId === tabId && changeInfo.status === "complete") { + finish(); + } + }; + const timeout = window.setTimeout(finish, timeoutMs); + chrome.tabs.onUpdated.addListener(listener); + }); +} + +function dedupeCookies(cookies) { + const seen = new Set(); + const result = []; + for (const cookie of cookies) { + const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : ""; + const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`; + if (!seen.has(key)) { + seen.add(key); + result.push(cookie); + } + } + return result; +} + +function localStorageOriginsForDomains(domains, cookies) { + const origins = new Set(); + for (const domain of domains) { + if (domain === "localhost" || isIpAddress(domain)) { + origins.add(`http://${domain}`); + origins.add(`https://${domain}`); + } else { + origins.add(`https://${domain}`); + } + } + + for (const cookie of cookies) { + const host = normalizeDomain(cookie.domain); + if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) { + continue; + } + origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`); + } + + return [...origins]; +} + +function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) { + try { + const actual = new URL(actualOrigin); + const requested = new URL(requestedOrigin); + return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port; + } catch { + return false; + } +} + +function localStorageItemCount(entries) { + return entries.reduce((count, entry) => count + Object.keys(entry.items || {}).length, 0); +} + +function originsForDomains(domains) { + return [...new Set(domains.flatMap((domain) => { + if (domain === "localhost" || isIpAddress(domain)) { + return [ + `http://${domain}/*`, + `https://${domain}/*` + ]; + } + return [ + `http://${domain}/*`, + `https://${domain}/*`, + `http://*.${domain}/*`, + `https://*.${domain}/*` + ]; + }))]; +} + +async function ensureHostPermissions(domains) { + const origins = originsForDomains(domains); + const granted = await chrome.permissions.contains({ origins }); + if (granted) { + return; + } + throw new Error( + [ + `CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`, + "Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings." + ].join(" ") + ); +} + +function normalizeImportUrl(value) { + const raw = value.trim(); + if (!raw) { + return ""; + } + try { + const url = new URL(raw); + if (!["127.0.0.1", "localhost"].includes(url.hostname)) { + return ""; + } + if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) { + return ""; + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString(); + } catch { + return ""; + } +} + +function normalizeDomain(value) { + return typeof value === "string" + ? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase() + : ""; +} + +function isIpAddress(value) { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":"); +} + +function setStatus(message, kind = "") { + statusElement.textContent = message; + statusElement.className = `status ${kind}`.trim(); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5e4e4bc..0cf78ab 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -700,6 +700,10 @@ function parseToolHub(value: unknown): Partial | undefined { if (typeof value.enabled === "boolean") { toolHub.enabled = value.enabled; } + const browserAutomation = value.browserAutomation ?? value.browser_automation; + if (typeof browserAutomation === "boolean") { + toolHub.browserAutomation = browserAutomation; + } const maxTools = readNumber(value.maxTools ?? value.max_tools); if (maxTools !== undefined) { toolHub.maxTools = clampNumber(maxTools, 1, 20); diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 63933cb..58a3868 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -170,6 +170,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon trayWidgets: DEFAULT_TRAY_WIDGETS, trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES, toolHub: { + browserAutomation: false, enabled: false, llm: { apiKey: "", diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 4c00d21..685a35e 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -642,6 +642,7 @@ export type ToolHubLlmConfig = { }; export type ToolHubConfig = { + browserAutomation: boolean; enabled: boolean; llm: ToolHubLlmConfig; mcpServers: GatewayMcpServerConfig[]; @@ -1468,12 +1469,91 @@ export type BuiltInBrowserTabState = { url: string; }; +export type BuiltInBrowserAutomationHandoffKind = + | "blocked" + | "human_verification" + | "login_required" + | "other" + | "verification_code"; + +export type BuiltInBrowserAutomationHandoff = { + id: string; + kind: BuiltInBrowserAutomationHandoffKind; + message: string; + reason?: string; + requestedAt: number; + sessionId?: string; + status: "pending"; + tabId?: string; +}; + export type BuiltInBrowserState = { activeTabId?: string; apps: InstalledBrowserApp[]; + automationHandoff?: BuiltInBrowserAutomationHandoff; tabs: BuiltInBrowserTabState[]; }; +export type ChromeLoginImportTarget = "browser" | "browser-and-web-search"; + +export type ChromeLoginImportStatus = + | "completed" + | "expired" + | "failed" + | "pending"; + +export type ChromeLoginImportRequest = { + domains: string[]; + openConfirmationPage?: boolean; + target?: ChromeLoginImportTarget; +}; + +export type ChromeLoginImportResult = { + completedAt: number; + cookieImported: number; + cookieSkipped: number; + domains: string[]; + errors?: string[]; + imported: number; + localStorageImported: number; + localStorageSkipped: number; + partitions: string[]; + skipped: number; +}; + +export type ChromeLoginImportJob = { + confirmUrl: string; + createdAt: number; + domains: string[]; + endpointUrl: string; + expiresAt: number; + id: string; + importUrl: string; + result?: ChromeLoginImportResult; + status: ChromeLoginImportStatus; + target: ChromeLoginImportTarget; +}; + +export type ChromeLoginImportCookie = { + domain: string; + expirationDate?: number; + hostOnly?: boolean; + httpOnly?: boolean; + name: string; + partitionKey?: unknown; + path?: string; + sameSite?: "lax" | "no_restriction" | "strict" | "unspecified"; + secure?: boolean; + session?: boolean; + storeId?: string; + value: string; +}; + +export type ChromeLoginImportLocalStorage = { + items: Record; + origin: string; +}; + export type ProxyCertificateInstallResult = { caCertFile: string; manualCommand?: string; diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 103b0b0..f24d040 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -73,10 +73,13 @@ export const IPC_CHANNELS = { browserBack: "ccr:browser:back", browserCloseTab: "ccr:browser:close-tab", browserForward: "ccr:browser:forward", + browserGetChromeLoginImport: "ccr:browser:get-chrome-login-import", browserGetState: "ccr:browser:get-state", browserNavigate: "ccr:browser:navigate", browserNewTab: "ccr:browser:new-tab", browserReload: "ccr:browser:reload", + browserResolveAutomationHandoff: "ccr:browser:resolve-automation-handoff", browserSelectTab: "ccr:browser:select-tab", + browserStartChromeLoginImport: "ccr:browser:start-chrome-login-import", browserStateChanged: "ccr:browser:state-changed" } as const; diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index b42ea83..c39b3f9 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -365,9 +365,11 @@ function injectClaudeCodeToolHubInstructions(body: Record, conf const invokeName = toolNames.invoke ?? "tool_hub.invoke"; appendSystemInstruction(body, [ ccrToolHubSystemInstructionMarker, - `You MUST call ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`, - `Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip ${toolNames.resolve} when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`, - `If ${toolNames.resolve} returns selected tools, use ${invokeName} to call the selected tool instead of telling the user that no such capability is available.`, + `The ToolHub search/resolution tool is ${toolNames.resolve}; call this actual tool, do not merely mention its name in text.`, + `You MUST call the ToolHub search/resolution tool ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`, + `Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip the ToolHub search/resolution tool when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`, + `If ${toolNames.resolve} returns selected tools, call the ToolHub invocation tool ${invokeName} to run the selected tool instead of telling the user that no such capability is available.`, + "When the ToolHub resolve result includes executionPlanJs or workflowSketch, treat that JavaScript as the invocation dependency plan: await means serial order, and only callTool calls grouped inside the same Promise.all may be issued in parallel.", "Use the user's request as the task and include concise context when resolving. Do not ask the user to name the MCP tool unless the task is genuinely ambiguous after resolution." ].join("\n")); } @@ -380,16 +382,40 @@ function claudeCodeToolHubToolNames(tools: unknown[]): { invoke?: string; resolv } const name = claudeCodeToolName(tool); const normalized = normalizeClaudeCodeToolName(name); - if (!names.resolve && normalized.endsWith("toolhubresolve")) { + if (normalized.endsWith("toolhubresolve") && shouldUseClaudeCodeToolHubName(names.resolve, name)) { names.resolve = name; } - if (!names.invoke && normalized.endsWith("toolhubinvoke")) { + if (normalized.endsWith("toolhubinvoke") && shouldUseClaudeCodeToolHubName(names.invoke, name)) { names.invoke = name; } } return names; } +function shouldUseClaudeCodeToolHubName(current: string | undefined, candidate: string | undefined): boolean { + if (!candidate) { + return false; + } + if (!current) { + return true; + } + return claudeCodeToolHubNameScore(candidate) > claudeCodeToolHubNameScore(current); +} + +function claudeCodeToolHubNameScore(name: string): number { + const normalized = name.toLowerCase(); + if (normalized.startsWith("mcp__ccr-toolhub__") || normalized.startsWith("mcp__ccr_toolhub__")) { + return 3; + } + if (normalized.startsWith("mcp__") && normalized.includes("toolhub")) { + return 2; + } + if (normalized.startsWith("mcp__")) { + return 1; + } + return 0; +} + function appendSystemInstruction(body: Record, instruction: string): void { if (systemContainsInstruction(body.system, ccrToolHubSystemInstructionMarker)) { return; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index a10ff09..ef274df 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -45,7 +45,7 @@ import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/service"; import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp"; -import { TOOL_HUB_MCP_SERVER_NAME, toolHubMcpRuntimeConfig } from "@ccr/core/mcp/toolhub-config"; +import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config"; import { pluginService } from "@ccr/core/plugins/service"; import { proxyService } from "@ccr/core/proxy/service"; import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; @@ -130,6 +130,7 @@ export type BrowserWebSearchMcpRegistration = { export type BrowserWebSearchProtocolResult = { content?: string; + diagnostics?: string[]; snippet?: string; title: string; url: string; @@ -151,6 +152,11 @@ export type BrowserWebSearchMcpIntegration = { stopBrowserWebSearchMcpServers: () => Promise; }; +export type BrowserAutomationMcpIntegration = { + handleBrowserAutomationMcpRequest: (request: IncomingMessage, response: ServerResponse) => Promise; + stopBrowserAutomationMcpServer: () => Promise; +}; + type CoreGatewayHealth = { runtimeId?: string; status?: string; @@ -312,6 +318,7 @@ const persistedApiKeyCacheTtlMs = 1000; let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; class GatewayService { + private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration; private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; private child?: ChildProcess; private config?: AppConfig; @@ -332,6 +339,10 @@ class GatewayService { this.browserWebSearchMcpIntegration = integration; } + setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void { + this.browserAutomationMcpIntegration = integration; + } + async start(config: AppConfig): Promise { const coreHostError = loopbackCoreHostError(config.gateway.coreHost); if (coreHostError) { @@ -439,6 +450,9 @@ class GatewayService { await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => { console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`); }); + await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => { + console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`); + }); this.status = { ...this.status, @@ -551,6 +565,31 @@ class GatewayService { return; } + if (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`) { + if (!browserAutomationMcpEnabled(this.config)) { + sendJson(response, 404, { + error: { + message: "CCR browser automation MCP is disabled." + } + }); + return; + } + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + if (!this.browserAutomationMcpIntegration) { + sendJson(response, 503, { + error: { + message: "CCR browser automation MCP is only available in the Electron desktop app." + } + }); + return; + } + await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response); + return; + } + if (isNetworkCaptureMcpPath(path)) { if (!this.config.proxy.captureNetwork) { sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } }); @@ -819,6 +858,15 @@ class GatewayService { sinceMs: startedAt - 1_000 }); + if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) { + const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } + if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { const records = await selectHostedWebSearchProtocolRecords( hostedWebSearchProtocolContext, @@ -1546,16 +1594,20 @@ function bundledFusionToolFallbackMcpEntryPath(): string { function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): GatewayMcpServerConfig | undefined { const toolHub = config.toolHub; - const runtimeConfig = toolHubMcpRuntimeConfig(config, backendServers); + const runtimeBackendServers = [ + ...toolHubBuiltInBackendServers(config), + ...backendServers + ]; + const runtimeConfig = toolHubMcpRuntimeConfig(config, runtimeBackendServers); if (!toolHub?.enabled || !runtimeConfig) { return undefined; } return { ...runtimeConfig, - name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, backendServers), + name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, runtimeBackendServers), protocolVersion: "2024-11-05", - requestTimeoutMs: Math.max(toolHub.requestTimeoutMs ?? 60000, 60000), + requestTimeoutMs: toolHubRequestTimeoutMs(config, runtimeBackendServers), startupTimeoutMs: 600000, stdioMessageMode: "content-length", transport: "stdio" @@ -1565,53 +1617,121 @@ function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): Gateway export function fusionFallbackToolDefinitions( profiles: unknown[], backedToolNames: Set = new Set() -): Array<{ description?: string; inputSchema?: Record; name: string }> { - const byName = new Map; name: string }>(); +): FusionFallbackToolDefinition[] { + const byName = new Map(); for (const profile of profiles) { - if (!isRecord(profile) || profile.enabled === false || !Array.isArray(profile.tools)) { + if (!isRecord(profile) || profile.enabled === false) { continue; } - for (const tool of profile.tools) { - if (!isRecord(tool)) { - continue; - } - const name = stringValue(tool.name); - if (!name) { - continue; - } - if (backedToolNames.has(name)) { - continue; - } - const existing = byName.get(name); - const description = stringValue(tool.description); - const inputSchema = isRecord(tool.inputSchema) - ? tool.inputSchema - : isRecord(tool.input_schema) - ? tool.input_schema - : undefined; - if (existing) { - if (!existing.description && description) { - existing.description = description; + if (Array.isArray(profile.tools)) { + for (const tool of profile.tools) { + if (!isRecord(tool)) { + continue; } - if (!existing.inputSchema && inputSchema) { - existing.inputSchema = inputSchema; + const name = stringValue(tool.name); + if (!name) { + continue; + } + if (backedToolNames.has(name)) { + continue; } - continue; - } - byName.set(name, { - ...(description ? { description } : {}), - ...(inputSchema ? { inputSchema } : {}), - name - }); + const existing = byName.get(name); + const description = stringValue(tool.description); + const inputSchema = isRecord(tool.inputSchema) + ? tool.inputSchema + : isRecord(tool.input_schema) + ? tool.input_schema + : undefined; + const unavailableMessage = fusionFallbackToolUnavailableMessage(profile, name); + if (existing) { + if (!existing.description && description) { + existing.description = description; + } + if (!existing.inputSchema && inputSchema) { + existing.inputSchema = inputSchema; + } + if (!existing.unavailableMessage && unavailableMessage) { + existing.unavailableMessage = unavailableMessage; + } + continue; + } + + byName.set(name, { + ...(description ? { description } : {}), + ...(inputSchema ? { inputSchema } : {}), + ...(unavailableMessage ? { unavailableMessage } : {}), + name + }); + } + } + + const browserFallback = browserWebSearchFallbackToolDefinition(profile, backedToolNames); + if (browserFallback && !byName.has(browserFallback.name)) { + byName.set(browserFallback.name, browserFallback); } } return [...byName.values()]; } +type FusionFallbackToolDefinition = { + description?: string; + inputSchema?: Record; + name: string; + unavailableMessage?: string; +}; + +function fusionFallbackToolUnavailableMessage(profile: unknown, toolName: string): string | undefined { + if (!isRecord(profile)) { + return undefined; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || webSearchConfig.toolName !== toolName) { + return undefined; + } + return browserWebSearchUnavailableMessage(toolName); +} + +function browserWebSearchUnavailableMessage(toolName: string): string { + return [ + `Fusion MCP tool "${toolName}" is unavailable because In-app Browser web search requires CCR Desktop.`, + "This runtime did not register the Electron browser web search integration, so the hidden browser search tool cannot run here.", + "Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa." + ].join(" "); +} + +function browserWebSearchFallbackToolDefinition( + profile: Record, + backedToolNames: Set +): FusionFallbackToolDefinition | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || !webSearchConfig.toolName || backedToolNames.has(webSearchConfig.toolName)) { + return undefined; + } + return { + description: "Fallback registration for CCR In-app Browser web search when the Electron browser integration is unavailable.", + inputSchema: { + additionalProperties: true, + properties: { + count: { maximum: 20, minimum: 1, type: "number" }, + prompt: { type: "string" }, + query: { type: "string" } + }, + required: ["prompt"], + type: "object" + }, + name: webSearchConfig.toolName, + unavailableMessage: fusionFallbackToolUnavailableMessage(profile, webSearchConfig.toolName) + }; +} + export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set { const names = new Set(); for (const server of servers) { @@ -3037,7 +3157,8 @@ function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], const content = focusedWebSearchContent(result.content, queryHint); const details = [ result.snippet ? `Search snippet: ${result.snippet}` : "", - content ? `Extracted page content: ${content}` : "" + content ? `Extracted page content: ${content}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" ].filter(Boolean).join("\n"); return [ `${resultIndex + 1}. ${result.title}`, @@ -5201,7 +5322,8 @@ function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined { const parts = [ result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "", - result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "" + result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" ].filter(Boolean); return parts.length > 0 ? parts.join("\n") : undefined; } diff --git a/packages/core/src/mcp/fusion-tool-fallback-mcp.ts b/packages/core/src/mcp/fusion-tool-fallback-mcp.ts index a67635d..87c9b62 100644 --- a/packages/core/src/mcp/fusion-tool-fallback-mcp.ts +++ b/packages/core/src/mcp/fusion-tool-fallback-mcp.ts @@ -28,6 +28,7 @@ type McpTool = { description: string; inputSchema: JsonValue; name: string; + unavailableMessage?: string; }; type ToolCallResult = { @@ -134,10 +135,11 @@ async function handleJsonRpcRequest(payload: unknown): Promise item.name === toolLabel); const knownSuffix = toolNames.has(toolLabel) ? "" : " The requested tool was not in the fallback catalog."; return { content: [{ - text: + text: tool?.unavailableMessage || `Fusion MCP tool "${toolLabel}" is temporarily unavailable. ` + "CCR registered a fallback definition because the real MCP server did not provide the tool during discovery. " + `Check the Fusion MCP server logs and retry.${knownSuffix}`, @@ -179,7 +181,8 @@ function readFallbackTools(): McpTool[] { readString(item.description) || `Fallback registration for Fusion MCP tool "${name}". The real MCP server should handle successful calls.`, inputSchema: isRecord(item.inputSchema) ? item.inputSchema as JsonValue : objectSchema({}), - name + name, + unavailableMessage: readString(item.unavailableMessage) }); } return result; diff --git a/packages/core/src/mcp/toolhub-config.ts b/packages/core/src/mcp/toolhub-config.ts index be91a80..5c4cbd1 100644 --- a/packages/core/src/mcp/toolhub-config.ts +++ b/packages/core/src/mcp/toolhub-config.ts @@ -1,9 +1,13 @@ import { join as pathJoin } from "node:path"; import { CONFIGDIR } from "@ccr/core/config/constants"; -import type { AppConfig } from "@ccr/core/contracts/app"; +import type { AppConfig, GatewayMcpServerConfig } from "@ccr/core/contracts/app"; export const TOOL_HUB_MCP_SERVER_NAME = "ccr-toolhub"; export const TOOL_HUB_MCP_RUNTIME_FILE_NAME = "toolhub-mcp.js"; +export const BROWSER_AUTOMATION_MCP_SERVER_NAME = "ccr-browser-automation"; +export const BROWSER_AUTOMATION_MCP_PATH = "/__ccr/browser-automation/mcp"; +export const BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS = 600000; +export const TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS = 60000; export type ToolHubMcpRuntimeConfig = { args: string[]; @@ -26,17 +30,53 @@ export type ToolHubClaudeCodeMcpConfig = { mcpServers: Record; }; -export function toolHubBackendServers(config: AppConfig | undefined, extraServers: unknown[] = []): unknown[] { +export function toolHubBackendServers( + config: AppConfig | undefined, + extraServers: unknown[] = [], + options: { + apiKey?: string; + includeBuiltIns?: boolean; + } = {} +): unknown[] { return [ + ...(options.includeBuiltIns === false ? [] : toolHubBuiltInBackendServers(config, options)), ...(Array.isArray(config?.agent?.mcpServers) ? config.agent.mcpServers : []), ...(Array.isArray(config?.toolHub?.mcpServers) ? config.toolHub.mcpServers : []), ...extraServers ].filter(isToolHubBackendServer); } +export function toolHubBuiltInBackendServers( + config: AppConfig | undefined, + options: { + apiKey?: string; + } = {} +): GatewayMcpServerConfig[] { + if (!config || !browserAutomationMcpEnabled(config) || !hasGatewayEndpoint(config)) { + return []; + } + + return [ + { + apiKey: options.apiKey || firstConfiguredApiKey(config), + headers: {}, + name: BROWSER_AUTOMATION_MCP_SERVER_NAME, + protocolVersion: "2024-11-05", + requestTimeoutMs: BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS, + startupTimeoutMs: 60000, + transport: "streamable-http", + url: `${gatewayEndpoint(config)}${BROWSER_AUTOMATION_MCP_PATH}` + } + ]; +} + +export function browserAutomationMcpEnabled(config: AppConfig | undefined): boolean { + return Boolean(config?.toolHub?.enabled && config.toolHub.browserAutomation); +} + export function toolHubMcpRuntimeConfig( config: AppConfig | undefined, - backendServers = toolHubBackendServers(config), + backendServers?: unknown[], options: { command?: string; entryPath?: string; @@ -52,10 +92,14 @@ export function toolHubMcpRuntimeConfig( return undefined; } - const normalizedBackendServers = backendServers.filter(isToolHubBackendServer); + const resolvedBackendServers = backendServers ?? toolHubBackendServers(config, [], { + apiKey: options.resolver?.apiKey + }); + const normalizedBackendServers = resolvedBackendServers.filter(isToolHubBackendServer); if (normalizedBackendServers.length === 0) { return undefined; } + const requestTimeoutMs = toolHubRequestTimeoutMs(config, normalizedBackendServers); return { args: [options.entryPath ?? bundledToolHubMcpEntryPath()], @@ -68,11 +112,18 @@ export function toolHubMcpRuntimeConfig( TOOLHUB_OPENAI_API_KEY: options.resolver?.apiKey ?? toolHub.llm?.apiKey ?? "", TOOLHUB_OPENAI_BASE_URL: options.resolver?.baseUrl ?? toolHub.llm?.baseUrl ?? "https://api.openai.com/v1", TOOLHUB_OPENAI_MODEL: options.resolver?.model ?? toolHub.llm?.model ?? "", - TOOLHUB_REQUEST_TIMEOUT_MS: String(toolHub.requestTimeoutMs ?? 60000) + TOOLHUB_REQUEST_TIMEOUT_MS: String(requestTimeoutMs) } }; } +export function toolHubRequestTimeoutMs(config: AppConfig | undefined, backendServers?: unknown[]): number { + const configuredTimeout = positiveInteger(config?.toolHub?.requestTimeoutMs, TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS); + const backendTimeouts = (backendServers ?? toolHubBackendServers(config)) + .map((server) => isRecord(server) ? positiveInteger(server.requestTimeoutMs, 0) : 0); + return Math.max(configuredTimeout, ...backendTimeouts); +} + export function toolHubClaudeCodeMcpConfig( config: AppConfig | undefined, options: Parameters[2] = {} @@ -82,7 +133,9 @@ export function toolHubClaudeCodeMcpConfig( return undefined; } - const backendServers = toolHubBackendServers(config); + const backendServers = toolHubBackendServers(config, [], { + apiKey: options.resolver?.apiKey + }); if (backendServers.length === 0) { return undefined; } @@ -126,6 +179,39 @@ function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function firstConfiguredApiKey(config: AppConfig): string | undefined { + return (Array.isArray(config.APIKEYS) ? config.APIKEYS : []) + .find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY); +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback; +} + +function gatewayEndpoint(config: AppConfig): string { + return `http://${formatHost(clientGatewayHost(config.gateway.host))}:${config.gateway.port}`; +} + +function hasGatewayEndpoint(config: AppConfig): boolean { + const gateway = (config as Partial).gateway; + return Boolean(gateway && stringValue(gateway.host) && Number.isFinite(gateway.port)); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function clientGatewayHost(host: string): string { + const value = stringValue(host) ?? "127.0.0.1"; + if (value === "0.0.0.0") { + return "127.0.0.1"; + } + if (value === "::" || value === "[::]") { + return "::1"; + } + return value; +} + function uniqueStrings(values: string[]): string[] { const seen = new Set(); const result: string[] = []; diff --git a/packages/core/src/mcp/toolhub-mcp.ts b/packages/core/src/mcp/toolhub-mcp.ts index 1a5e9c7..9d440d9 100644 --- a/packages/core/src/mcp/toolhub-mcp.ts +++ b/packages/core/src/mcp/toolhub-mcp.ts @@ -149,6 +149,8 @@ type LlmToolResolution = { type ResolveOutput = { alreadyResolved?: boolean; + executionPlanInstructions?: string; + executionPlanJs?: string; nextAction?: { confirmationRequiredFor: string[]; firstAction: { @@ -186,6 +188,17 @@ const defaultRequestTimeoutMs = 60_000; const defaultMaxTools = 10; const discoveryCacheMaxAgeMs = 10_000; const repeatedResolveWindowMs = 5 * 60_000; +const executionPlanInstructions = [ + "Treat executionPlanJs as the dependency plan for ToolHub invocations.", + "Each callTool(toolName, args) call maps to one tool_hub.invoke call with tool=toolName and args=args.", + "await means the next ToolHub invocation depends on the previous result and must not be started early.", + "Only callTool calls inside the same Promise.all([...]) expression may be issued as parallel tool calls.", + "If a later call needs values from an earlier result, wait for that earlier result and fill the arguments after it returns." +].join(" "); +const reservedJavaScriptWords = new Set( + "await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield" + .split(" ") +); type RuntimeLike = { callTool(params: unknown): Promise; @@ -365,15 +378,25 @@ class ToolHubRuntime { await this.registry.refreshServers(undefined, true); catalog = await this.registry.listTools(); } + if (taskWantsChromeLoginImport(task) && !catalogHasChromeLoginImportTool(catalog)) { + await this.registry.refreshServers(["ccr-browser-automation"], true); + catalog = await this.registry.listTools(); + } if (catalog.length === 0) { throw new Error("No MCP tools are available to resolve."); } const cached = this.findRecentlyResolvedTask(scopeKey, taskHash); if (cached) { - const selectedTools = cached.toolNames - .map((toolName) => this.resolveCatalogEntry(toolName, catalog)) - .filter((entry): entry is CatalogEntry => Boolean(entry)); + const selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...cached.toolNames + .map((toolName) => this.resolveCatalogEntry(toolName, catalog)) + .filter((entry): entry is CatalogEntry => Boolean(entry)), + ...getDeterministicTaskTools(task, catalog) + ]), + catalog + ); if (selectedTools.length > 0) { this.markToolsLoaded(scopeKey, selectedTools); return toolResult(this.buildRepeatedResolveOutput(selectedTools)); @@ -383,9 +406,15 @@ class ToolHubRuntime { const inFlightResolve = session.inFlightResolves.get(taskHash); if (inFlightResolve) { const output = await inFlightResolve; - const selectedTools = output.selectedTools - .map((tool) => this.resolveCatalogEntry(tool.toolName, catalog) ?? tool) - .filter((entry): entry is CatalogEntry => Boolean(entry)); + const selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...output.selectedTools + .map((tool) => this.resolveCatalogEntry(tool.toolName, catalog) ?? tool) + .filter((entry): entry is CatalogEntry => Boolean(entry)), + ...getDeterministicTaskTools(task, catalog) + ]), + catalog + ); if (selectedTools.length > 0) { this.markToolsLoaded(scopeKey, selectedTools); return toolResult(this.buildRepeatedResolveOutput(selectedTools)); @@ -461,7 +490,13 @@ class ToolHubRuntime { usedLlm = false; } - let selectedTools = resolution.selectedTools; + let selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...resolution.selectedTools, + ...getDeterministicTaskTools(input.task, input.catalog) + ]), + input.catalog + ); if (input.withoutSideEffects) { selectedTools = selectedTools.filter((tool) => !tool.invocation.sideEffect); } @@ -471,13 +506,14 @@ class ToolHubRuntime { this.markToolsLoaded(input.scopeKey, selectedTools); this.rememberResolvedTask(input.scopeKey, input.taskHash, selectedTools); + const executionPlanJs = buildExecutionPlanJs(resolution.workflowSketch, selectedTools); return { - ...this.buildResolveOutput(resolution.summary, selectedTools), + ...this.buildResolveOutput(resolution.summary, selectedTools, executionPlanJs), plannedSteps: resolution.plannedSteps, referencedTokens: resolution.referencedTokens, retriever, usedLlm, - workflowSketch: resolution.workflowSketch + workflowSketch: executionPlanJs }; } @@ -587,39 +623,50 @@ class ToolHubRuntime { return catalog.find((entry) => entry.toolName === resolvedName); } - private buildResolveOutput(summary: string, selectedTools: CatalogEntry[]): ResolveOutput { + private buildResolveOutput(summary: string, selectedTools: CatalogEntry[], executionPlanJs = buildSequentialExecutionPlanJs(selectedTools)): ResolveOutput { return { + executionPlanInstructions, + executionPlanJs, nextAction: this.buildResolveNextAction(selectedTools), reasoningSummary: summary, runtimeContext: { - availableContextKeys: ["selectedTools"], - summary: selectedTools.map((tool) => `${tool.toolName}: ${tool.description || tool.title}`) + availableContextKeys: ["selectedTools", "executionPlanJs", "executionPlanInstructions"], + summary: [ + ...selectedTools.map((tool) => `${tool.toolName}: ${tool.description || tool.title}`), + "Follow executionPlanJs for dependency ordering before invoking selected tools." + ] }, selectedToolNames: selectedTools.map((tool) => tool.toolName), selectedTools, - tsDefinitions: buildTsDefinitions(selectedTools) + tsDefinitions: buildTsDefinitions(selectedTools), + workflowSketch: executionPlanJs }; } private buildRepeatedResolveOutput(selectedTools: CatalogEntry[]): ResolveOutput { const nextAction = this.buildResolveNextAction(selectedTools); + const executionPlanJs = buildSequentialExecutionPlanJs(selectedTools); return { alreadyResolved: true, + executionPlanInstructions, + executionPlanJs, nextAction, reasoningSummary: [ "This task has already been resolved in the current ToolHub session.", nextAction.instruction ].join(" "), runtimeContext: { - availableContextKeys: ["selectedTools", "nextAction"], + availableContextKeys: ["selectedTools", "nextAction", "executionPlanJs", "executionPlanInstructions"], summary: [ "The selected tools are already loaded for tool_hub.invoke.", "Do not repeat discovery for this task.", - nextAction.instruction + nextAction.instruction, + "Follow executionPlanJs for dependency ordering before invoking selected tools." ] }, selectedToolNames: selectedTools.map((tool) => tool.toolName), - selectedTools + selectedTools, + workflowSketch: executionPlanJs }; } @@ -646,7 +693,10 @@ class ToolHubRuntime { toolName: firstTool?.toolName, type: "invoke_tool" as const }; - const instructionParts = ["Do not call tool_hub.resolve again for this task."]; + const instructionParts = [ + "Do not call tool_hub.resolve again for this task.", + "Follow executionPlanJs: await is serial dependency; only Promise.all groups may run in parallel." + ]; if (firstTool && firstRequiredArguments.length > 0) { instructionParts.push(`Ask the user for missing required arguments for ${firstTool.toolName}: ${firstRequiredArguments.join(", ")}.`); } else if (firstTool) { @@ -790,7 +840,7 @@ class ToolHubRegistry { private async ensureDiscoveryFresh(maxAgeMs = discoveryCacheMaxAgeMs): Promise { const staleServerNames = [...this.entries.values()] - .filter((entry) => !entry.loadedFromCache && (!entry.lastCheckedAt || Date.now() - entry.lastCheckedAt > maxAgeMs)) + .filter((entry) => !entry.lastCheckedAt || Date.now() - entry.lastCheckedAt > maxAgeMs) .map((entry) => entry.config.name); if (staleServerNames.length === 0) { return; @@ -1752,6 +1802,7 @@ function metaTools(): Array<{ description: string; inputSchema: Record\", {...}) or tools.call(\"\", {...}).", "3. Call the tree-sitter tool on that sketch.", "4. Inspect the tree-sitter result and check whether the bundle is complete end-to-end.", @@ -2000,10 +2051,18 @@ function buildSearchSystemPrompt(catalog: SearchCatalogItem[], topK: number): st "{ \"summary\": string, \"steps\": string[], \"workflowSketch\": string, \"toolNames\": string[] }", "Rules:", "- toolNames must be exact catalog names or aliases.", + "- workflowSketch is the dependency plan that will be returned to the caller as executionPlanJs.", + "- In workflowSketch, use await to show serial dependencies and Promise.all([...]) only for tool calls that are independent and safe to invoke in parallel.", + "- Do not put side-effecting calls in Promise.all unless they are truly independent and safe to run concurrently.", "- In workflowSketch, prefer string-literal tool calls over reconstructed member chains whenever possible.", "- Include prerequisite and downstream tools you will likely need after the first action.", "- Never invent tool names.", "- Generic browser automation tools are a strong match for web tasks such as ordering, buying, booking, delivery, or checkout when no domain-specific MCP tool exists.", + "- For browser navigation/open calls, prefer omitting waitUntil or using waitUntil: \"interactive\" so the agent can inspect and act as soon as the page is usable.", + "- Do not use waitUntil: \"network_idle\" for Gmail, Google sign-in, SPAs, mail, chat, auth, checkout, verification, or pages with long-lived requests. Use network_idle only when the user explicitly asks for network quiescence.", + "- When selecting CCR browser automation tools, include the human-handoff follow-up tools needed if login, CAPTCHA, verification, blocked navigation, or manual confirmation appears.", + "- For CCR browser automation bundles, include browser_handoff_request and browser_handoff_wait when the workflow may need user help. Do not assume all browser tools are preloaded.", + "- For importing existing Chrome login state into CCR's in-app browser, select browser_chrome_login_import and include browser_chrome_login_import_status to check completion.", "- If using member-call syntax, prefer tools.(...) or mcp..(...).", "- For lookup tasks, do not stop at opening or navigating. Include the tools needed to read or extract the answer.", "- If the catalog has no strong match, return an empty toolNames array.", @@ -2312,6 +2371,21 @@ function getLocalFallbackPreferredTools(taskText: string): Map { add("browser_screenshot", 60); add("browser_events_await", 44); } + if (hasAnyToken(tokens, [ + "auth", + "chrome", + "cookie", + "cookies", + "import", + "localstorage", + "login", + "session", + "signin", + "storage" + ]) || /chrome|cookie|cookies|localstorage|local storage|登录态|登录状态|导入登录|浏览器登录|本地存储/.test(normalizedText)) { + add("browser_chrome_login_import", 128); + add("browser_chrome_login_import_status", 92); + } if (hasAnyToken(tokens, ["address", "delivery", "geo", "geolocation", "location", "nearby", "pickup", "store"]) || /地址|定位|位置|附近|配送|外卖|自取|门店/.test(normalizedText)) { add("location_permission_status", 90); @@ -2324,6 +2398,28 @@ function getLocalFallbackPreferredTools(taskText: string): Map { return scores; } +function getDeterministicTaskTools(taskText: string, catalog: CatalogEntry[]): CatalogEntry[] { + if (!taskWantsChromeLoginImport(taskText)) { + return []; + } + return [ + catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import"), + catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import_status") + ].filter((tool): tool is CatalogEntry => Boolean(tool)); +} + +function taskWantsChromeLoginImport(taskText: string): boolean { + const normalizedText = taskText.toLowerCase(); + return normalizedText.includes("browser_chrome_login_import") || + /(chrome|谷歌浏览器|浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储|cookie)/.test(normalizedText) || + /(import|导入|迁移|同步).*(chrome|谷歌浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储)/.test(normalizedText) || + /(登录态|登录状态|登录信息).*(chrome|谷歌浏览器|浏览器).*(导入|迁移|同步)/.test(normalizedText); +} + +function catalogHasChromeLoginImportTool(catalog: CatalogEntry[]): boolean { + return catalog.some((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import"); +} + function tokenizeLocalSearchText(text: string): string[] { const tokens = text .replace(/([a-z0-9])([A-Z])/g, "$1 $2") @@ -2350,10 +2446,113 @@ function uniqueToolEntries(entries: CatalogEntry[]): CatalogEntry[] { return output; } +function expandToolBundleWithCompanionTools(selectedTools: CatalogEntry[], catalog: CatalogEntry[]): CatalogEntry[] { + const output = uniqueToolEntries(selectedTools); + const selectedNames = new Set(output.map((tool) => tool.toolName)); + const selectedRemoteNames = new Set(output.map((tool) => tool.remoteToolName)); + const hasBrowserAutomationTool = output.some((tool) => isBrowserAutomationTool(tool)); + const hasHandoffRequestTool = output.some((tool) => + isBrowserAutomationTool(tool) && + (tool.remoteToolName === "browser_handoff_request" || tool.remoteToolName === "askHumanHelp") + ); + const hasChromeLoginImportTool = output.some((tool) => + isBrowserAutomationTool(tool) && + tool.remoteToolName === "browser_chrome_login_import" + ); + const companionRemoteNames = new Set(); + + if (hasBrowserAutomationTool) { + companionRemoteNames.add("browser_handoff_request"); + companionRemoteNames.add("browser_handoff_status"); + companionRemoteNames.add("browser_handoff_wait"); + } + if (hasHandoffRequestTool) { + companionRemoteNames.add("browser_handoff_wait"); + } + if (hasChromeLoginImportTool) { + companionRemoteNames.add("browser_chrome_login_import_status"); + } + + for (const remoteToolName of companionRemoteNames) { + if (selectedRemoteNames.has(remoteToolName)) { + continue; + } + const companion = catalog.find((tool) => + isBrowserAutomationTool(tool) && + tool.remoteToolName === remoteToolName && + !selectedNames.has(tool.toolName) + ); + if (companion) { + output.push(companion); + selectedNames.add(companion.toolName); + selectedRemoteNames.add(companion.remoteToolName); + } + } + + return output; +} + +function isBrowserAutomationTool(tool: CatalogEntry): boolean { + return tool.serverName === "ccr-browser-automation" || + tool.serverId === "ccr-browser-automation" || + tool.serverNamespace === "ccr_browser_automation" || + tool.toolName.startsWith("mcp.ccr_browser_automation."); +} + function buildLocalFallbackWorkflowSketch(selectedTools: CatalogEntry[]): string | undefined { - return selectedTools.length > 0 - ? selectedTools.slice(0, 5).map((tool) => `await callTool(${JSON.stringify(tool.toolName)}, {});`).join("\n") - : undefined; + return selectedTools.length > 0 ? buildSequentialExecutionPlanJs(selectedTools) : undefined; +} + +function isBrowserNavigationTool(tool: CatalogEntry): boolean { + return isBrowserAutomationTool(tool) && ( + tool.toolName.endsWith("browser_session_open") || + tool.toolName.endsWith("browser_navigate") || + tool.remoteToolName === "browser_session_open" || + tool.remoteToolName === "browser_navigate" + ); +} + +function buildExecutionPlanJs(workflowSketch: string | undefined, selectedTools: CatalogEntry[]): string { + const trimmed = typeof workflowSketch === "string" ? workflowSketch.trim() : ""; + return trimmed || buildSequentialExecutionPlanJs(selectedTools); +} + +function buildSequentialExecutionPlanJs(selectedTools: CatalogEntry[]): string { + if (selectedTools.length === 0) { + return [ + "async function runWithToolHub() {", + " // Ask the user for missing task details before invoking tools.", + "}" + ].join("\n"); + } + const lines = [ + "async function runWithToolHub() {", + " // Invoke calls in this order unless the plan explicitly uses Promise.all." + ]; + selectedTools.forEach((tool, index) => { + lines.push(` const step${index + 1} = await callTool(${JSON.stringify(tool.toolName)}, ${buildExecutionPlanArgs(tool)});`); + }); + lines.push("}"); + return lines.join("\n"); +} + +function buildExecutionPlanArgs(tool: CatalogEntry): string { + if (isBrowserNavigationTool(tool)) { + return "{ url, waitUntil: \"interactive\" }"; + } + const required = getSchemaRequiredProperties(tool.inputSchema); + if (required.length === 0) { + return "{}"; + } + return `{ ${required.map((key) => `${JSON.stringify(key)}: ${toPlanVariableName(key)}`).join(", ")} }`; +} + +function toPlanVariableName(value: string): string { + const identifier = toIdentifier(value).replace(/^[A-Z]/, (match) => match.toLowerCase()); + if (!identifier || reservedJavaScriptWords.has(identifier)) { + return "value"; + } + return identifier; } function inferInvocation(tool: ToolDefinition): ToolInvocation { diff --git a/packages/electron/src/main/browser-automation-mcp.ts b/packages/electron/src/main/browser-automation-mcp.ts new file mode 100644 index 0000000..884905f --- /dev/null +++ b/packages/electron/src/main/browser-automation-mcp.ts @@ -0,0 +1,3517 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { Event as ElectronEvent, WebContents } from "electron"; +import { loadAppConfig } from "@ccr/core/config/config"; +import type { + BuiltInBrowserAutomationHandoff, + BuiltInBrowserAutomationHandoffKind, + BuiltInBrowserState, + BuiltInBrowserTabState +} from "@ccr/core/contracts/app"; +import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/service"; +import { BROWSER_AUTOMATION_MCP_PATH } from "@ccr/core/mcp/toolhub-config"; +import { builtInBrowserService, type BrowserAutomationEvent } from "./built-in-browser"; +import { chromeLoginImportService } from "./chrome-login-import"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type McpTool = { + description: string; + inputSchema: JsonValue; + name: string; + tags?: string[]; + title?: string; +}; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +type BrowserTarget = { + axNodeId?: string; + backendNodeId?: number; + exact?: boolean; + index?: number; + ref?: string; + role?: string; + selector?: string; + text?: string; +}; + +type BrowserSessionRef = { + frameId?: string; + sessionId: string; + tabId: string; + userId?: string; +}; + +type AttachedSession = { + attachedAt: number; + leaseId: string; + observeOnly: boolean; + ref: BrowserSessionRef; +}; + +type EventSubscription = { + channels: Set; + dropped: boolean; + events: BrowserAutomationEvent[]; + ref?: BrowserSessionRef; + redactTextInput: boolean; + sampleMouseMove: boolean; + startedAt: number; + subscriptionId: string; + unsubscribe: () => void; +}; + +type AxSnapshotResult = { + handoff?: BuiltInBrowserAutomationHandoff; + handoffRequired?: boolean; + humanHelp?: Record; + humanHelpRequired?: boolean; + nextAction?: "human_help"; + nodes: Array>; + scope: string; + session: BrowserSessionRef; + title: string; + url: string; +}; + +type BrowserHandoffDetection = { + kind: BuiltInBrowserAutomationHandoffKind; + message: string; + reason: string; +}; + +type ReadinessResult = { + matched: boolean; + matchedEvent?: string; + navigationError?: { + errorCode?: number; + errorDescription?: string; + url?: string; + }; + readinessUrl?: string; + timedOut: boolean; +}; + +type ReadinessEvent = { + errorCode?: number; + errorDescription?: string; + isMainFrame?: boolean; + type: string; + url?: string; +}; + +type NavigationReadinessContext = { + expectedUrl?: string; + previousUrl?: string; +}; + +const protocolVersion = "2024-11-05"; +const automationEventReplayMs = 15_000; +const defaultAxSnapshotLimit = 60; +const defaultSnapshotMaxElements = 80; +const defaultSnapshotMaxText = 3_000; +const defaultEventAwaitTimeoutMs = 10_000; +const defaultJavascriptTimeoutMs = 8_000; +const maxMcpRequestBytes = 2 * 1024 * 1024; +const maxSubscriptionEvents = 512; +const maxToolResultChars = 60_000; +const maxToolResultArrayItems = 120; +const maxToolResultStringChars = 2_000; +const maxToolResultObjectKeys = 120; +const maxSnapshotResultElements = 80; +const maxAxSnapshotResultNodes = 80; +const maxBrowserResultTextChars = 3_000; +const defaultWaitTimeoutMs = 10_000; + +const sessionSchema = objectSchema({ + frameId: { description: "Optional frame id. CCR currently targets the main frame.", type: "string" }, + sessionId: { description: "Browser automation session id.", type: "string" }, + tabId: { description: "Built-in browser tab id.", type: "string" }, + userId: { description: "Optional logical user id.", type: "string" } +}, ["sessionId", "tabId"]); + +const cursorSchema = objectSchema({ + dropped: { description: "Whether earlier events were dropped before this cursor.", type: "boolean" }, + seq: { description: "Last observed event sequence.", type: "number" }, + subscriptionId: { description: "Event subscription id.", type: "string" }, + ts: { description: "Last observed event timestamp.", type: "number" } +}, ["subscriptionId", "seq"]); + +const targetSchema = objectSchema({ + axNodeId: { description: "Accessibility node id returned by browser_ax_snapshot/query. In CCR this is a stable element ref.", type: "string" }, + backendNodeId: { description: "Reserved for CDP-compatible clients. CSS/ref targeting is preferred in CCR.", type: "number" }, + exact: { description: "Match text/name exactly instead of by substring.", type: "boolean" }, + index: { description: "Zero-based match index when text/role resolves multiple elements.", minimum: 0, type: "number" }, + ref: { description: "Element ref returned by browser_snapshot. Refs are CSS selectors.", type: "string" }, + role: { description: "Accessible or implicit role such as button, link, textbox, combobox, checkbox.", type: "string" }, + selector: { description: "CSS selector. Used directly when provided.", type: "string" }, + text: { description: "Visible text, accessible name, placeholder, label, or value to match.", type: "string" } +}); + +const browserAutomationTools: McpTool[] = [ + ...browserPublicAutomationTools(), + ...browserLegacyAliasTools() +].filter((tool, index, tools) => tools.findIndex((candidate) => candidate.name === tool.name) === index); + +function browserPublicAutomationTools(): McpTool[] { + return [ + { + description: "Open a URL or attach an existing CCR built-in browser tab and create an automation session.", + inputSchema: objectSchema({ + observeOnly: { description: "If true, action tools reject this session.", type: "boolean" }, + tabId: { description: "Existing tab id to attach.", type: "string" }, + timeoutMs: { description: "Optional navigation timeout in milliseconds.", type: "number" }, + url: { description: "Optional URL or search query to open.", type: "string" }, + userId: { description: "Optional logical user id.", type: "string" }, + waitUntil: { description: "Readiness condition. Default/recommended: interactive, which waits until the page is inspectable/actionable and avoids long-lived network requests. network_idle is rarely appropriate for SPAs, Google, mail, chat, auth, or streaming pages.", enum: ["none", "interactive", "domcontentloaded", "load", "network_idle"], type: "string" }, + windowId: { description: "Ignored in CCR; included for agentic-browser compatibility.", type: "string" } + }), + name: "browser_session_open", + tags: ["browser", "automation", "session", "open", "tab"], + title: "Open Browser Automation Session" + }, + { + description: "Detach and release a browser automation session. Does not close the tab.", + inputSchema: objectSchema({ session: sessionSchema }, ["session"]), + name: "browser_session_close", + tags: ["browser", "automation", "session", "close"], + title: "Close Browser Automation Session" + }, + { + description: "Create a new CCR built-in browser tab. Accepts an existing session or the fixed CCR browser window id.", + inputSchema: objectSchema({ + activate: { description: "Whether the new tab should become active. Defaults to true.", type: "boolean" }, + session: sessionSchema, + url: { description: "Optional URL or search query.", type: "string" }, + windowId: { description: "Ignored in CCR; included for compatibility.", type: "string" } + }), + name: "browser_tab_create", + tags: ["browser", "automation", "tab", "create"], + title: "Create Browser Tab" + }, + { + description: "List all CCR built-in browser tabs with active, loading, URL, title, and navigation state.", + inputSchema: objectSchema({ + session: sessionSchema, + windowId: { description: "Ignored in CCR; included for compatibility.", type: "string" } + }), + name: "browser_tab_list", + tags: ["browser", "automation", "tab", "list"], + title: "List Browser Tabs" + }, + { + description: "Activate an existing CCR built-in browser tab.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Tab id to activate. Defaults to session.tabId.", type: "string" } + }), + name: "browser_tab_activate", + tags: ["browser", "automation", "tab", "activate", "focus"], + title: "Activate Browser Tab" + }, + { + description: "Close a CCR built-in browser tab.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Tab id to close. Defaults to session.tabId.", type: "string" } + }), + name: "browser_tab_close", + tags: ["browser", "automation", "tab", "close"], + title: "Close Browser Tab" + }, + { + description: "Navigate the session tab or a specified tab to a URL or search query.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId or active tab.", type: "string" }, + timeoutMs: { description: "Optional navigation timeout in milliseconds.", maximum: 120000, minimum: 100, type: "number" }, + url: { description: "URL or search query to load.", type: "string" }, + waitUntil: { description: "Readiness condition. Default/recommended: interactive, which waits until the page is inspectable/actionable and avoids long-lived network requests. network_idle is rarely appropriate for SPAs, Google, mail, chat, auth, or streaming pages.", enum: ["none", "interactive", "domcontentloaded", "load", "network_idle"], type: "string" } + }, ["url"]), + name: "browser_navigate", + tags: ["browser", "automation", "navigate", "tab", "url"], + title: "Navigate Browser Tab" + }, + { + description: "Go back in the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_go_back", + tags: ["browser", "automation", "tab", "back"], + title: "Browser Tab Back" + }, + { + description: "Go forward in the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_go_forward", + tags: ["browser", "automation", "tab", "forward"], + title: "Browser Tab Forward" + }, + { + description: "Reload the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_reload", + tags: ["browser", "automation", "tab", "reload"], + title: "Reload Browser Tab" + }, + { + description: "Read a condensed accessibility-oriented page snapshot. Nodes include axNodeId/ref, role, name, value, text, and rect.", + inputSchema: objectSchema({ + includeIgnored: { description: "Included for compatibility; CCR returns visible/high-signal nodes.", type: "boolean" }, + limit: { description: "Maximum nodes to return.", maximum: 300, minimum: 1, type: "number" }, + maxDepth: { description: "Included for compatibility.", type: "number" }, + rootAxNodeId: { description: "Optional root node/ref to scope the snapshot.", type: "string" }, + scope: { description: "full or outline. CCR returns the same compact shape for both.", enum: ["full", "outline"], type: "string" }, + session: sessionSchema + }, ["session"]), + name: "browser_ax_snapshot", + tags: ["browser", "automation", "accessibility", "snapshot", "dom", "page"], + title: "Browser Accessibility Snapshot" + }, + { + description: "Search the page accessibility outline by role, accessible name, visible text, label, placeholder, or value.", + inputSchema: objectSchema({ + includeIgnored: { description: "Included for compatibility; CCR searches visible/high-signal nodes.", type: "boolean" }, + limit: { description: "Maximum matches.", maximum: 300, minimum: 1, type: "number" }, + name: { description: "Accessible name filter.", type: "string" }, + role: { description: "Role filter such as button, link, textbox, combobox.", type: "string" }, + rootAxNodeId: { description: "Optional root node/ref to scope the query.", type: "string" }, + session: sessionSchema, + text: { description: "Visible text/value/description filter.", type: "string" } + }, ["session"]), + name: "browser_ax_query", + tags: ["browser", "automation", "accessibility", "query", "find"], + title: "Query Browser Accessibility Tree" + }, + { + description: "Click an element resolved by axNodeId/ref, CSS selector, role, or text.", + inputSchema: objectSchema({ force: { type: "boolean" }, session: sessionSchema, target: targetSchema }, ["session", "target"]), + name: "browser_element_click", + tags: ["browser", "automation", "element", "click"], + title: "Click Browser Element" + }, + { + description: "Set text into an input, textarea, contenteditable, or textbox-like element.", + inputSchema: objectSchema({ + replace: { description: "Replace existing text. Defaults to true.", type: "boolean" }, + session: sessionSchema, + target: targetSchema, + text: { type: "string" } + }, ["session", "target", "text"]), + name: "browser_element_input", + tags: ["browser", "automation", "element", "input", "type", "form"], + title: "Input Browser Element" + }, + { + description: "Select an option on a native select by value or visible label.", + inputSchema: objectSchema({ + exact: { type: "boolean" }, + session: sessionSchema, + target: targetSchema, + value: { description: "Requested option value or visible label.", type: "string" } + }, ["session", "target", "value"]), + name: "browser_element_select", + tags: ["browser", "automation", "element", "select", "form"], + title: "Select Browser Element Option" + }, + { + description: "Press a keyboard key against a resolved target or the current focused element.", + inputSchema: objectSchema({ + key: { description: "Keyboard key, e.g. Enter, Tab, Escape, ArrowDown.", type: "string" }, + session: sessionSchema, + target: targetSchema + }, ["session", "key"]), + name: "browser_element_press", + tags: ["browser", "automation", "element", "press", "keyboard"], + title: "Press Browser Element Key" + }, + { + description: "Scroll the page or a target element.", + inputSchema: objectSchema({ + amount: { description: "Scroll amount in CSS pixels.", type: "number" }, + direction: { description: "Scroll direction.", enum: ["up", "down"], type: "string" }, + session: sessionSchema, + target: targetSchema + }, ["session"]), + name: "browser_element_scroll", + tags: ["browser", "automation", "element", "scroll"], + title: "Scroll Browser Element" + }, + { + description: "Subscribe to browser automation events such as tab, navigation, DOM-ready, runtime, and loading signals.", + inputSchema: objectSchema({ + channels: { description: "Event channels: tab, navigation, dom, runtime, dialog, download, input, focus, selection, handoff.", items: { type: "string" }, type: "array" }, + redactTextInput: { type: "boolean" }, + sampleMouseMove: { type: "boolean" }, + session: sessionSchema + }, ["session", "channels"]), + name: "browser_events_subscribe", + tags: ["browser", "automation", "events", "subscribe"], + title: "Subscribe Browser Events" + }, + { + description: "Read buffered browser automation events from a subscription.", + inputSchema: objectSchema({ + cursor: cursorSchema, + limit: { maximum: 200, minimum: 1, type: "number" }, + subscriptionId: { type: "string" } + }, ["subscriptionId"]), + name: "browser_events_read", + tags: ["browser", "automation", "events", "read"], + title: "Read Browser Events" + }, + { + description: "Wait for browser automation events matching kind, tabId, URL pattern, title pattern, or summary pattern.", + inputSchema: objectSchema({ + coalesceMs: { description: "Small delay after a first match to collect related events.", type: "number" }, + cursor: cursorSchema, + kinds: { items: { type: "string" }, type: "array" }, + maxEvents: { maximum: 50, minimum: 1, type: "number" }, + summaryPattern: { type: "string" }, + tabId: { type: "string" }, + timeoutMs: { maximum: 120000, minimum: 100, type: "number" }, + titlePattern: { type: "string" }, + urlPattern: { type: "string" }, + subscriptionId: { type: "string" } + }, ["subscriptionId"]), + name: "browser_events_await", + tags: ["browser", "automation", "events", "await", "wait"], + title: "Await Browser Events" + }, + { + description: "Unsubscribe from browser automation events and release the subscription.", + inputSchema: objectSchema({ subscriptionId: { type: "string" } }, ["subscriptionId"]), + name: "browser_events_unsubscribe", + tags: ["browser", "automation", "events", "unsubscribe"], + title: "Unsubscribe Browser Events" + }, + { + description: "Handle a currently open JavaScript alert/confirm/prompt dialog using Chromium CDP.", + inputSchema: objectSchema({ + accept: { type: "boolean" }, + promptText: { type: "string" }, + session: sessionSchema + }, ["session", "accept"]), + name: "browser_dialog_handle", + tags: ["browser", "automation", "dialog", "alert", "confirm", "prompt"], + title: "Handle Browser Dialog" + }, + { + description: "Request human intervention for the current browser task. Shows the hidden browser window and displays the requested action in the top toolbar.", + inputSchema: objectSchema({ + kind: { + description: "Type of help needed.", + enum: ["login_required", "verification_code", "human_verification", "blocked", "other"], + type: "string" + }, + message: { description: "Short instruction shown in the top toolbar.", type: "string" }, + reason: { description: "Why automation is blocked.", type: "string" }, + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId or active tab.", type: "string" } + }, ["reason"]), + name: "browser_handoff_request", + tags: ["browser", "automation", "human", "handoff", "intervention"], + title: "Request Browser Human Handoff" + }, + { + description: "Read the current browser human handoff status.", + inputSchema: objectSchema({}), + name: "browser_handoff_status", + tags: ["browser", "automation", "human", "handoff"], + title: "Browser Handoff Status" + }, + { + description: "Wait until the user clicks Done or Hide on the current browser handoff toolbar. Use after browser_handoff_request or a tool result with humanHelpRequired.", + inputSchema: objectSchema({ + handoffId: { description: "Optional handoff id returned by browser_handoff_request or humanHelp.handoff.", type: "string" }, + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId.", type: "string" }, + timeoutMs: { description: "Maximum wait time in milliseconds.", maximum: 600000, minimum: 100, type: "number" } + }), + name: "browser_handoff_wait", + tags: ["browser", "automation", "human", "handoff", "wait"], + title: "Wait For Browser Human Handoff" + }, + { + description: "Clear the current browser human handoff prompt.", + inputSchema: objectSchema({ + status: { enum: ["completed", "dismissed"], type: "string" } + }), + name: "browser_handoff_clear", + tags: ["browser", "automation", "human", "handoff"], + title: "Clear Browser Handoff" + }, + { + description: "Ask the user to confirm importing Chrome cookies and localStorage for selected domains into CCR's in-app browser. Opens a browser confirmation page; the Chrome extension performs the import after user confirmation.", + inputSchema: objectSchema({ + domain: { description: "Single domain to import, such as github.com. Used with domains if both are provided.", type: "string" }, + domains: { description: "Domains to import. If omitted, CCR derives the current tab hostname.", items: { type: "string" }, type: "array" }, + openConfirmationPage: { description: "Open the system browser confirmation page. Defaults to true.", type: "boolean" }, + session: sessionSchema, + tabId: { description: "Optional tab id used to derive a domain when domain/domains are omitted.", type: "string" }, + target: { description: "Target CCR browser storage partition.", enum: ["browser", "browser-and-web-search"], type: "string" } + }), + name: "browser_chrome_login_import", + tags: ["browser", "automation", "chrome", "login", "import", "cookies", "localStorage"], + title: "Import Chrome Login State" + }, + { + description: "Read the status of a Chrome login import job created by browser_chrome_login_import.", + inputSchema: objectSchema({ + jobId: { description: "Chrome login import job id.", type: "string" } + }, ["jobId"]), + name: "browser_chrome_login_import_status", + tags: ["browser", "automation", "chrome", "login", "import", "status"], + title: "Chrome Login Import Status" + } + ]; +} + +function browserLegacyAliasTools(): McpTool[] { + return [ + { + description: "Open or focus the CCR built-in browser window. Optionally navigate the active tab to a URL.", + inputSchema: objectSchema({ + url: { description: "Optional URL or search query to load in the active tab.", type: "string" } + }), + name: "browser_open", + tags: ["browser", "automation", "open", "focus"], + title: "Open Browser" + }, + { + description: "List CCR built-in browser tabs, including active tab, URL, title, and loading state.", + inputSchema: objectSchema({}), + name: "browser_tabs", + tags: ["browser", "automation", "tabs"], + title: "List Browser Tabs" + }, + { + description: "Open a new CCR built-in browser tab. Optionally load a URL or search query.", + inputSchema: objectSchema({ + url: { description: "Optional URL or search query to load in the new tab.", type: "string" } + }), + name: "browser_tab_new", + tags: ["browser", "automation", "tab"], + title: "New Browser Tab" + }, + { + description: "Focus a CCR built-in browser tab by tabId.", + inputSchema: objectSchema({ + tabId: { description: "Tab id returned by browser_tabs or browser_tab_new.", type: "string" } + }, ["tabId"]), + name: "browser_tab_activate", + tags: ["browser", "automation", "tab", "focus"], + title: "Activate Browser Tab" + }, + { + description: "Close a CCR built-in browser tab by tabId.", + inputSchema: objectSchema({ + tabId: { description: "Tab id returned by browser_tabs or browser_tab_new.", type: "string" } + }, ["tabId"]), + name: "browser_tab_close", + tags: ["browser", "automation", "tab"], + title: "Close Browser Tab" + }, + { + description: "Navigate a CCR built-in browser tab to a URL or search query.", + inputSchema: objectSchema({ + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + url: { description: "URL or search query to load.", type: "string" } + }, ["url"]), + name: "browser_navigate", + tags: ["browser", "automation", "navigate", "url"], + title: "Navigate Browser" + }, + { + description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.", + inputSchema: objectSchema({ + maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" }, + maxText: { description: "Maximum page text characters to include.", maximum: 20000, minimum: 0, type: "number" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" } + }), + name: "browser_snapshot", + tags: ["browser", "automation", "snapshot", "accessibility", "dom", "page"], + title: "Browser Page Snapshot" + }, + { + description: "Click an element in the CCR built-in browser by ref, CSS selector, role, or visible text.", + inputSchema: objectSchema({ + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }, ["target"]), + name: "browser_click", + tags: ["browser", "automation", "click", "element"], + title: "Click Browser Element" + }, + { + description: "Type text into an input, textarea, select-like textbox, or contenteditable element in the CCR built-in browser.", + inputSchema: objectSchema({ + replaceExisting: { description: "Replace existing text. Defaults to true.", type: "boolean" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema, + text: { description: "Text to type.", type: "string" } + }, ["target", "text"]), + name: "browser_type", + tags: ["browser", "automation", "type", "input", "form"], + title: "Type In Browser Element" + }, + { + description: "Choose an option in a select element by value or visible label.", + inputSchema: objectSchema({ + exact: { description: "Match label exactly instead of by substring.", type: "boolean" }, + label: { description: "Visible option label to choose.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema, + value: { description: "Option value to choose.", type: "string" } + }, ["target"]), + name: "browser_select", + tags: ["browser", "automation", "select", "form"], + title: "Select Browser Option" + }, + { + description: "Press a keyboard key in the CCR built-in browser. Optionally focus an element first.", + inputSchema: objectSchema({ + key: { description: "Electron keyCode, for example Enter, Tab, Escape, Backspace, ArrowDown.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }, ["key"]), + name: "browser_press_key", + tags: ["browser", "automation", "keyboard", "press"], + title: "Press Browser Key" + }, + { + description: "Scroll the page or a target element in the CCR built-in browser.", + inputSchema: objectSchema({ + deltaX: { description: "Horizontal scroll delta in pixels.", type: "number" }, + deltaY: { description: "Vertical scroll delta in pixels.", type: "number" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }), + name: "browser_scroll", + tags: ["browser", "automation", "scroll"], + title: "Scroll Browser" + }, + { + description: "Wait until a browser page condition is true: URL includes/matches, selector is visible, or text is visible.", + inputSchema: objectSchema({ + selector: { description: "CSS selector that must be visible.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + text: { description: "Text that must be visible in document body.", type: "string" }, + timeoutMs: { description: "Maximum wait time in milliseconds.", maximum: 120000, minimum: 100, type: "number" }, + urlIncludes: { description: "Substring that must appear in the URL.", type: "string" }, + urlMatches: { description: "JavaScript regular expression that must match the URL.", type: "string" } + }), + name: "browser_wait_for", + tags: ["browser", "automation", "wait", "url", "selector", "text"], + title: "Wait For Browser Page" + }, + { + description: "Agentic-browser-compatible alias for browser_handoff_request. Request human help for login, verification, CAPTCHA, or other manual-only blockers.", + inputSchema: objectSchema({ + browserSessionId: { type: "string" }, + kind: { enum: ["login_required", "verification_code", "human_verification", "blocked", "other"], type: "string" }, + message: { type: "string" }, + reason: { type: "string" }, + tabId: { type: "string" } + }, ["reason", "kind"]), + name: "askHumanHelp", + tags: ["browser", "automation", "human", "handoff", "intervention"], + title: "Ask Human Help" + } + ]; +} + +class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration { + private readonly sessions = new Map(); + private readonly subscriptions = new Map(); + + async handleBrowserAutomationMcpRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader("MCP-Protocol-Version", protocolVersion); + const path = request.url ? new URL(request.url, "http://127.0.0.1").pathname : "/"; + + if (request.method === "GET" && (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`)) { + sendJson(response, 200, { + endpoint: BROWSER_AUTOMATION_MCP_PATH, + name: "ccr-browser-automation", + protocol: "mcp", + transport: "streamable-http" + }); + return; + } + + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "Browser automation MCP endpoint only supports GET and POST." } }); + return; + } + + let payload: unknown; + try { + payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown; + } catch (error) { + sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + return; + } + + const requests = Array.isArray(payload) ? payload : [payload]; + const responses = await Promise.all(requests.map((item) => this.handleJsonRpcRequest(item))); + const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item)); + if (filtered.length === 0) { + response.writeHead(204); + response.end(); + return; + } + + sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]); + } + + async stopBrowserAutomationMcpServer(): Promise { + for (const subscription of this.subscriptions.values()) { + subscription.unsubscribe(); + } + this.subscriptions.clear(); + this.sessions.clear(); + // The gateway owns this MCP route. Browser tabs remain user-visible state and are + // intentionally not closed when the gateway restarts. + } + + private async handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-browser-automation", + title: "CCR Browser Automation", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: browserAutomationTools as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await this.callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } + } + + private async callTool(params: unknown): Promise { + if (!isRecord(params) || typeof params.name !== "string") { + throw new Error("tools/call params must include a tool name."); + } + const args = isRecord(params.arguments) ? params.arguments : {}; + try { + const result = await this.runTool(params.name, args); + return textResult(formatToolResult(params.name, result)); + } catch (error) { + return { + ...textResult(formatError(error)), + isError: true + }; + } + } + + private async runTool(name: string, args: Record): Promise { + switch (name) { + case "browser_session_open": + return await this.openSession(args); + case "browser_session_close": + return this.closeSession(args); + case "browser_tab_create": + return await this.createTab(args); + case "browser_tab_list": + await this.ensureBrowserOpen(); + return browserWindowState(); + case "browser_tab_activate": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + const tabId = readString(args.tabId) || session?.ref.tabId; + if (!tabId) { + throw new Error("browser_tab_activate requires tabId or session."); + } + const state = builtInBrowserService.selectAutomationTab(tabId); + return { + ...browserWindowState(state), + tab: summarizeTab(requiredTabState(state, tabId), state.activeTabId) + }; + } + case "browser_tab_close": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const tabId = readString(args.tabId) || session?.ref.tabId; + if (!tabId) { + throw new Error("browser_tab_close requires tabId or session."); + } + const state = builtInBrowserService.closeAutomationTab(tabId); + this.removeSessionsForTab(tabId); + return browserWindowState(state); + } + case "browser_navigate": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const tabId = readString(args.tabId) || session?.ref.tabId || builtInBrowserService.getAutomationState().activeTabId; + if (!tabId) { + throw new Error("browser_navigate could not resolve an active tab."); + } + const url = requiredString(args.url, "browser_navigate requires url."); + const waitUntil = normalizeWaitUntil(readString(args.waitUntil), "interactive"); + const readiness = await waitForReadiness( + builtInBrowserService.getAutomationWebContents(tabId), + waitUntil, + clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000), + () => builtInBrowserService.navigateAutomationTab(url, tabId), + url + ); + const state = builtInBrowserService.getAutomationState(); + const webContents = builtInBrowserService.getAutomationWebContents(tabId); + const handoff = await maybeRequestHandoffAfterNavigation(webContents, readiness, { + requestedUrl: url, + session: session?.ref, + tabId, + waitUntil + }); + return { + ...browserWindowState(state), + ...(handoff ? humanHelpResultFields(handoff) : {}), + session: session?.ref, + tab: summarizeTab(requiredTabState(state, tabId), state.activeTabId), + ...readiness + }; + } + case "browser_tab_go_back": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.goBackAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_tab_go_forward": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.goForwardAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_tab_reload": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.reloadAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_ax_snapshot": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + return await captureAxSnapshot( + builtInBrowserService.getAutomationWebContents(session.ref.tabId), + session.ref, + { + limit: clampInteger(readNumber(args.limit) ?? defaultAxSnapshotLimit, 1, 300), + rootAxNodeId: readString(args.rootAxNodeId), + scope: readString(args.scope) === "outline" ? "outline" : "full" + } + ); + } + case "browser_ax_query": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + return await queryAxSnapshot( + builtInBrowserService.getAutomationWebContents(session.ref.tabId), + session.ref, + { + limit: clampInteger(readNumber(args.limit) ?? 50, 1, 300), + name: readString(args.name), + role: readString(args.role), + rootAxNodeId: readString(args.rootAxNodeId), + text: readString(args.text) + } + ); + } + case "browser_element_click": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "click", readTarget(args.target)); + return await pageActionResult(webContents, session.ref, "click", result); + } + case "browser_element_input": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "type", readTarget(args.target), { + replaceExisting: args.replace !== false, + text: typeof args.text === "string" ? args.text : "" + }); + return await pageActionResult(webContents, session.ref, "input", result); + } + case "browser_element_select": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const value = requiredString(args.value, "browser_element_select requires value."); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "select", readTarget(args.target), { + exact: args.exact === true, + label: value, + value + }); + return await pageActionResult(webContents, session.ref, "select", result); + } + case "browser_element_press": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await pressKey( + webContents, + requiredString(args.key, "browser_element_press requires key."), + isRecord(args.target) ? readTarget(args.target) : undefined + ); + return await pageActionResult(webContents, session.ref, "press", result); + } + case "browser_element_scroll": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const amount = Math.abs(readNumber(args.amount) ?? 700); + const direction = readString(args.direction) === "up" ? "up" : "down"; + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction( + webContents, + "scroll", + isRecord(args.target) ? readTarget(args.target) : undefined, + { + deltaX: 0, + deltaY: direction === "up" ? -amount : amount + } + ); + return await pageActionResult(webContents, session.ref, "scroll", result); + } + case "browser_events_subscribe": + await this.ensureBrowserOpen(); + return this.subscribeEvents(args); + case "browser_events_read": + return this.readEvents(args); + case "browser_events_await": + return await this.awaitEvents(args); + case "browser_events_unsubscribe": + return this.unsubscribeEvents(args); + case "browser_dialog_handle": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + return await handleJavaScriptDialog(webContents, args.accept === true, readString(args.promptText), session.ref); + } + case "browser_handoff_request": + case "askHumanHelp": + await this.ensureBrowserOpen(); + return this.requestHandoff(args); + case "browser_handoff_status": + await this.ensureBrowserOpen(); + return { + handoff: builtInBrowserService.getAutomationState().automationHandoff, + state: browserWindowState() + }; + case "browser_handoff_wait": + await this.ensureBrowserOpen(); + return await this.waitForHandoff(args); + case "browser_handoff_clear": + return { + handoff: undefined, + state: builtInBrowserService.resolveAutomationHandoff(readString(args.status) === "dismissed" ? "dismissed" : "completed") + }; + case "browser_chrome_login_import": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + const domains = resolveChromeLoginImportDomains(args, session?.ref.tabId); + const job = await chromeLoginImportService.createJob({ + domains, + openConfirmationPage: args.openConfirmationPage !== false, + target: readString(args.target) === "browser-and-web-search" ? "browser-and-web-search" : "browser" + }); + return { + humanHelpRequired: true, + job, + nextAction: "user_confirm_chrome_import", + state: browserWindowState(), + summary: `Opened Chrome login import confirmation for ${job.domains.join(", ")}.` + }; + } + case "browser_chrome_login_import_status": { + const jobId = requiredString(args.jobId, "browser_chrome_login_import_status requires jobId."); + const job = chromeLoginImportService.getJob(jobId); + if (!job) { + throw new Error(`Chrome login import job was not found: ${jobId}`); + } + return { job }; + } + case "browser_open": { + const state = await this.ensureBrowserVisible(); + const url = readString(args.url); + return url ? await builtInBrowserService.navigateAutomationTab(url, state.activeTabId) : builtInBrowserService.getAutomationState(); + } + case "browser_tabs": + await this.ensureBrowserOpen(); + return builtInBrowserService.getAutomationState(); + case "browser_tab_new": { + await this.ensureBrowserOpen(); + const tab = builtInBrowserService.createAutomationTab(readString(args.url) || undefined); + return { state: builtInBrowserService.getAutomationState(), tab }; + } + case "browser_snapshot": + await this.ensureBrowserOpen(); + return await captureSnapshotWithHandoff( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + readString(args.tabId), + { + maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300), + maxText: clampInteger(readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, 20000) + } + ); + case "browser_click": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "click", + readTarget(args.target) + ); + case "browser_type": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "type", + readTarget(args.target), + { + replaceExisting: args.replaceExisting !== false, + text: typeof args.text === "string" ? args.text : "" + } + ); + case "browser_select": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "select", + readTarget(args.target), + { + exact: args.exact === true, + label: readString(args.label), + value: readString(args.value) + } + ); + case "browser_press_key": + await this.ensureBrowserOpen(); + return await pressKey( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + requiredString(args.key, "browser_press_key requires key."), + isRecord(args.target) ? readTarget(args.target) : undefined + ); + case "browser_scroll": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "scroll", + isRecord(args.target) ? readTarget(args.target) : undefined, + { + deltaX: readNumber(args.deltaX) ?? 0, + deltaY: readNumber(args.deltaY) ?? 700 + } + ); + case "browser_wait_for": + await this.ensureBrowserOpen(); + return await waitForPageCondition( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + { + selector: readString(args.selector), + text: readString(args.text), + timeoutMs: clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000), + urlIncludes: readString(args.urlIncludes), + urlMatches: readString(args.urlMatches) + } + ); + default: + throw new Error(`Unknown browser automation tool: ${name}`); + } + } + + private async ensureBrowserOpen(): Promise { + await builtInBrowserService.openHidden(await loadAppConfig()); + return builtInBrowserService.getAutomationState(); + } + + private async ensureBrowserVisible(): Promise { + await builtInBrowserService.open(await loadAppConfig()); + return builtInBrowserService.getAutomationState(); + } + + private async openSession(args: Record): Promise { + let state = await this.ensureBrowserOpen(); + const url = readString(args.url); + const requestedTabId = readString(args.tabId); + const previousActiveTabId = state.activeTabId; + let tabId = requestedTabId; + let createdTab = false; + + if (tabId && !state.tabs.some((tab) => tab.id === tabId)) { + throw new Error(`Browser tab was not found: ${tabId}`); + } + + if (!tabId && url) { + const tab = builtInBrowserService.createAutomationTab(); + tabId = tab.id; + createdTab = true; + } else if (!tabId) { + tabId = state.activeTabId || builtInBrowserService.createAutomationTab().id; + createdTab = !state.activeTabId; + } else if (state.activeTabId !== tabId) { + builtInBrowserService.selectAutomationTab(tabId); + } + + if (!tabId) { + throw new Error("Unable to resolve browser tab for automation session."); + } + + const waitUntil = normalizeWaitUntil(readString(args.waitUntil), url ? "interactive" : "none"); + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000); + const webContents = builtInBrowserService.getAutomationWebContents(tabId); + const readiness = await waitForReadiness( + webContents, + waitUntil, + timeoutMs, + url ? () => builtInBrowserService.navigateAutomationTab(url, tabId) : undefined, + url + ); + state = builtInBrowserService.getAutomationState(); + const tab = requiredTabState(state, tabId); + const ref: BrowserSessionRef = { + sessionId: randomUUID(), + tabId, + ...(readString(args.userId) ? { userId: readString(args.userId) } : {}) + }; + const attached: AttachedSession = { + attachedAt: Date.now(), + leaseId: randomUUID(), + observeOnly: args.observeOnly === true, + ref + }; + this.sessions.set(ref.sessionId, attached); + const handoff = await maybeRequestHandoffAfterNavigation(webContents, readiness, { + requestedUrl: url, + session: ref, + tabId, + waitUntil + }); + + return { + session: ref, + attachedAt: attached.attachedAt, + createdTab, + ...(handoff ? humanHelpResultFields(handoff) : {}), + matched: readiness.matched, + matchedEvent: readiness.matchedEvent, + ...(readiness.navigationError ? { navigationError: readiness.navigationError } : {}), + previousActiveTabId, + readinessUrl: readiness.readinessUrl, + tabId, + timedOut: readiness.timedOut, + title: tab.title, + url: tab.url, + waitUntil, + windowId: builtInBrowserService.getAutomationWindowId() + }; + } + + private closeSession(args: Record): unknown { + const session = this.requireSession(args); + this.sessions.delete(session.ref.sessionId); + for (const [subscriptionId, subscription] of this.subscriptions) { + if (subscription.ref?.sessionId === session.ref.sessionId) { + subscription.unsubscribe(); + this.subscriptions.delete(subscriptionId); + } + } + return { success: true }; + } + + private async createTab(args: Record): Promise { + const state = await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const previousActiveTabId = state.activeTabId; + const tab = builtInBrowserService.createAutomationTab(readString(args.url) || undefined); + if (args.activate === false && previousActiveTabId) { + builtInBrowserService.selectAutomationTab(previousActiveTabId); + } + const nextState = builtInBrowserService.getAutomationState(); + return summarizeTab(requiredTabState(nextState, tab.id), nextState.activeTabId); + } + + private requireSession(args: Record): AttachedSession { + const session = this.resolveOptionalSession(args); + if (!session) { + throw new Error("A valid browser automation session is required."); + } + return session; + } + + private resolveOptionalSession(args: Record): AttachedSession | undefined { + const ref = readSessionRef(args.session); + if (!ref) { + return undefined; + } + const existing = this.sessions.get(ref.sessionId); + if (existing) { + if (existing.ref.tabId !== ref.tabId) { + throw new Error("Browser automation session tabId does not match the attached session."); + } + return existing; + } + const state = builtInBrowserService.getAutomationState(); + if (!state.tabs.some((tab) => tab.id === ref.tabId)) { + throw new Error(`Browser automation session tab was not found: ${ref.tabId}`); + } + const restored: AttachedSession = { + attachedAt: Date.now(), + leaseId: randomUUID(), + observeOnly: false, + ref + }; + this.sessions.set(ref.sessionId, restored); + return restored; + } + + private assertCanMutate(session?: AttachedSession): void { + if (session?.observeOnly) { + throw new Error("This browser automation session is observeOnly and cannot mutate browser state."); + } + } + + private removeSessionsForTab(tabId: string): void { + for (const [sessionId, session] of this.sessions) { + if (session.ref.tabId === tabId) { + this.sessions.delete(sessionId); + } + } + for (const [subscriptionId, subscription] of this.subscriptions) { + if (subscription.ref?.tabId === tabId) { + subscription.unsubscribe(); + this.subscriptions.delete(subscriptionId); + } + } + } + + private subscribeEvents(args: Record): unknown { + const session = this.requireSession(args); + const channels = normalizeEventChannels(readStringArray(args.channels)); + const subscription: EventSubscription = { + channels, + dropped: false, + events: [], + ref: session.ref, + redactTextInput: args.redactTextInput !== false, + sampleMouseMove: args.sampleMouseMove === true, + startedAt: Date.now(), + subscriptionId: randomUUID(), + unsubscribe: () => undefined + }; + const listener = (event: BrowserAutomationEvent) => { + if (!matchesSubscriptionEvent(subscription, event)) { + return; + } + subscription.events.push(event); + if (subscription.events.length > maxSubscriptionEvents) { + subscription.events.splice(0, subscription.events.length - maxSubscriptionEvents); + subscription.dropped = true; + } + }; + subscription.unsubscribe = builtInBrowserService.subscribeAutomationEvents(listener, { + replayRecentMs: automationEventReplayMs + }); + this.subscriptions.set(subscription.subscriptionId, subscription); + + return { + subscription: subscriptionDescriptor(subscription) + }; + } + + private readEvents(args: Record): unknown { + const subscription = this.requireSubscription(args); + const limit = clampInteger(readNumber(args.limit) ?? 100, 1, 200); + const cursorSeq = readCursorSeq(args.cursor); + const events = eventsAfterCursor(subscription, cursorSeq).slice(0, limit); + return { + dropped: subscription.dropped || cursorDropped(subscription, cursorSeq), + events, + nextCursor: makeEventCursor(subscription, events.at(-1)?.seq ?? cursorSeq), + subscriptionId: subscription.subscriptionId + }; + } + + private async awaitEvents(args: Record): Promise { + const subscription = this.requireSubscription(args); + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? defaultEventAwaitTimeoutMs, 100, 120000); + const maxEvents = clampInteger(readNumber(args.maxEvents) ?? 10, 1, 50); + const coalesceMs = clampInteger(readNumber(args.coalesceMs) ?? 100, 0, 2000); + const cursorSeq = readCursorSeq(args.cursor); + const deadline = Date.now() + timeoutMs; + let matched: BrowserAutomationEvent[] = []; + + while (Date.now() <= deadline) { + matched = eventsAfterCursor(subscription, cursorSeq) + .filter((event) => matchesAwaitFilter(event, args)) + .slice(0, maxEvents); + if (matched.length > 0) { + if (coalesceMs > 0) { + await sleep(coalesceMs); + matched = eventsAfterCursor(subscription, cursorSeq) + .filter((event) => matchesAwaitFilter(event, args)) + .slice(0, maxEvents); + } + break; + } + await sleep(100); + } + + return { + dropped: subscription.dropped || cursorDropped(subscription, cursorSeq), + event: matched[0], + events: matched, + matched: matched.length > 0, + nextCursor: makeEventCursor(subscription, matched.at(-1)?.seq ?? cursorSeq), + subscriptionId: subscription.subscriptionId, + timedOut: matched.length === 0 + }; + } + + private unsubscribeEvents(args: Record): unknown { + const subscription = this.requireSubscription(args); + subscription.unsubscribe(); + this.subscriptions.delete(subscription.subscriptionId); + return { + ok: true, + subscriptionId: subscription.subscriptionId + }; + } + + private requireSubscription(args: Record): EventSubscription { + const subscriptionId = requiredString(args.subscriptionId, "Browser event subscriptionId is required."); + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) { + throw new Error(`Browser event subscription was not found: ${subscriptionId}`); + } + return subscription; + } + + private requestHandoff(args: Record): unknown { + const session = this.resolveOptionalSession(args); + const sessionId = session?.ref.sessionId || readString(args.browserSessionId); + const tabId = readString(args.tabId) || session?.ref.tabId; + const handoff = builtInBrowserService.requestAutomationHandoff({ + kind: readHandoffKind(args.kind), + message: readString(args.message), + reason: requiredString(args.reason, "browser_handoff_request requires reason."), + sessionId, + tabId + }); + return { + ...humanHelpResultFields(handoff), + state: browserWindowState() + }; + } + + private async waitForHandoff(args: Record): Promise { + const session = this.resolveOptionalSession(args); + const handoffId = readString(args.handoffId); + const tabId = readString(args.tabId) || session?.ref.tabId; + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? 300000, 100, 600000); + + const current = builtInBrowserService.getAutomationState().automationHandoff; + if (!handoffMatches(current, { handoffId, tabId })) { + const recentResolution = findRecentHandoffResolution({ handoffId, tabId }); + if (recentResolution) { + return handoffResolutionResult(recentResolution, undefined, false); + } + return { + handoff: current, + matched: false, + reason: current ? "A browser handoff is pending, but it does not match the requested handoffId/tabId." : "No browser handoff is currently pending.", + state: browserWindowState(), + timedOut: false + }; + } + + return await new Promise((resolve) => { + let settled = false; + const finish = (result: Record) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(result); + }; + const unsubscribe = builtInBrowserService.subscribeAutomationEvents((event) => { + if (event.kind !== "handoff.completed" && event.kind !== "handoff.dismissed") { + return; + } + if (handoffId && event.handoffId !== handoffId) { + return; + } + if (tabId && event.tabId !== tabId) { + return; + } + finish(handoffResolutionResult(event, current, false)); + }); + const timer = setTimeout(() => { + finish({ + handoff: current, + matched: false, + state: browserWindowState(), + timedOut: true + }); + }, timeoutMs); + }); + } +} + +export const browserAutomationMcpService = new BrowserAutomationMcpService(); + +function browserWindowState(state = builtInBrowserService.getAutomationState()): Record { + return { + activeTabId: state.activeTabId, + tabs: state.tabs.map((tab) => summarizeTab(tab, state.activeTabId)), + windowId: builtInBrowserService.getAutomationWindowId() + }; +} + +function resolveChromeLoginImportDomains(args: Record, fallbackTabId?: string): string[] { + const explicit = uniqueStrings([ + ...readStringArray(args.domains), + ...(readString(args.domain) ? [readString(args.domain) as string] : []) + ].map(normalizeChromeLoginImportDomain).filter((domain): domain is string => Boolean(domain))); + if (explicit.length > 0) { + return explicit; + } + + const state = builtInBrowserService.getAutomationState(); + const tabId = readString(args.tabId) || fallbackTabId || state.activeTabId; + const tab = state.tabs.find((candidate) => candidate.id === tabId) || state.tabs.find((candidate) => candidate.id === state.activeTabId); + const domain = normalizeChromeLoginImportDomain(tab?.url); + if (!domain) { + throw new Error("browser_chrome_login_import requires domains or an active http(s) tab."); + } + return [domain]; +} + +function normalizeChromeLoginImportDomain(value: unknown): string | undefined { + const raw = readString(value)?.toLowerCase(); + if (!raw) { + return undefined; + } + try { + const url = new URL(raw.includes("://") ? raw : `https://${raw}`); + return url.hostname.replace(/^\*\./, "").replace(/^\./, ""); + } catch { + const domain = raw.replace(/^\*\./, "").replace(/^\./, "").split("/")[0]; + return domain && !domain.includes(" ") ? domain : undefined; + } +} + +function summarizeTab(tab: BuiltInBrowserTabState, activeTabId?: string): Record { + return { + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + displayUrl: tab.url, + isActive: tab.id === activeTabId, + isLoading: tab.isLoading, + tabId: tab.id, + title: tab.title, + url: tab.url, + windowId: builtInBrowserService.getAutomationWindowId() + }; +} + +function requiredTabState(state: BuiltInBrowserState, tabId: string): BuiltInBrowserTabState { + const tab = state.tabs.find((candidate) => candidate.id === tabId); + if (!tab) { + throw new Error(`Browser tab was not found: ${tabId}`); + } + return tab; +} + +function readSessionRef(value: unknown): BrowserSessionRef | undefined { + if (!isRecord(value)) { + return undefined; + } + const sessionId = readString(value.sessionId); + const tabId = readString(value.tabId); + if (!sessionId || !tabId) { + return undefined; + } + return { + sessionId, + tabId, + ...(readString(value.frameId) ? { frameId: readString(value.frameId) } : {}), + ...(readString(value.userId) ? { userId: readString(value.userId) } : {}) + }; +} + +async function pageActionResult( + webContents: WebContents, + session: BrowserSessionRef, + action: string, + result: unknown +): Promise> { + const handoff = await maybeRequestPageHandoff(webContents, { + session, + tabId: session.tabId + }); + return { + action, + ...(handoff ? humanHelpResultFields(handoff) : {}), + result, + session, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +async function captureAxSnapshot( + webContents: WebContents, + session: BrowserSessionRef, + options: { limit: number; rootAxNodeId?: string; scope: string } +): Promise { + const snapshot = await captureSnapshot(webContents, { + maxElements: options.limit, + maxText: options.scope === "outline" ? 0 : defaultSnapshotMaxText + }); + const record = isRecord(snapshot) ? snapshot : {}; + const title = readString(record.title) || await webContents.getTitle(); + const url = readString(record.url) || webContents.getURL(); + const elements = Array.isArray(record.elements) ? record.elements.filter(isRecord) : []; + const elementNodes = elements.map((element, index) => axNodeFromSnapshotElement(element, index)); + const rootNode = { + axNodeId: "document", + childAxNodeIds: elementNodes.map((node) => String(node.axNodeId)), + ignored: false, + name: title || url, + role: "document", + url + }; + let nodes: Array> = [rootNode, ...elementNodes]; + if (options.rootAxNodeId && options.rootAxNodeId !== "document") { + nodes = nodes.filter((node) => node.axNodeId === options.rootAxNodeId || node.ref === options.rootAxNodeId); + } + const result: AxSnapshotResult = { + nodes: nodes.slice(0, options.limit), + scope: options.scope, + session, + title, + url + }; + const handoff = await maybeRequestPageHandoff(webContents, { + session, + snapshot, + tabId: session.tabId + }); + return handoff ? { ...result, ...humanHelpResultFields(handoff) } : result; +} + +async function queryAxSnapshot( + webContents: WebContents, + session: BrowserSessionRef, + options: { limit: number; name?: string; role?: string; rootAxNodeId?: string; text?: string } +): Promise> { + const snapshot = await captureAxSnapshot(webContents, session, { + limit: 300, + rootAxNodeId: options.rootAxNodeId, + scope: "outline" + }); + const role = options.role?.toLowerCase(); + const name = options.name?.toLowerCase(); + const text = options.text?.toLowerCase(); + const matches = snapshot.nodes + .filter((node) => node.role !== "document") + .filter((node) => { + if (role && String(node.role || "").toLowerCase() !== role) { + return false; + } + if (name && !String(node.name || "").toLowerCase().includes(name)) { + return false; + } + if (text) { + const haystack = [ + node.name, + node.value, + node.description, + node.text, + node.placeholder + ].join(" ").toLowerCase(); + if (!haystack.includes(text)) { + return false; + } + } + return true; + }) + .slice(0, options.limit); + return { + ...(snapshot.handoff ? humanHelpResultFields(snapshot.handoff) : {}), + matches, + session, + title: snapshot.title, + url: snapshot.url + }; +} + +function axNodeFromSnapshotElement(element: Record, index: number): Record { + const ref = readString(element.ref) || `element-${index}`; + const text = readString(element.text); + const name = readString(element.name) || text || readString(element.placeholder) || ref; + const description = text && text !== name ? text : undefined; + return { + axNodeId: ref, + backendNodeId: readNumber(element.backendNodeId), + checked: element.checked === true ? true : undefined, + description, + disabled: element.disabled === true ? true : undefined, + href: readString(element.href), + ignored: false, + index, + name, + placeholder: readString(element.placeholder), + rect: isRecord(element.rect) ? element.rect : undefined, + ref, + role: readString(element.role) || readString(element.tag) || "generic", + tag: readString(element.tag), + text: description, + value: readString(element.value) + }; +} + +async function waitForReadiness( + webContents: WebContents, + waitUntil: string, + timeoutMs: number, + requestNavigation?: () => Promise, + expectedUrl?: string +): Promise { + if (waitUntil === "none") { + if (requestNavigation) { + await requestNavigation(); + } + return { matched: true, matchedEvent: "none", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + if (requestNavigation) { + const readiness = waitForNextReadinessEvent(webContents, waitUntil, timeoutMs, { + expectedUrl: normalizeNavigationUrlForMatch(expectedUrl), + previousUrl: safeWebContentsUrl(webContents) + }); + try { + await requestNavigation(); + } catch (error) { + return navigationFailureResult("navigation_request_failed", { + errorDescription: formatError(error), + url: normalizeNavigationUrlForMatch(expectedUrl) || safeWebContentsUrl(webContents) + }); + } + return await readiness; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + if (!webContents.isLoading()) { + const readinessUrl = safeWebContentsUrl(webContents); + if (isChromiumErrorPage(readinessUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: readinessUrl + }); + } + if (waitUntil === "interactive") { + const interactive = await interactiveReadinessResult(webContents, readinessUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl, timedOut: false }; + } + if (waitUntil === "load") { + return { matched: true, matchedEvent: "load", readinessUrl, timedOut: false }; + } + if (waitUntil === "network_idle") { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + } + } + + const event = await waitForReadinessEvent(webContents, Math.max(0, deadline - Date.now())); + if (event.type === "timeout") { + break; + } + if (event.type === "destroyed") { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + if (event.type === "did-fail-load" && event.errorCode !== -3) { + return navigationFailureResult("did-fail-load", event); + } + const eventUrl = event.url || safeWebContentsUrl(webContents); + if (isChromiumErrorPage(eventUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: eventUrl + }); + } + if (waitUntil === "interactive" && isInteractiveReadinessEvent(event)) { + const interactive = await interactiveReadinessResult(webContents, eventUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded" && event.type === "dom-ready") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl: eventUrl, timedOut: false }; + } + if (waitUntil === "load" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "load", readinessUrl: eventUrl, timedOut: false }; + } + if (waitUntil === "network_idle" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + } + } + if (waitUntil === "interactive" || waitUntil === "network_idle") { + const fallback = await interactiveReadinessResult( + webContents, + undefined, + waitUntil === "network_idle" ? "interactive_after_network_idle_timeout" : "interactive" + ); + if (fallback) { + return fallback; + } + } + return { + matched: false, + matchedEvent: webContents.isDestroyed() ? "destroyed" : undefined, + timedOut: !webContents.isDestroyed() + }; +} + +async function waitForNextReadinessEvent( + webContents: WebContents, + waitUntil: string, + timeoutMs: number, + context: NavigationReadinessContext +): Promise { + const deadline = Date.now() + timeoutMs; + let navigationStarted = false; + let lastNavigationUrl: string | undefined; + + while (Date.now() <= deadline) { + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + const event = await waitForReadinessEvent(webContents, Math.max(0, deadline - Date.now())); + if (event.type === "timeout") { + break; + } + if (event.type === "destroyed") { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + const eventUrl = event.url || safeWebContentsUrl(webContents); + if (isNavigationStartEvent(event)) { + if (event.isMainFrame !== false && isRelevantNavigationUrl(eventUrl, context)) { + navigationStarted = true; + lastNavigationUrl = eventUrl; + } + continue; + } + + if (event.type === "did-fail-load") { + if (event.errorCode === -3) { + continue; + } + if (event.isMainFrame !== false && (navigationStarted || isRelevantNavigationUrl(eventUrl, context))) { + return navigationFailureResult("did-fail-load", event); + } + continue; + } + + if (!navigationStarted) { + continue; + } + + if (isChromiumErrorPage(eventUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: eventUrl + }); + } + if (waitUntil === "interactive" && isInteractiveReadinessEvent(event)) { + const interactive = await interactiveReadinessResult(webContents, eventUrl || lastNavigationUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded" && event.type === "dom-ready") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl: eventUrl || lastNavigationUrl, timedOut: false }; + } + if (waitUntil === "load" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "load", readinessUrl: eventUrl || lastNavigationUrl, timedOut: false }; + } + if (waitUntil === "network_idle" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents) || lastNavigationUrl, timedOut: false }; + } + } + } + if (waitUntil === "interactive" || waitUntil === "network_idle") { + const fallback = await interactiveReadinessResult( + webContents, + lastNavigationUrl, + waitUntil === "network_idle" ? "interactive_after_network_idle_timeout" : "interactive" + ); + if (fallback && (navigationStarted || isRelevantNavigationUrl(fallback.readinessUrl, context))) { + return fallback; + } + } + return { + matched: false, + matchedEvent: webContents.isDestroyed() ? "destroyed" : undefined, + timedOut: !webContents.isDestroyed() + }; +} + +function waitForReadinessEvent(webContents: WebContents, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (event: ReadinessEvent) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + webContents.off("did-navigate", onDidNavigate); + webContents.off("did-redirect-navigation", onDidRedirectNavigation); + webContents.off("did-fail-load", onDidFailLoad); + webContents.off("did-finish-load", onDidFinishLoad); + webContents.off("did-start-navigation", onDidStartNavigation); + webContents.off("did-start-loading", onDidStartLoading); + webContents.off("did-stop-loading", onDidStopLoading); + webContents.off("dom-ready", onDomReady); + webContents.off("destroyed", onDestroyed); + resolve(event); + }; + const onDidFailLoad = ( + _event: ElectronEvent, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame?: boolean + ) => finish({ + errorCode, + errorDescription, + isMainFrame, + type: "did-fail-load", + url: validatedUrl || safeWebContentsUrl(webContents) + }); + const onDidFinishLoad = () => finish({ type: "did-finish-load", url: safeWebContentsUrl(webContents) }); + const onDidNavigate = (_event: ElectronEvent, url: string) => finish({ isMainFrame: true, type: "did-navigate", url }); + const onDidRedirectNavigation = (_event: ElectronEvent, url: string, isInPlace?: boolean, isMainFrame?: boolean) => { + finish({ isMainFrame, type: isInPlace ? "did-navigate-in-page" : "did-redirect-navigation", url }); + }; + const onDidStartNavigation = (_event: ElectronEvent, url: string, isInPlace?: boolean, isMainFrame?: boolean) => { + finish({ isMainFrame, type: isInPlace ? "did-navigate-in-page" : "did-start-navigation", url }); + }; + const onDidStartLoading = () => finish({ type: "did-start-loading", url: safeWebContentsUrl(webContents) }); + const onDidStopLoading = () => finish({ type: "did-stop-loading", url: safeWebContentsUrl(webContents) }); + const onDomReady = () => finish({ type: "dom-ready", url: safeWebContentsUrl(webContents) }); + const onDestroyed = () => finish({ type: "destroyed" }); + const timer = setTimeout(() => finish({ type: "timeout" }), Math.max(0, timeoutMs)); + + webContents.once("did-navigate", onDidNavigate); + webContents.once("did-redirect-navigation", onDidRedirectNavigation); + webContents.once("did-fail-load", onDidFailLoad); + webContents.once("did-finish-load", onDidFinishLoad); + webContents.once("did-start-navigation", onDidStartNavigation); + webContents.once("did-start-loading", onDidStartLoading); + webContents.once("did-stop-loading", onDidStopLoading); + webContents.once("dom-ready", onDomReady); + webContents.once("destroyed", onDestroyed); + }); +} + +function waitForNoLoadingStart(webContents: WebContents, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (idle: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + webContents.off("did-start-loading", onDidStartLoading); + webContents.off("destroyed", onDestroyed); + resolve(idle); + }; + const onDidStartLoading = () => finish(false); + const onDestroyed = () => finish(false); + const timer = setTimeout(() => finish(true), Math.max(0, timeoutMs)); + webContents.once("did-start-loading", onDidStartLoading); + webContents.once("destroyed", onDestroyed); + }); +} + +async function interactiveReadinessResult( + webContents: WebContents, + fallbackUrl?: string, + matchedEvent = "interactive" +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + const readinessUrl = safeWebContentsUrl(webContents) || fallbackUrl; + if (!isUsableInteractiveUrl(readinessUrl)) { + return undefined; + } + const readyState = await documentReadyState(webContents); + if (readyState === "interactive" || readyState === "complete" || !webContents.isLoading()) { + return { matched: true, matchedEvent, readinessUrl, timedOut: false }; + } + return undefined; +} + +async function documentReadyState(webContents: WebContents): Promise { + try { + const value = await executeJavaScriptWithTimeout( + webContents, + "document.readyState", + 1000, + "Document readiness check" + ); + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +function isInteractiveReadinessEvent(event: ReadinessEvent): boolean { + return event.type === "dom-ready" || + event.type === "did-finish-load" || + event.type === "did-stop-loading"; +} + +function isUsableInteractiveUrl(value: string | undefined): value is string { + return Boolean(value) && !isBlankPageUrl(value) && !isChromiumErrorPage(value); +} + +function navigationFailureResult(matchedEvent: string, event: Pick): ReadinessResult { + return { + matched: false, + matchedEvent, + navigationError: { + ...(event.errorCode !== undefined ? { errorCode: event.errorCode } : {}), + ...(event.errorDescription ? { errorDescription: event.errorDescription } : {}), + ...(event.url ? { url: event.url } : {}) + }, + readinessUrl: event.url, + timedOut: false + }; +} + +function isNavigationStartEvent(event: ReadinessEvent): boolean { + return event.type === "did-start-navigation" || + event.type === "did-redirect-navigation" || + event.type === "did-navigate"; +} + +function isRelevantNavigationUrl(url: string | undefined, context: NavigationReadinessContext): boolean { + if (!url) { + return false; + } + const normalizedUrl = normalizeUrlForComparison(url); + if (!normalizedUrl) { + return false; + } + if (context.expectedUrl && urlsMatchForNavigation(normalizedUrl, context.expectedUrl)) { + return true; + } + if (isChromiumErrorPage(normalizedUrl)) { + return true; + } + if (isBlankPageUrl(normalizedUrl)) { + return isBlankPageUrl(context.expectedUrl); + } + if (!context.previousUrl) { + return true; + } + return !urlsMatchForNavigation(normalizedUrl, context.previousUrl); +} + +function normalizeNavigationUrlForMatch(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (isBlankPageUrl(trimmed) || isChromiumErrorPage(trimmed)) { + return trimmed; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + return normalizeUrlForComparison(trimmed); + } + if (/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(trimmed) || trimmed.includes(".")) { + return normalizeUrlForComparison(`https://${trimmed}`); + } + return normalizeUrlForComparison(`https://www.google.com/search?q=${encodeURIComponent(trimmed)}`); +} + +function normalizeUrlForComparison(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + try { + const url = new URL(trimmed); + url.hash = ""; + return url.toString(); + } catch { + return trimmed; + } +} + +function urlsMatchForNavigation(left: string | undefined, right: string | undefined): boolean { + if (!left || !right) { + return false; + } + const normalizedLeft = normalizeUrlForComparison(left); + const normalizedRight = normalizeUrlForComparison(right); + if (!normalizedLeft || !normalizedRight) { + return false; + } + if (normalizedLeft === normalizedRight) { + return true; + } + try { + const leftUrl = new URL(normalizedLeft); + const rightUrl = new URL(normalizedRight); + return leftUrl.protocol === rightUrl.protocol && + leftUrl.hostname === rightUrl.hostname && + leftUrl.pathname === rightUrl.pathname && + leftUrl.search === rightUrl.search; + } catch { + return normalizedLeft === normalizedRight; + } +} + +function isBlankPageUrl(value: string | undefined): boolean { + return value === "about:blank"; +} + +function isChromiumErrorPage(value: string | undefined): boolean { + return value === "chrome-error://chromewebdata/"; +} + +function safeWebContentsUrl(webContents: WebContents): string | undefined { + if (webContents.isDestroyed()) { + return undefined; + } + try { + return webContents.getURL(); + } catch { + return undefined; + } +} + +function normalizeWaitUntil(value: string | undefined, fallback: string): string { + const normalized = (value || fallback).toLowerCase().replace(/[-\s]/g, "_"); + if (normalized === "auto" || normalized === "interactive" || normalized === "ready") { + return "interactive"; + } + if (normalized === "dom_ready" || normalized === "domcontentloaded") { + return "domcontentloaded"; + } + if (normalized === "networkidle" || normalized === "network_idle") { + return "network_idle"; + } + if (normalized === "none" || normalized === "load") { + return normalized; + } + return fallback; +} + +function readHandoffKind(value: unknown): BuiltInBrowserAutomationHandoffKind { + const kind = readString(value); + if ( + kind === "blocked" || + kind === "human_verification" || + kind === "login_required" || + kind === "other" || + kind === "verification_code" + ) { + return kind; + } + return "other"; +} + +function normalizeEventChannels(values: string[]): Set { + const normalized = values + .map((value) => value.toLowerCase().trim()) + .filter(Boolean); + if (normalized.length === 0) { + return new Set(["tab", "navigation", "dom", "runtime", "dialog", "download", "input", "focus", "selection", "handoff"]); + } + return new Set(normalized); +} + +function matchesSubscriptionEvent(subscription: EventSubscription, event: BrowserAutomationEvent): boolean { + const channel = eventChannel(event.kind); + if (!subscription.channels.has(channel) && !subscription.channels.has(event.kind) && !subscription.channels.has("*")) { + return false; + } + if (!subscription.ref) { + return true; + } + if (!event.tabId || event.tabId === subscription.ref.tabId) { + return true; + } + return channel === "tab"; +} + +function eventChannel(kind: string): string { + if (kind.startsWith("tab.")) return "tab"; + if (kind.startsWith("page.navigation") || kind.startsWith("page.loading")) return "navigation"; + if (kind === "page.dom_ready") return "dom"; + if (kind.startsWith("runtime.")) return "runtime"; + if (kind.startsWith("dialog.")) return "dialog"; + if (kind.startsWith("download.")) return "download"; + if (kind.startsWith("handoff.")) return "handoff"; + if (kind.startsWith("input.")) return "input"; + if (kind.startsWith("focus.")) return "focus"; + if (kind.startsWith("selection.")) return "selection"; + return kind.split(".")[0] || kind; +} + +function subscriptionDescriptor(subscription: EventSubscription): Record { + return { + channels: [...subscription.channels], + cursor: makeEventCursor(subscription), + redactTextInput: subscription.redactTextInput, + sampleMouseMove: subscription.sampleMouseMove, + session: subscription.ref, + startedAt: subscription.startedAt, + subscriptionId: subscription.subscriptionId + }; +} + +function findRecentHandoffResolution(filter: { handoffId?: string; tabId?: string }): BrowserAutomationEvent | undefined { + const events = builtInBrowserService.getAutomationEvents({ replayRecentMs: automationEventReplayMs }); + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.kind !== "handoff.completed" && event.kind !== "handoff.dismissed") { + continue; + } + if (filter.handoffId && event.handoffId !== filter.handoffId) { + continue; + } + if (filter.tabId && event.tabId !== filter.tabId) { + continue; + } + return event; + } + return undefined; +} + +function handoffResolutionResult( + event: BrowserAutomationEvent, + handoff: BuiltInBrowserAutomationHandoff | undefined, + timedOut: boolean +): Record { + return { + event, + handoff, + matched: !timedOut, + state: browserWindowState(), + status: event.handoffStatus || (event.kind === "handoff.completed" ? "completed" : "dismissed"), + timedOut, + title: event.title, + url: event.url + }; +} + +function eventsAfterCursor(subscription: EventSubscription, cursorSeq?: number): BrowserAutomationEvent[] { + const seq = cursorSeq ?? 0; + return subscription.events.filter((event) => event.seq > seq); +} + +function cursorDropped(subscription: EventSubscription, cursorSeq?: number): boolean { + if (cursorSeq === undefined || subscription.events.length === 0) { + return subscription.dropped; + } + return subscription.dropped && cursorSeq < subscription.events[0].seq - 1; +} + +function makeEventCursor(subscription: EventSubscription, seq?: number): Record { + const lastEvent = subscription.events.findLast((event) => seq === undefined || event.seq <= seq) || subscription.events.at(-1); + return { + dropped: subscription.dropped, + seq: seq ?? lastEvent?.seq ?? 0, + subscriptionId: subscription.subscriptionId, + ts: lastEvent?.ts ?? subscription.startedAt + }; +} + +function readCursorSeq(value: unknown): number | undefined { + if (!isRecord(value)) { + return undefined; + } + return readNumber(value.seq); +} + +function matchesAwaitFilter(event: BrowserAutomationEvent, args: Record): boolean { + const match = isRecord(args.match) ? args.match : args; + const kinds = readStringArray(match.kinds); + const kind = readString(match.kind); + if (kind && event.kind !== kind) { + return false; + } + if (kinds.length > 0 && !kinds.includes(event.kind)) { + return false; + } + const tabId = readString(match.tabId); + if (tabId && event.tabId !== tabId) { + return false; + } + const windowId = readString(match.windowId); + if (windowId && event.windowId !== windowId) { + return false; + } + return stringMatchesPattern(event.url, readString(match.urlPattern)) + && stringMatchesPattern(event.title, readString(match.titlePattern)) + && stringMatchesPattern(event.summary, readString(match.summaryPattern)); +} + +function stringMatchesPattern(value: string | undefined, pattern: string | undefined): boolean { + if (!pattern) { + return true; + } + const haystack = value || ""; + try { + return new RegExp(pattern).test(haystack); + } catch { + return haystack.toLowerCase().includes(pattern.toLowerCase()); + } +} + +async function handleJavaScriptDialog( + webContents: WebContents, + accept: boolean, + promptText: string | undefined, + session: BrowserSessionRef +): Promise> { + let attachedHere = false; + try { + if (!webContents.debugger.isAttached()) { + webContents.debugger.attach("1.3"); + attachedHere = true; + } + await withTimeout(webContents.debugger.sendCommand("Page.enable"), defaultJavascriptTimeoutMs, "Timed out enabling browser dialog handling."); + await withTimeout( + webContents.debugger.sendCommand("Page.handleJavaScriptDialog", { + accept, + ...(promptText !== undefined ? { promptText } : {}) + }), + defaultJavascriptTimeoutMs, + "Timed out handling browser dialog." + ); + return { + accepted: accept, + ok: true, + promptText, + session, + title: await webContents.getTitle(), + url: webContents.getURL() + }; + } catch (error) { + throw new Error(`Failed to handle browser dialog: ${formatError(error)}`); + } finally { + if (attachedHere && webContents.debugger.isAttached()) { + try { + webContents.debugger.detach(); + } catch { + // Ignore detach errors after the dialog is resolved. + } + } + } +} + +async function executeJavaScriptWithTimeout( + webContents: WebContents, + script: string, + timeoutMs: number, + label: string +): Promise { + if (webContents.isDestroyed()) { + throw new Error(`${label} failed because the browser tab is destroyed.`); + } + return await withTimeout( + webContents.executeJavaScript(script, true) as Promise, + timeoutMs, + `${label} timed out after ${timeoutMs}ms.` + ); +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), Math.max(0, timeoutMs)); + }) + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise { + return await executeJavaScriptWithTimeout( + webContents, + `(${snapshotScript})(${JSON.stringify(options)})`, + defaultJavascriptTimeoutMs, + "Browser snapshot" + ) as unknown; +} + +async function captureSnapshotWithHandoff( + webContents: WebContents, + tabId: string | undefined, + options: { maxElements: number; maxText: number } +): Promise { + const snapshot = await captureSnapshot(webContents, options); + const handoff = await maybeRequestPageHandoff(webContents, { + snapshot, + tabId + }); + if (!handoff || !isRecord(snapshot)) { + return snapshot; + } + return { + ...snapshot, + ...humanHelpResultFields(handoff) + }; +} + +async function maybeRequestHandoffAfterNavigation( + webContents: WebContents, + readiness: ReadinessResult, + options: { + requestedUrl?: string; + session?: BrowserSessionRef; + tabId?: string; + waitUntil?: string; + } +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + + if (!readiness.navigationError) { + const pageHandoff = await maybeRequestPageHandoff(webContents, { + session: options.session, + tabId: options.tabId + }); + if (pageHandoff) { + return pageHandoff; + } + } + + return maybeRequestNavigationHandoff(webContents, readiness, options); +} + +function maybeRequestNavigationHandoff( + webContents: WebContents, + readiness: ReadinessResult, + options: { + requestedUrl?: string; + session?: BrowserSessionRef; + tabId?: string; + waitUntil?: string; + } +): BuiltInBrowserAutomationHandoff | undefined { + if (readiness.matched || webContents.isDestroyed()) { + return undefined; + } + + const tabId = options.tabId || options.session?.tabId; + const existing = existingHandoffForTab(tabId); + if (existing) { + return existing; + } + + const currentUrl = safeWebContentsUrl(webContents); + const target = options.requestedUrl || readiness.readinessUrl || currentUrl || "the requested page"; + const error = readiness.navigationError; + const reason = error + ? `Browser navigation was blocked or failed while opening ${target}${error.errorCode !== undefined ? ` (error ${error.errorCode})` : ""}${error.errorDescription ? `: ${error.errorDescription}` : "."}` + : `Browser automation did not reach ${options.waitUntil || "the requested readiness state"} while opening ${target}.`; + + return builtInBrowserService.requestAutomationHandoff({ + kind: "blocked", + message: "Please inspect this browser page and complete or retry the blocked step, then click Done.", + reason, + sessionId: options.session?.sessionId, + tabId + }); +} + +async function maybeRequestPageHandoff( + webContents: WebContents, + options: { + session?: BrowserSessionRef; + snapshot?: unknown; + tabId?: string; + } = {} +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + + let snapshot = options.snapshot; + if (snapshot === undefined) { + try { + snapshot = await captureSnapshot(webContents, { + maxElements: 20, + maxText: 1200 + }); + } catch { + snapshot = undefined; + } + } + + const detection = detectPageHandoff(snapshot, { + title: await webContents.getTitle(), + url: webContents.getURL() + }); + if (!detection) { + return undefined; + } + + const tabId = options.tabId || options.session?.tabId; + const existing = existingHandoffForTab(tabId); + if (existing) { + return existing; + } + + return builtInBrowserService.requestAutomationHandoff({ + kind: detection.kind, + message: detection.message, + reason: detection.reason, + sessionId: options.session?.sessionId, + tabId + }); +} + +function existingHandoffForTab(tabId: string | undefined): BuiltInBrowserAutomationHandoff | undefined { + const existing = builtInBrowserService.getAutomationState().automationHandoff; + if (existing && (!tabId || existing.tabId === tabId)) { + return existing; + } + return undefined; +} + +function handoffMatches( + handoff: BuiltInBrowserAutomationHandoff | undefined, + filter: { handoffId?: string; tabId?: string } +): boolean { + if (!handoff) { + return false; + } + if (filter.handoffId && handoff.id !== filter.handoffId) { + return false; + } + if (filter.tabId && handoff.tabId !== filter.tabId) { + return false; + } + return true; +} + +function detectPageHandoff(snapshot: unknown, fallback: { title?: string; url?: string }): BrowserHandoffDetection | undefined { + const record = isRecord(snapshot) ? snapshot : {}; + const title = readString(record.title) || fallback.title || ""; + const url = readString(record.url) || fallback.url || ""; + const signal = pageHandoffSignal(record, title, url); + if (isChromiumErrorPage(url)) { + return { + kind: "blocked", + message: "Please inspect this browser error page and retry or complete the blocked step, then click Done.", + reason: "Chromium displayed an internal error page for this navigation." + }; + } + if (!signal) { + return undefined; + } + + const verificationCode = /verification code|security code|one[-\s]?time code|2[-\s]?step|two[-\s]?step|authenticator|check your (?:phone|email)|enter the code/.test(signal); + if (verificationCode) { + return { + kind: "verification_code", + message: "Please enter the verification code in this browser window, then click Done.", + reason: `The page appears to require a verification code: ${title || url || "current page"}.` + }; + } + + const cloudflare = signal.includes("cloudflare") || /\bray id\s*:/.test(signal); + const botVerification = /performing security verification|security verification|verif(?:y|ies|ying)[^.\n]{0,80}(?:not a bot|human)|not a bot|not a robot|human verification|are you human|checking your browser|security check/.test(signal); + const challenge = /captcha|hcaptcha|recaptcha|turnstile|cf-challenge|cf-turnstile/.test(signal); + const justAMoment = title.trim().toLowerCase() === "just a moment..." || signal.includes("just a moment..."); + if (challenge || (cloudflare && (botVerification || justAMoment))) { + return { + kind: "human_verification", + message: "Please complete the security verification in this browser window, then click Done.", + reason: `The page appears to be a human verification or bot-protection challenge: ${title || url || "current page"}.` + }; + } + + const loginUrl = /(?:^|[/.?=&_-])(?:login|signin|sign-in|accounts)(?:[/.?=&_-]|$)/.test(url.toLowerCase()) || + /accounts\.google\.com|mail\.google\.com/.test(url.toLowerCase()); + const loginTitle = /\bsign in\b|\blog in\b|\blogin\b|google accounts|account login/.test(title.toLowerCase()); + const credentialFields = /email or phone|email address|username|password|forgot (?:email|password)|create account|\bnext\b/.test(signal); + if ((loginUrl || loginTitle) && credentialFields) { + return { + kind: "login_required", + message: "Please sign in in this browser window, then click Done.", + reason: `The page appears to require a human login: ${title || url || "current page"}.` + }; + } + + return undefined; +} + +function humanHelpResultFields(handoff: BuiltInBrowserAutomationHandoff): Record { + return { + handoff, + handoffRequired: true, + humanHelp: { + aliasTool: "askHumanHelp", + instruction: "Browser automation needs human help. The browser window has been shown with a top toolbar instruction. Call browser_handoff_wait to receive the user's Done/Hide result before continuing automation.", + handoffId: handoff.id, + message: handoff.message, + reason: handoff.reason, + required: true, + status: "requested", + tool: "browser_handoff_wait" + }, + humanHelpRequired: true, + nextAction: "human_help" + }; +} + +function pageHandoffSignal(record: Record, title: string, url: string): string { + const parts = [title, url, rawString(record.text)]; + const activeElement = isRecord(record.activeElement) ? record.activeElement : undefined; + if (activeElement) { + parts.push(rawString(activeElement.name), rawString(activeElement.text), rawString(activeElement.role)); + } + const elements = Array.isArray(record.elements) ? record.elements : []; + for (const element of elements.slice(0, 40)) { + if (!isRecord(element)) { + continue; + } + parts.push( + rawString(element.name), + rawString(element.text), + rawString(element.role), + rawString(element.tag), + rawString(element.href) + ); + } + return parts + .filter((part): part is string => Boolean(part)) + .join("\n") + .replace(/\s+/g, " ") + .toLowerCase(); +} + +async function runTargetAction( + webContents: WebContents, + action: "click" | "focus" | "scroll" | "select" | "type", + target?: BrowserTarget, + value: Record = {} +): Promise { + const result = await executeJavaScriptWithTimeout( + webContents, + `(${targetActionScript})(${JSON.stringify({ action, target, value })})`, + defaultJavascriptTimeoutMs, + `Browser target action ${action}` + ) as { error?: string; ok?: boolean }; + if (!result?.ok) { + throw new Error(result?.error || `Browser target action failed: ${action}`); + } + return result; +} + +async function pressKey(webContents: WebContents, key: string, target?: BrowserTarget): Promise { + if (target) { + await runTargetAction(webContents, "focus", target); + } + webContents.sendInputEvent({ keyCode: key, type: "keyDown" }); + webContents.sendInputEvent({ keyCode: key, type: "keyUp" }); + return { + key, + ok: true, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +async function waitForPageCondition( + webContents: WebContents, + condition: { + selector?: string; + text?: string; + timeoutMs: number; + urlIncludes?: string; + urlMatches?: string; + } +): Promise { + const deadline = Date.now() + condition.timeoutMs; + let last: unknown; + while (Date.now() <= deadline) { + try { + last = await executeJavaScriptWithTimeout( + webContents, + `(${waitForConditionScript})(${JSON.stringify(condition)})`, + Math.max(100, Math.min(1000, deadline - Date.now())), + "Browser wait condition check" + ) as unknown; + if (isRecord(last) && last.matched === true) { + return last; + } + } catch (error) { + last = { + error: formatError(error), + matched: false + }; + } + await sleep(150); + } + return { + last, + matched: false, + timedOut: true, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +const snapshotScript = function(options: { maxElements: number; maxText: number }) { + const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80))); + const maxText = Math.max(0, Math.min(20000, Math.floor(options.maxText || 3000))); + const refAttribute = "data-ccr-browser-ref"; + const elementSelector = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "summary", + "[role]", + "[contenteditable='true']", + "[contenteditable='plaintext-only']", + "[tabindex]:not([tabindex='-1'])" + ].join(","); + + function visible(el: Element) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity || "1") > 0 && + rect.width > 0 && + rect.height > 0; + } + + function cssEscape(value: string) { + return window.CSS && typeof window.CSS.escape === "function" + ? window.CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + + function uniqueIdSelector(id: string) { + if (!id) return ""; + const selector = `#${cssEscape(id)}`; + try { + return document.querySelectorAll(selector).length === 1 ? selector : ""; + } catch { + return ""; + } + } + + function refFor(el: Element) { + const win = window as Window & { __ccrBrowserRefSeq?: number }; + const existing = el.getAttribute(refAttribute); + if (existing) { + return `[${refAttribute}="${cssEscape(existing)}"]`; + } + for (let attempt = 0; attempt < 1000; attempt += 1) { + win.__ccrBrowserRefSeq = (win.__ccrBrowserRefSeq || 0) + 1; + const nextRef = `ccr-${win.__ccrBrowserRefSeq}`; + const selector = `[${refAttribute}="${cssEscape(nextRef)}"]`; + if (!document.querySelector(selector)) { + el.setAttribute(refAttribute, nextRef); + return selector; + } + } + + const idSelector = uniqueIdSelector((el as HTMLElement).id || ""); + if (idSelector) return idSelector; + const parts: string[] = []; + let current: Element | null = el; + while (current && current.nodeType === 1 && current !== document.documentElement && parts.length < 8) { + const tag = current.tagName.toLowerCase(); + const parent: HTMLElement | null = current.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const parentIdSelector = uniqueIdSelector(parent.id || ""); + const sameTag = Array.from(parent.children).filter((child): child is Element => child instanceof Element && child.tagName === current!.tagName); + const index = sameTag.indexOf(current) + 1; + parts.unshift(`${tag}:nth-of-type(${Math.max(index, 1)})`); + if (parentIdSelector) { + parts.unshift(parentIdSelector); + break; + } + current = parent; + } + return parts.join(" > "); + } + + function text(el: Element) { + return ((el as HTMLElement).innerText || el.textContent || "").replace(/\s+/g, " ").trim(); + } + + function labelText(el: Element) { + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + const value = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.innerText || "") + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (value) return value; + } + const id = (el as HTMLInputElement).id; + if (id) { + const label = document.querySelector(`label[for="${cssEscape(id)}"]`); + if (label) return text(label); + } + const wrappingLabel = el.closest("label"); + return wrappingLabel ? text(wrappingLabel) : ""; + } + + function unsafeField(el: Element) { + const input = el as HTMLInputElement; + const haystack = [ + input.type, + input.name, + input.id, + input.autocomplete, + el.getAttribute("aria-label"), + labelText(el) + ].join(" ").toLowerCase(); + return /\b(password|secret|token|api[-_ ]?key|bearer|credential)\b/.test(haystack); + } + + function valueOf(el: Element) { + if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) { + return ""; + } + if (unsafeField(el)) { + return ""; + } + return el.value || ""; + } + + function roleOf(el: Element) { + const explicit = el.getAttribute("role"); + if (explicit) return explicit; + const tag = el.tagName.toLowerCase(); + const input = el as HTMLInputElement; + if (tag === "a") return "link"; + if (tag === "button") return "button"; + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "summary") return "button"; + if (tag === "input") { + if (["button", "submit", "reset"].includes(input.type)) return "button"; + if (input.type === "checkbox") return "checkbox"; + if (input.type === "radio") return "radio"; + if (input.type === "range") return "slider"; + return "textbox"; + } + if (el.getAttribute("contenteditable")) return "textbox"; + return ""; + } + + function nameOf(el: Element) { + const input = el as HTMLInputElement; + return ( + el.getAttribute("aria-label") || + labelText(el) || + el.getAttribute("alt") || + el.getAttribute("title") || + input.placeholder || + (input.type && ["button", "submit", "reset"].includes(input.type) ? input.value : "") || + text(el) + ).replace(/\s+/g, " ").trim(); + } + + const elements = Array.from(document.querySelectorAll(elementSelector)) + .filter(visible) + .slice(0, maxElements) + .map((el, index) => { + const rect = el.getBoundingClientRect(); + return { + checked: el instanceof HTMLInputElement && ["checkbox", "radio"].includes(el.type) ? el.checked : undefined, + disabled: (el as HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).disabled || undefined, + href: el instanceof HTMLAnchorElement ? el.href : undefined, + index, + name: nameOf(el).slice(0, 240), + placeholder: (el as HTMLInputElement).placeholder || undefined, + rect: { + height: Math.round(rect.height), + width: Math.round(rect.width), + x: Math.round(rect.x), + y: Math.round(rect.y) + }, + ref: refFor(el), + role: roleOf(el), + tag: el.tagName.toLowerCase(), + text: text(el).slice(0, 180), + value: valueOf(el) || undefined + }; + }); + + return { + activeElement: document.activeElement ? { + name: nameOf(document.activeElement).slice(0, 240), + ref: refFor(document.activeElement), + role: roleOf(document.activeElement), + tag: document.activeElement.tagName.toLowerCase() + } : undefined, + elements, + text: (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, maxText), + title: document.title, + url: location.href + }; +}; + +const targetActionScript = function(request: { + action: "click" | "focus" | "scroll" | "select" | "type"; + target?: BrowserTarget; + value?: Record; +}) { + function visible(el: Element) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + } + function text(el: Element) { + return ((el as HTMLElement).innerText || el.textContent || "").replace(/\s+/g, " ").trim(); + } + function cssEscape(value: string) { + return window.CSS && typeof window.CSS.escape === "function" + ? window.CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + function labelText(el: Element) { + const id = (el as HTMLInputElement).id; + if (id) { + const label = document.querySelector(`label[for="${cssEscape(id)}"]`); + if (label) return text(label); + } + const wrappingLabel = el.closest("label"); + return wrappingLabel ? text(wrappingLabel) : ""; + } + function roleOf(el: Element) { + const explicit = el.getAttribute("role"); + if (explicit) return explicit.toLowerCase(); + const tag = el.tagName.toLowerCase(); + const input = el as HTMLInputElement; + if (tag === "a") return "link"; + if (tag === "button") return "button"; + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "summary") return "button"; + if (tag === "input") { + if (["button", "submit", "reset"].includes(input.type)) return "button"; + if (input.type === "checkbox") return "checkbox"; + if (input.type === "radio") return "radio"; + return "textbox"; + } + if (el.getAttribute("contenteditable")) return "textbox"; + return ""; + } + function nameOf(el: Element) { + const input = el as HTMLInputElement; + return ( + el.getAttribute("aria-label") || + labelText(el) || + el.getAttribute("alt") || + el.getAttribute("title") || + input.placeholder || + input.value || + text(el) + ).replace(/\s+/g, " ").trim(); + } + function summarize(el: Element) { + const rect = el.getBoundingClientRect(); + return { + name: nameOf(el).slice(0, 240), + rect: { + height: Math.round(rect.height), + width: Math.round(rect.width), + x: Math.round(rect.x), + y: Math.round(rect.y) + }, + role: roleOf(el), + tag: el.tagName.toLowerCase(), + text: text(el).slice(0, 180) + }; + } + function selectorElement(selector: string) { + try { + return document.querySelector(selector); + } catch { + return null; + } + } + function matchesText(value: string, needle: string, exact?: boolean) { + const left = value.trim().toLowerCase(); + const right = needle.trim().toLowerCase(); + return exact ? left === right : left.includes(right); + } + function findTarget(target?: BrowserTarget) { + if (!target && request.action !== "scroll") return null; + const directSelector = target?.selector || target?.ref || target?.axNodeId; + if (directSelector) { + return selectorElement(directSelector); + } + const selector = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "summary", + "[role]", + "[contenteditable='true']", + "[contenteditable='plaintext-only']", + "[tabindex]:not([tabindex='-1'])" + ].join(","); + const candidates = Array.from(document.querySelectorAll(selector)).filter(visible); + const role = target?.role?.trim().toLowerCase(); + const needle = target?.text?.trim(); + const matches = candidates.filter((el) => { + if (role && roleOf(el) !== role) return false; + if (!needle) return true; + const haystack = [ + nameOf(el), + text(el), + (el as HTMLInputElement).placeholder || "", + (el as HTMLInputElement).value || "" + ].join(" "); + return matchesText(haystack, needle, target?.exact); + }); + return matches[Math.max(0, Math.floor(target?.index || 0))] || null; + } + function dispatchValueEvents(el: Element) { + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + + const el = findTarget(request.target); + if (!el && request.action !== "scroll") { + return { error: "No matching browser element found.", ok: false }; + } + if (el) { + el.scrollIntoView({ block: "center", inline: "center" }); + if (el instanceof HTMLElement) { + el.focus({ preventScroll: true }); + } + } + + if (request.action === "focus") { + return { element: el ? summarize(el) : undefined, ok: true }; + } + + if (request.action === "click") { + if (!(el instanceof HTMLElement)) { + return { error: "Matched element is not clickable.", ok: false }; + } + el.click(); + return { element: summarize(el), ok: true }; + } + + if (request.action === "type") { + const nextText = typeof request.value?.text === "string" ? request.value.text : ""; + const replaceExisting = request.value?.replaceExisting !== false; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + el.value = replaceExisting ? nextText : `${el.value}${nextText}`; + dispatchValueEvents(el); + return { element: summarize(el), ok: true, value: el.type === "password" ? "" : el.value }; + } + if (el instanceof HTMLElement && el.isContentEditable) { + if (replaceExisting) { + el.textContent = nextText; + } else { + el.textContent = `${el.textContent || ""}${nextText}`; + } + dispatchValueEvents(el); + return { element: summarize(el), ok: true }; + } + return { error: "Matched element cannot receive typed text.", ok: false }; + } + + if (request.action === "select") { + if (!(el instanceof HTMLSelectElement)) { + return { error: "Matched element is not a select.", ok: false }; + } + const requestedValue = typeof request.value?.value === "string" ? request.value.value : ""; + const requestedLabel = typeof request.value?.label === "string" ? request.value.label : ""; + const exact = request.value?.exact === true; + const option = Array.from(el.options).find((candidate) => { + if (requestedValue && candidate.value === requestedValue) return true; + if (!requestedLabel) return false; + return matchesText(candidate.textContent || "", requestedLabel, exact); + }); + if (!option) { + return { error: "No matching select option found.", ok: false }; + } + el.value = option.value; + dispatchValueEvents(el); + return { element: summarize(el), label: option.textContent || "", ok: true, value: option.value }; + } + + if (request.action === "scroll") { + const deltaX = typeof request.value?.deltaX === "number" ? request.value.deltaX : 0; + const deltaY = typeof request.value?.deltaY === "number" ? request.value.deltaY : 700; + if (el instanceof HTMLElement) { + el.scrollBy({ behavior: "auto", left: deltaX, top: deltaY }); + return { element: summarize(el), ok: true }; + } + window.scrollBy({ behavior: "auto", left: deltaX, top: deltaY }); + return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY }; + } + + return { error: "Unsupported browser target action.", ok: false }; +}; + +const waitForConditionScript = function(condition: { + selector?: string; + text?: string; + urlIncludes?: string; + urlMatches?: string; +}) { + const checks: Array<{ matched: boolean; type: string }> = []; + if (condition.urlIncludes) { + checks.push({ matched: location.href.includes(condition.urlIncludes), type: "urlIncludes" }); + } + if (condition.urlMatches) { + let matched = false; + try { + matched = new RegExp(condition.urlMatches).test(location.href); + } catch { + matched = false; + } + checks.push({ matched, type: "urlMatches" }); + } + if (condition.selector) { + let matched = false; + try { + const el = document.querySelector(condition.selector); + if (el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + matched = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + } + } catch { + matched = false; + } + checks.push({ matched, type: "selector" }); + } + if (condition.text) { + const pageText = (document.body?.innerText || "").toLowerCase(); + checks.push({ matched: pageText.includes(condition.text.toLowerCase()), type: "text" }); + } + return { + checks, + matched: checks.length > 0 && checks.every((check) => check.matched), + title: document.title, + url: location.href + }; +}; + +function readTarget(value: unknown): BrowserTarget { + if (!isRecord(value)) { + throw new Error("A browser target object is required."); + } + return { + axNodeId: readString(value.axNodeId), + backendNodeId: readNumber(value.backendNodeId), + exact: value.exact === true, + index: clampInteger(readNumber(value.index) ?? 0, 0, 10000), + ref: readString(value.ref) || readString(value.axNodeId), + role: readString(value.role), + selector: readString(value.selector), + text: readString(value.text) + }; +} + +function requiredString(value: unknown, message: string): string { + const text = readString(value); + if (!text) { + throw new Error(message); + } + return text; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function clampInteger(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: false, + properties, + required, + type: "object" + }; +} + +type CompactResultOptions = { + maxArrayItems: number; + maxDepth: number; + maxObjectKeys: number; + maxStringChars: number; +}; + +function formatToolResult(toolName: string, result: unknown): string { + const compacted = compactToolResult(toolName, result); + const serialized = stringifyToolResult(compacted); + if (serialized.length <= maxToolResultChars) { + return serialized; + } + + const tighter = compactJsonValue(compacted, { + maxArrayItems: 50, + maxDepth: 6, + maxObjectKeys: 80, + maxStringChars: 600 + }); + const tightSerialized = stringifyToolResult({ + guidance: largeResultGuidance(toolName), + maxChars: maxToolResultChars, + originalChars: serialized.length, + result: tighter, + tool: toolName, + truncated: true + }); + if (tightSerialized.length <= maxToolResultChars) { + return tightSerialized; + } + + return stringifyToolResult({ + guidance: largeResultGuidance(toolName), + maxChars: maxToolResultChars, + originalChars: serialized.length, + preview: truncateString(tightSerialized, 12_000), + tool: toolName, + truncated: true + }); +} + +function compactToolResult(toolName: string, result: unknown): unknown { + if (toolName === "browser_snapshot") { + return compactSnapshotResult(result); + } + if (toolName === "browser_ax_snapshot") { + return compactAxSnapshotResult(result); + } + if (toolName === "browser_ax_query") { + return compactArrayResult(result, "matches", 80, 600); + } + if (toolName === "browser_events_read" || toolName === "browser_events_await") { + return compactArrayResult(result, "events", 80, 800); + } + return compactJsonValue(result, defaultCompactResultOptions()); +} + +function compactSnapshotResult(result: unknown): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const elements = Array.isArray(result.elements) ? result.elements : []; + const text = rawString(result.text); + const compacted: Record = {}; + for (const [key, value] of Object.entries(result)) { + if (key === "elements" || key === "text") { + continue; + } + compacted[key] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars: 600 + }); + } + + if (text !== undefined) { + compacted.text = truncateString(text, maxBrowserResultTextChars); + } + compacted.elements = elements + .slice(0, maxSnapshotResultElements) + .map((element) => compactJsonValue(element, { + maxArrayItems: 20, + maxDepth: 4, + maxObjectKeys: 40, + maxStringChars: 500 + })); + compacted.elementCount = elements.length; + + const truncated: Record = {}; + if (text !== undefined && text.length > maxBrowserResultTextChars) { + truncated.textChars = text.length - maxBrowserResultTextChars; + } + if (elements.length > maxSnapshotResultElements) { + truncated.elements = elements.length - maxSnapshotResultElements; + } + if (Object.keys(truncated).length > 0) { + compacted.truncated = truncated; + } + return compacted; +} + +function compactAxSnapshotResult(result: unknown): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const nodes = Array.isArray(result.nodes) ? result.nodes : []; + const compacted: Record = {}; + for (const [key, value] of Object.entries(result)) { + if (key === "nodes") { + continue; + } + compacted[key] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars: 400 + }); + } + compacted.nodes = nodes + .slice(0, maxAxSnapshotResultNodes) + .map((node) => compactJsonValue(node, { + maxArrayItems: 80, + maxDepth: 5, + maxObjectKeys: 40, + maxStringChars: 260 + })); + compacted.nodeCount = nodes.length; + if (nodes.length > maxAxSnapshotResultNodes) { + compacted.truncated = { + nodes: nodes.length - maxAxSnapshotResultNodes + }; + } + return compacted; +} + +function compactArrayResult(result: unknown, key: string, maxItems: number, maxStringChars: number): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const items = Array.isArray(result[key]) ? result[key] : []; + const compacted: Record = {}; + for (const [entryKey, value] of Object.entries(result)) { + if (entryKey === key) { + continue; + } + compacted[entryKey] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars + }); + } + compacted[key] = items + .slice(0, maxItems) + .map((item) => compactJsonValue(item, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars + })); + compacted[`${key}Count`] = items.length; + if (items.length > maxItems) { + compacted.truncated = { + [key]: items.length - maxItems + }; + } + return compacted; +} + +function compactJsonValue( + value: unknown, + options: CompactResultOptions, + depth = 0, + seen: WeakSet = new WeakSet() +): unknown { + if (value === null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + return truncateString(value, options.maxStringChars); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") { + return undefined; + } + if (typeof value !== "object") { + return String(value); + } + if (seen.has(value)) { + return "[Circular]"; + } + if (depth >= options.maxDepth) { + return { + reason: "Maximum result depth reached.", + truncated: true + }; + } + + seen.add(value); + if (Array.isArray(value)) { + const limit = Math.max(0, options.maxArrayItems); + const items = value + .slice(0, limit) + .map((item) => compactJsonValue(item, options, depth + 1, seen)); + if (value.length > limit) { + items.push({ + omittedItems: value.length - limit, + truncated: true + }); + } + seen.delete(value); + return items; + } + + const entries = Object.entries(value); + const compacted: Record = {}; + for (const [key, entryValue] of entries.slice(0, options.maxObjectKeys)) { + compacted[key] = compactJsonValue(entryValue, options, depth + 1, seen); + } + if (entries.length > options.maxObjectKeys) { + compacted.truncatedKeys = entries.length - options.maxObjectKeys; + } + seen.delete(value); + return compacted; +} + +function defaultCompactResultOptions(): CompactResultOptions { + return { + maxArrayItems: maxToolResultArrayItems, + maxDepth: 8, + maxObjectKeys: maxToolResultObjectKeys, + maxStringChars: maxToolResultStringChars + }; +} + +function stringifyToolResult(value: unknown): string { + return JSON.stringify(value, null, 2) || "null"; +} + +function rawString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function truncateString(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + let suffix = "\n[truncated]"; + for (let index = 0; index < 2; index += 1) { + const keep = Math.max(0, maxChars - suffix.length); + suffix = `\n[truncated ${value.length - keep} chars]`; + } + return `${value.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`; +} + +function largeResultGuidance(toolName: string): string { + if (toolName === "browser_snapshot") { + return "Result was compacted. Re-run browser_snapshot with lower maxElements/maxText, or use browser_ax_query to fetch targeted elements."; + } + if (toolName === "browser_ax_snapshot") { + return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters."; + } + if (toolName === "browser_ax_query") { + return "Result was compacted. Re-run browser_ax_query with a lower limit or narrower role/name/text filters."; + } + if (toolName === "browser_events_read" || toolName === "browser_events_await") { + return "Result was compacted. Read events with a lower limit/maxEvents or a more specific cursor/filter."; + } + return "Result was compacted before returning to the MCP client. Use narrower arguments or lower limits for more detail."; +} + +function textResult(text: string): ToolCallResult { + return { + content: [{ text, type: "text" }] + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function sendJson(response: ServerResponse, status: number, payload: unknown): void { + const body = `${JSON.stringify(payload)}\n`; + response.writeHead(status, { + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + request.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > maxBytes) { + reject(new Error("MCP request body is too large.")); + request.destroy(); + return; + } + chunks.push(buffer); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/electron/src/main/browser-preload.ts b/packages/electron/src/main/browser-preload.ts index 2e1930f..9d571ff 100644 --- a/packages/electron/src/main/browser-preload.ts +++ b/packages/electron/src/main/browser-preload.ts @@ -1,16 +1,19 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; -import type { BuiltInBrowserState } from "@ccr/core/contracts/app"; +import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app"; contextBridge.exposeInMainWorld("ccrBrowser", { back: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserBack, tabId) as Promise, closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserCloseTab, tabId) as Promise, forward: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserForward, tabId) as Promise, + getChromeLoginImport: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.browserGetChromeLoginImport, id) as Promise, getState: () => ipcRenderer.invoke(IPC_CHANNELS.browserGetState) as Promise, navigate: (url: string, tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNavigate, url, tabId) as Promise, newTab: (url?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNewTab, url) as Promise, reload: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserReload, tabId) as Promise, + resolveAutomationHandoff: (status: "completed" | "dismissed") => ipcRenderer.invoke(IPC_CHANNELS.browserResolveAutomationHandoff, status) as Promise, selectTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserSelectTab, tabId) as Promise, + startChromeLoginImport: (request: ChromeLoginImportRequest) => ipcRenderer.invoke(IPC_CHANNELS.browserStartChromeLoginImport, request) as Promise, onStateChanged: (callback: (state: BuiltInBrowserState) => void) => { const handler = (_event: IpcRendererEvent, state: BuiltInBrowserState) => callback(state); ipcRenderer.on(IPC_CHANNELS.browserStateChanged, handler); diff --git a/packages/electron/src/main/built-in-browser.ts b/packages/electron/src/main/built-in-browser.ts index a8ae1b0..c941016 100644 --- a/packages/electron/src/main/built-in-browser.ts +++ b/packages/electron/src/main/built-in-browser.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; import { BrowserWindow, clipboard, @@ -10,28 +11,64 @@ import { WebContentsView, type ContextMenuParams, type IpcMainInvokeEvent, - type MenuItemConstructorOptions + type MenuItemConstructorOptions, + type WebContents } from "electron"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { AppConfig, BuiltInBrowserState, BuiltInBrowserTabState, GatewayPluginAppConfig, InstalledBrowserApp } from "@ccr/core/contracts/app"; +import type { + AppConfig, + BuiltInBrowserAutomationHandoff, + BuiltInBrowserAutomationHandoffKind, + BuiltInBrowserState, + BuiltInBrowserTabState, + ChromeLoginImportRequest, + GatewayPluginAppConfig, + InstalledBrowserApp +} from "@ccr/core/contracts/app"; import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; import { APP_NAME } from "@ccr/core/config/constants"; import { pluginService } from "@ccr/core/plugins/service"; -import { proxyService } from "@ccr/core/proxy/service"; +import { chromeLoginImportService } from "./chrome-login-import"; type BrowserTab = BuiltInBrowserTabState & { view: WebContentsView; }; -const browserChromeHeight = 82; +export type BrowserAutomationEvent = { + errorCode?: number; + errorDescription?: string; + handoffId?: string; + handoffStatus?: "completed" | "dismissed"; + kind: string; + seq: number; + summary?: string; + tabId?: string; + title?: string; + ts: number; + url?: string; + windowId?: string; +}; + +export type BrowserAutomationEventListener = (event: BrowserAutomationEvent) => void; + +const browserChromeBaseHeight = 82; +const browserHandoffToolbarHeight = 44; const browserHomeUrl = "about:blank"; const browserPartition = "persist:ccr-built-in-browser"; +const browserAutomationWindowId = "ccr-built-in-browser"; +const maxAutomationEventHistory = 512; +const maxAutomationEventAgeMs = 60_000; const titleBarHeight = 46; class BuiltInBrowserService { private activeTabId?: string; private apps: InstalledBrowserApp[] = []; + private automationHandoff?: BuiltInBrowserAutomationHandoff; + private automationEventSeq = 0; + private readonly automationEvents = new EventEmitter(); + private readonly automationEventHistory: BrowserAutomationEvent[] = []; + private hideWindowAfterAutomationHandoff = false; private proxyConfigKey = ""; private tabOrder: string[] = []; private tabs = new Map(); @@ -44,10 +81,7 @@ class BuiltInBrowserService { async open(config: AppConfig): Promise { await this.syncProxy(config); - const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow(); - if (this.tabs.size === 0) { - this.createTab(browserHomeUrl); - } + const window = this.ensureWindow(); if (window.isMinimized()) { window.restore(); } @@ -57,28 +91,18 @@ class BuiltInBrowserService { this.sendState(); } + async openHidden(config: AppConfig): Promise { + await this.syncProxy(config); + this.ensureWindow(); + this.layoutActiveView(); + this.sendState(); + } + async syncProxy(config: AppConfig): Promise { this.syncApps(config); const browserSession = session.fromPartition(browserPartition); - if (config.proxy.enabled) { - await proxyService.refreshUpstreamProxyFromCurrentSystem(); - } - const proxyStatus = proxyService.getStatus(); - const shouldUseProxy = Boolean( - config.proxy.enabled && - proxyStatus.state === "running" && - proxyStatus.endpoint - ); - const proxyConfig = shouldUseProxy - ? { - mode: "fixed_servers" as const, - proxyBypassRules: "<-loopback>", - proxyRules: electronProxyRules(proxyStatus.endpoint) - } - : { - mode: "direct" as const - }; + const proxyConfig = { mode: "system" as const }; const nextKey = JSON.stringify(proxyConfig); if (nextKey === this.proxyConfigKey) { return; @@ -100,9 +124,211 @@ class BuiltInBrowserService { async clearProxy(): Promise { const browserSession = session.fromPartition(browserPartition); - await browserSession.setProxy({ mode: "direct" }); + const proxyConfig = { mode: "system" as const }; + await browserSession.setProxy(proxyConfig); await browserSession.forceReloadProxyConfig(); - this.proxyConfigKey = JSON.stringify({ mode: "direct" }); + this.proxyConfigKey = JSON.stringify(proxyConfig); + } + + getAutomationState(): BuiltInBrowserState { + return this.getState(); + } + + getAutomationWindowId(): string { + return browserAutomationWindowId; + } + + getAutomationEvents(options: { replayRecentMs?: number } = {}): BrowserAutomationEvent[] { + const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0)); + const cutoff = replayRecentMs > 0 ? Date.now() - replayRecentMs : 0; + this.pruneAutomationEventHistory(); + return this.automationEventHistory + .filter((event) => event.ts >= cutoff) + .map((event) => ({ ...event })); + } + + requestAutomationHandoff(request: { + kind?: BuiltInBrowserAutomationHandoffKind; + message?: string; + reason?: string; + sessionId?: string; + tabId?: string; + }): BuiltInBrowserAutomationHandoff { + const existingWindow = this.window && !this.window.isDestroyed() ? this.window : undefined; + if (!this.automationHandoff) { + this.hideWindowAfterAutomationHandoff = !existingWindow || !existingWindow.isVisible() || existingWindow.isMinimized(); + } + const tab = this.getTab(request.tabId); + if (request.tabId && tab?.id) { + this.selectTab(request.tabId); + } + const targetTab = tab || this.getTab(); + const handoff: BuiltInBrowserAutomationHandoff = { + id: randomUUID(), + kind: request.kind || "other", + message: request.message?.trim() || defaultAutomationHandoffMessage(request.kind), + ...(request.reason?.trim() ? { reason: request.reason.trim() } : {}), + requestedAt: Date.now(), + ...(request.sessionId?.trim() ? { sessionId: request.sessionId.trim() } : {}), + status: "pending", + tabId: targetTab?.id || request.tabId || this.activeTabId + }; + this.automationHandoff = handoff; + this.layoutActiveView(); + this.showAutomationWindow(); + this.sendState(); + this.emitAutomationEvent({ + handoffId: handoff.id, + kind: "handoff.requested", + summary: handoff.message, + tabId: handoff.tabId, + title: targetTab?.title, + url: targetTab?.url + }); + return handoff; + } + + resolveAutomationHandoff(status: "completed" | "dismissed" = "completed"): BuiltInBrowserState { + const handoff = this.automationHandoff; + const shouldHideWindow = this.hideWindowAfterAutomationHandoff; + this.automationHandoff = undefined; + this.hideWindowAfterAutomationHandoff = false; + this.layoutActiveView(); + this.sendState(); + if (handoff) { + const tab = this.getTab(handoff.tabId); + if (shouldHideWindow) { + this.hideAutomationWindow(); + } + this.emitAutomationEvent({ + handoffId: handoff.id, + handoffStatus: status, + kind: status === "completed" ? "handoff.completed" : "handoff.dismissed", + summary: status === "completed" ? "User completed the requested browser handoff." : "User dismissed the requested browser handoff.", + tabId: handoff.tabId, + title: tab?.title, + url: tab?.url + }); + } + return this.getState(); + } + + subscribeAutomationEvents( + listener: BrowserAutomationEventListener, + options: { replayRecentMs?: number } = {} + ): () => void { + this.automationEvents.on("event", listener); + const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0)); + if (replayRecentMs > 0) { + const cutoff = Date.now() - replayRecentMs; + this.pruneAutomationEventHistory(); + for (const event of this.automationEventHistory) { + if (event.ts >= cutoff) { + listener(event); + } + } + } + return () => this.automationEvents.off("event", listener); + } + + createAutomationTab(url = browserHomeUrl): BuiltInBrowserTabState { + const tab = this.createTab(url); + const { view: _view, ...state } = tab; + return state; + } + + selectAutomationTab(tabId: string): BuiltInBrowserState { + this.selectTab(tabId); + return this.getState(); + } + + closeAutomationTab(tabId: string): BuiltInBrowserState { + this.closeTab(tabId); + return this.getState(); + } + + async navigateAutomationTab(url: string, tabId?: string): Promise { + const tab = this.getTab(tabId); + if (!tab) { + throw new Error("Browser tab was not found."); + } + + const nextUrl = normalizeBrowserUrl(url); + tab.url = nextUrl; + if (tab.id === this.activeTabId) { + this.layoutActiveView(); + } + this.sendState(); + void tab.view.webContents.loadURL(nextUrl).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Navigation request failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + }); + this.emitAutomationEvent({ + kind: "page.navigation_requested", + summary: `Navigation requested: ${nextUrl}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + return this.getState(); + } + + goBackAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.navigationHistory.goBack(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.go_back", + summary: `Go back in tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + goForwardAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.navigationHistory.goForward(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.go_forward", + summary: `Go forward in tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + reloadAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.reload(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.reload", + summary: `Reload tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + getAutomationWebContents(tabId?: string): WebContents { + const tab = this.getTab(tabId); + if (!tab || tab.view.webContents.isDestroyed()) { + throw new Error("Browser tab is unavailable."); + } + return tab.view.webContents; } private registerIpcHandlers(): void { @@ -110,6 +336,14 @@ class BuiltInBrowserService { this.assertBrowserSender(event); return this.getState(); }); + ipcMain.handle(IPC_CHANNELS.browserStartChromeLoginImport, async (event, request: ChromeLoginImportRequest) => { + this.assertBrowserSender(event); + return await chromeLoginImportService.createJob(request); + }); + ipcMain.handle(IPC_CHANNELS.browserGetChromeLoginImport, (event, id: string) => { + this.assertBrowserSender(event); + return chromeLoginImportService.getJob(typeof id === "string" ? id : ""); + }); ipcMain.handle(IPC_CHANNELS.browserNewTab, (event, url?: string) => { this.assertBrowserSender(event); this.createTab(url); @@ -145,6 +379,34 @@ class BuiltInBrowserService { this.getTab(tabId)?.view.webContents.reload(); return this.getState(); }); + ipcMain.handle(IPC_CHANNELS.browserResolveAutomationHandoff, (event, status?: string) => { + this.assertBrowserSender(event); + return this.resolveAutomationHandoff(status === "dismissed" ? "dismissed" : "completed"); + }); + } + + private ensureWindow(): BrowserWindow { + const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow(); + if (this.tabs.size === 0) { + this.createTab(browserHomeUrl); + } + return window; + } + + private showAutomationWindow(): void { + const window = this.ensureWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + + private hideAutomationWindow(): void { + const window = this.window; + if (window && !window.isDestroyed()) { + window.hide(); + } } private createWindow(): BrowserWindow { @@ -187,15 +449,12 @@ class BuiltInBrowserService { this.window = undefined; } }); - window.once("ready-to-show", () => { - if (!window.isDestroyed()) { - window.show(); - } - }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("did-finish-load", () => this.sendState()); - void window.loadURL(this.resolveRendererUrl("pages/browser/index.html")); + void window.loadURL(this.resolveRendererUrl("pages/browser/index.html")).catch((error) => { + console.warn(`[browser] Failed to load browser chrome: ${formatError(error)}`); + }); return window; } @@ -225,8 +484,23 @@ class BuiltInBrowserService { this.window?.contentView.addChildView(view); view.setVisible(false); this.selectTab(tab.id); - void view.webContents.loadURL(tab.url); + void view.webContents.loadURL(tab.url).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Initial tab load failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + }); this.sendState(); + this.emitAutomationEvent({ + kind: "tab.created", + summary: `Created tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); return tab; } @@ -234,7 +508,14 @@ class BuiltInBrowserService { const { webContents } = tab.view; webContents.setWindowOpenHandler(({ url }) => { if (isHttpUrl(url)) { - this.createTab(url); + const opened = this.createTab(url); + this.emitAutomationEvent({ + kind: "tab.opened", + summary: `Opened new tab from window.open: ${url}`, + tabId: opened.id, + title: opened.title, + url: opened.url + }); } return { action: "deny" }; }); @@ -244,14 +525,35 @@ class BuiltInBrowserService { webContents.on("page-title-updated", (_event, title) => { tab.title = title || titleFromUrl(tab.url); this.sendState(); + this.emitAutomationEvent({ + kind: "page.title", + summary: `Title updated: ${tab.title}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-start-loading", () => { tab.isLoading = true; this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.loading_started", + summary: `Loading started: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-stop-loading", () => { tab.isLoading = false; this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.loading_stopped", + summary: `Loading stopped: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-navigate", (_event, url) => { tab.url = url; @@ -260,6 +562,13 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.navigation", + summary: `Navigated to ${url}`, + tabId: tab.id, + title: tab.title, + url + }); }); webContents.on("did-navigate-in-page", (_event, url) => { tab.url = url; @@ -267,8 +576,15 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.navigation_in_page", + summary: `In-page navigation to ${url}`, + tabId: tab.id, + title: tab.title, + url + }); }); - webContents.on("did-fail-load", (_event, errorCode, _errorDescription, validatedUrl) => { + webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedUrl) => { if (errorCode !== -3) { tab.isLoading = false; tab.url = validatedUrl || tab.url; @@ -276,6 +592,35 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + errorCode, + errorDescription, + kind: "page.load_failed", + summary: `Load failed (${errorCode}): ${errorDescription}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + }); + webContents.on("dom-ready", () => { + this.emitAutomationEvent({ + kind: "page.dom_ready", + summary: `DOM ready: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + }); + webContents.on("console-message", (_event, level, message) => { + if (level >= 2) { + this.emitAutomationEvent({ + kind: "runtime.console", + summary: message, + tabId: tab.id, + title: tab.title, + url: tab.url + }); } }); webContents.on("destroyed", () => { @@ -286,6 +631,13 @@ class BuiltInBrowserService { this.activeTabId = this.tabOrder[0]; } this.sendState(); + this.emitAutomationEvent({ + kind: "tab.destroyed", + summary: `Destroyed tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); } }); } @@ -403,6 +755,13 @@ class BuiltInBrowserService { selected.view.webContents.focus(); } this.sendState(); + this.emitAutomationEvent({ + kind: "tab.activated", + summary: `Activated tab ${tabId}.`, + tabId, + title: selected.title, + url: selected.url + }); } private closeTab(tabId: string): void { @@ -415,6 +774,13 @@ class BuiltInBrowserService { this.tabs.delete(tabId); this.tabOrder = this.tabOrder.filter((id) => id !== tabId); tab.view.webContents.close({ waitForBeforeUnload: false }); + this.emitAutomationEvent({ + kind: "tab.closed", + summary: `Closed tab ${tabId}.`, + tabId, + title: tab.title, + url: tab.url + }); if (this.tabOrder.length === 0) { this.createTab(browserHomeUrl); @@ -440,8 +806,23 @@ class BuiltInBrowserService { if (tab.id === this.activeTabId) { this.layoutActiveView(); } - void tab.view.webContents.loadURL(nextUrl); + void tab.view.webContents.loadURL(nextUrl).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Navigation request failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + }); this.sendState(); + this.emitAutomationEvent({ + kind: "page.navigation_requested", + summary: `Navigation requested: ${nextUrl}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); } private getTab(tabId?: string): BrowserTab | undefined { @@ -469,17 +850,22 @@ class BuiltInBrowserService { const { height, width } = window.getContentBounds(); activeTab.view.setVisible(true); activeTab.view.setBounds({ - height: Math.max(0, height - browserChromeHeight), + height: Math.max(0, height - this.browserChromeHeight()), width, x: 0, - y: browserChromeHeight + y: this.browserChromeHeight() }); } + private browserChromeHeight(): number { + return browserChromeBaseHeight + (this.automationHandoff ? browserHandoffToolbarHeight : 0); + } + private getState(): BuiltInBrowserState { return { activeTabId: this.activeTabId, apps: this.apps.map((app) => ({ ...app })), + ...(this.automationHandoff ? { automationHandoff: { ...this.automationHandoff } } : {}), tabs: this.tabOrder .map((id) => this.tabs.get(id)) .filter((tab): tab is BrowserTab => Boolean(tab)) @@ -512,6 +898,27 @@ class BuiltInBrowserService { this.activeTabId = undefined; } + private emitAutomationEvent(event: Omit): void { + const nextEvent: BrowserAutomationEvent = { + ...event, + seq: ++this.automationEventSeq, + ts: Date.now(), + windowId: browserAutomationWindowId + }; + this.automationEventHistory.push(nextEvent); + this.pruneAutomationEventHistory(nextEvent.ts); + this.automationEvents.emit("event", nextEvent); + } + + private pruneAutomationEventHistory(now = Date.now()): void { + while (this.automationEventHistory.length > 0 && now - this.automationEventHistory[0].ts > maxAutomationEventAgeMs) { + this.automationEventHistory.shift(); + } + if (this.automationEventHistory.length > maxAutomationEventHistory) { + this.automationEventHistory.splice(0, this.automationEventHistory.length - maxAutomationEventHistory); + } + } + private resolveRendererUrl(relativeHtmlPath: string): string { return pathToFileURL(path.join(__dirname, "../renderer", relativeHtmlPath)).toString(); } @@ -619,9 +1026,22 @@ function titleFromUrl(value: string): string { } } -function electronProxyRules(endpoint: string): string { - const parsed = new URL(endpoint); - const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname; - const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); - return `http=${host}:${port};https=${host}:${port}`; +function defaultAutomationHandoffMessage(kind?: BuiltInBrowserAutomationHandoffKind): string { + if (kind === "login_required") { + return "Please sign in on this page, then click Done."; + } + if (kind === "verification_code") { + return "Please enter the verification code, then click Done."; + } + if (kind === "human_verification") { + return "Please complete the human verification, then click Done."; + } + if (kind === "blocked") { + return "Please resolve the blocker on this page, then click Done."; + } + return "Please complete the requested browser step, then click Done."; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/packages/electron/src/main/chrome-login-import.ts b/packages/electron/src/main/chrome-login-import.ts new file mode 100644 index 0000000..850dd17 --- /dev/null +++ b/packages/electron/src/main/chrome-login-import.ts @@ -0,0 +1,593 @@ +import { randomUUID } from "node:crypto"; +import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { BrowserWindow, session, shell } from "electron"; +import type { + ChromeLoginImportCookie, + ChromeLoginImportJob, + ChromeLoginImportLocalStorage, + ChromeLoginImportRequest, + ChromeLoginImportResult, + ChromeLoginImportTarget +} from "@ccr/core/contracts/app"; + +type ServerInfo = { + server: Server; + url: string; +}; + +type StoredChromeLoginImportJob = ChromeLoginImportJob; + +type CookieSetDetails = Parameters[0]; + +const browserPartition = "persist:ccr-built-in-browser"; +const webSearchPartition = "persist:ccr-browser-web-search-mcp"; +const importJobTtlMs = 5 * 60_000; +const maxImportRequestBytes = 16 * 1024 * 1024; +const maxStoredErrors = 20; +const localStorageWriteTimeoutMs = 15_000; + +class ChromeLoginImportService { + private readonly jobs = new Map(); + private serverInfo?: ServerInfo; + private serverStartPromise?: Promise; + + async createJob(request: ChromeLoginImportRequest): Promise { + const domains = uniqueStrings(request.domains.map(normalizeImportDomain).filter((item): item is string => Boolean(item))); + if (domains.length === 0) { + throw new Error("At least one Chrome import domain is required."); + } + + const target = normalizeImportTarget(request.target); + const serverInfo = await this.ensureServer(); + const id = randomUUID(); + const now = Date.now(); + const job: StoredChromeLoginImportJob = { + confirmUrl: `${serverInfo.url}/chrome-import/jobs/${id}/confirm`, + createdAt: now, + domains, + endpointUrl: serverInfo.url, + expiresAt: now + importJobTtlMs, + id, + importUrl: `${serverInfo.url}/chrome-import/jobs/${id}`, + status: "pending", + target + }; + this.jobs.set(id, job); + this.pruneJobs(); + if (request.openConfirmationPage) { + await this.openConfirmationPage(job.id); + } + return cloneJob(job); + } + + getJob(id: string): ChromeLoginImportJob | undefined { + const job = this.jobs.get(id); + if (!job) { + return undefined; + } + this.refreshExpiredJob(job); + return cloneJob(job); + } + + async openConfirmationPage(id: string): Promise { + const job = this.jobs.get(id); + if (!job) { + throw new Error("Chrome login import job was not found."); + } + this.refreshExpiredJob(job); + if (job.status !== "pending") { + throw new Error(`Chrome login import job is ${job.status}.`); + } + await shell.openExternal(job.confirmUrl); + return cloneJob(job); + } + + private async ensureServer(): Promise { + if (this.serverInfo) { + return this.serverInfo; + } + if (this.serverStartPromise) { + return this.serverStartPromise; + } + + this.serverStartPromise = new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + void this.handleRequest(request, response).catch((error) => { + sendJson(response, 500, { error: { message: formatError(error) } }); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Chrome login import server failed to start.")); + return; + } + const info = { + server, + url: `http://127.0.0.1:${address.port}` + }; + this.serverInfo = info; + resolve(info); + }); + }).finally(() => { + this.serverStartPromise = undefined; + }); + return this.serverStartPromise; + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + setCorsHeaders(response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + const path = request.url ? new URL(request.url, "http://127.0.0.1").pathname : "/"; + const match = path.match(/^\/chrome-import\/jobs\/([^/]+)(?:\/(confirm|cookies))?$/); + if (!match) { + sendJson(response, 404, { error: { message: "Chrome login import endpoint not found." } }); + return; + } + + const id = decodeURIComponent(match[1]); + const job = this.jobs.get(id); + if (!job) { + sendJson(response, 404, { error: { message: "Chrome login import job was not found." } }); + return; + } + this.refreshExpiredJob(job); + if (job.status === "expired") { + sendJson(response, 410, { error: { message: "Chrome login import job expired." }, job: cloneJob(job) }); + return; + } + + const action = match[2]; + if (request.method === "GET" && action === "confirm") { + sendHtml(response, 200, confirmationPageHtml(job)); + return; + } + + if (request.method === "GET" && !action) { + sendJson(response, 200, { job: cloneJob(job) }); + return; + } + + if (request.method === "POST" && action === "cookies") { + if (job.status !== "pending") { + sendJson(response, 409, { + error: { + message: `Chrome login import job is ${job.status}.` + }, + job: cloneJob(job) + }); + return; + } + const payload = await readJsonRequest(request); + const result = await this.importLoginState(job, payload); + sendJson(response, 200, { job: cloneJob(job), result }); + return; + } + + sendJson(response, 405, { error: { message: "Unsupported Chrome login import method." } }); + } + + private async importLoginState(job: StoredChromeLoginImportJob, payload: unknown): Promise { + const importPayload = readImportPayload(payload); + const partitions = targetPartitions(job.target); + const result: ChromeLoginImportResult = { + completedAt: Date.now(), + cookieImported: 0, + cookieSkipped: 0, + domains: [...job.domains], + imported: 0, + localStorageImported: 0, + localStorageSkipped: 0, + partitions, + skipped: 0 + }; + const errors: string[] = []; + + for (const cookie of importPayload.cookies) { + const normalized = normalizeChromeCookie(cookie, job.domains); + if (!normalized) { + result.cookieSkipped += 1; + continue; + } + + try { + await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.set(normalized))); + result.cookieImported += 1; + } catch (error) { + result.cookieSkipped += 1; + if (errors.length < maxStoredErrors) { + errors.push(`${normalized.domain || new URL(normalized.url).hostname}:${normalized.name}: ${formatError(error)}`); + } + } + } + + await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.flushStore())); + + for (const localStorageEntry of importPayload.localStorage) { + const normalized = normalizeLocalStorageEntry(localStorageEntry, job.domains); + if (!normalized) { + result.localStorageSkipped += 1; + continue; + } + + const itemCount = Object.keys(normalized.items).length; + if (itemCount === 0) { + result.localStorageSkipped += 1; + continue; + } + + try { + await Promise.all(partitions.map((partition) => writeLocalStorage(partition, normalized.origin, normalized.items))); + result.localStorageImported += itemCount; + } catch (error) { + result.localStorageSkipped += itemCount; + if (errors.length < maxStoredErrors) { + errors.push(`${normalized.origin}: localStorage: ${formatError(error)}`); + } + } + } + + result.imported = result.cookieImported + result.localStorageImported; + result.skipped = result.cookieSkipped + result.localStorageSkipped; + if (errors.length > 0) { + result.errors = errors; + } + job.result = result; + job.status = result.imported > 0 || result.skipped > 0 ? "completed" : "failed"; + return result; + } + + private refreshExpiredJob(job: StoredChromeLoginImportJob): void { + if (job.status === "pending" && Date.now() > job.expiresAt) { + job.status = "expired"; + } + } + + private pruneJobs(): void { + const cutoff = Date.now() - importJobTtlMs; + for (const [id, job] of this.jobs) { + this.refreshExpiredJob(job); + if (job.expiresAt < cutoff) { + this.jobs.delete(id); + } + } + } +} + +export const chromeLoginImportService = new ChromeLoginImportService(); + +function targetPartitions(target: ChromeLoginImportTarget): string[] { + return target === "browser-and-web-search" + ? [browserPartition, webSearchPartition] + : [browserPartition]; +} + +function normalizeChromeCookie(cookie: ChromeLoginImportCookie, allowedDomains: string[]): CookieSetDetails | undefined { + if (!isRecord(cookie) || typeof cookie.name !== "string" || typeof cookie.value !== "string") { + return undefined; + } + if (cookie.partitionKey !== undefined) { + return undefined; + } + + const rawDomain = readString(cookie.domain).toLowerCase(); + const cookieHost = normalizeCookieHost(rawDomain); + if (!cookieHost || !allowedDomains.some((domain) => cookieDomainMatches(cookieHost, domain))) { + return undefined; + } + + const path = normalizeCookiePath(cookie.path); + const expirationDate = typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate) + ? cookie.expirationDate + : undefined; + if (expirationDate !== undefined && expirationDate <= Date.now() / 1000) { + return undefined; + } + + const secure = cookie.secure === true; + const details: CookieSetDetails = { + httpOnly: cookie.httpOnly === true, + name: cookie.name, + path, + secure, + url: cookieUrl(cookieHost, path, secure), + value: cookie.value + }; + if (cookie.hostOnly !== true) { + details.domain = rawDomain || cookieHost; + } + if (expirationDate !== undefined && cookie.session !== true) { + details.expirationDate = expirationDate; + } + const sameSite = normalizeSameSite(cookie.sameSite); + if (sameSite) { + details.sameSite = sameSite; + } + return details; +} + +function normalizeLocalStorageEntry( + entry: ChromeLoginImportLocalStorage, + allowedDomains: string[] +): ChromeLoginImportLocalStorage | undefined { + if (!isRecord(entry) || !isRecord(entry.items)) { + return undefined; + } + + let origin: URL; + try { + origin = new URL(readString(entry.origin)); + } catch { + return undefined; + } + + if (!["http:", "https:"].includes(origin.protocol)) { + return undefined; + } + if (!allowedDomains.some((domain) => cookieDomainMatches(origin.hostname.toLowerCase(), domain))) { + return undefined; + } + + const items: Record = {}; + for (const [key, value] of Object.entries(entry.items)) { + if (typeof key === "string" && typeof value === "string") { + items[key] = value; + } + } + return { + items, + origin: origin.origin + }; +} + +async function writeLocalStorage(partition: string, origin: string, items: Record): Promise { + const window = new BrowserWindow({ + height: 480, + paintWhenInitiallyHidden: true, + show: false, + skipTaskbar: true, + title: "CCR Chrome Login Import", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + partition, + sandbox: true, + webSecurity: true + }, + width: 640 + }); + try { + await withTimeout(window.webContents.loadURL(`${origin}/`), localStorageWriteTimeoutMs, "Timed out loading localStorage origin."); + const loadedOrigin = new URL(window.webContents.getURL()).origin; + if (loadedOrigin !== origin) { + throw new Error(`Origin redirected to ${loadedOrigin}.`); + } + const entries = Object.entries(items); + await window.webContents.executeJavaScript( + `(() => { + const entries = ${JSON.stringify(entries)}; + for (const [key, value] of entries) { + window.localStorage.setItem(key, value); + } + return entries.length; + })()`, + true + ); + } finally { + if (!window.isDestroyed()) { + window.destroy(); + } + } +} + +function normalizeImportTarget(value: unknown): ChromeLoginImportTarget { + return value === "browser-and-web-search" ? "browser-and-web-search" : "browser"; +} + +function normalizeImportDomain(value: unknown): string | undefined { + const raw = readString(value).toLowerCase(); + if (!raw) { + return undefined; + } + try { + const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`); + return normalizeCookieHost(parsed.hostname); + } catch { + return normalizeCookieHost(raw.replace(/^\*\./, "").split("/")[0] || ""); + } +} + +function normalizeCookieHost(value: string): string | undefined { + const host = value.trim().replace(/^\./, "").toLowerCase(); + if (!host || host.includes("*") || host.includes("/") || host.includes(" ")) { + return undefined; + } + return host; +} + +function normalizeCookiePath(value: unknown): string { + const path = readString(value); + return path.startsWith("/") ? path : "/"; +} + +function cookieUrl(host: string, path: string, secure: boolean): string { + const normalizedPath = path.startsWith("/") ? path : "/"; + return `${secure ? "https" : "http"}://${host}${normalizedPath}`; +} + +function cookieDomainMatches(cookieHost: string, allowedDomain: string): boolean { + return cookieHost === allowedDomain || cookieHost.endsWith(`.${allowedDomain}`); +} + +function normalizeSameSite(value: unknown): CookieSetDetails["sameSite"] | undefined { + return value === "lax" || value === "strict" || value === "no_restriction" || value === "unspecified" + ? value + : undefined; +} + +function readImportPayload(payload: unknown): { + cookies: ChromeLoginImportCookie[]; + localStorage: ChromeLoginImportLocalStorage[]; +} { + if (!isRecord(payload)) { + throw new Error("Chrome login import payload must be an object."); + } + const cookies = Array.isArray(payload.cookies) + ? payload.cookies.filter(isRecord) as ChromeLoginImportCookie[] + : []; + const localStorage = Array.isArray(payload.localStorage) + ? payload.localStorage.filter(isRecord) as ChromeLoginImportLocalStorage[] + : []; + if (cookies.length === 0 && localStorage.length === 0) { + throw new Error("Chrome login import payload must include cookies or localStorage."); + } + return { cookies, localStorage }; +} + +function cloneJob(job: StoredChromeLoginImportJob): ChromeLoginImportJob { + return { + ...job, + domains: [...job.domains], + ...(job.result + ? { + result: { + ...job.result, + domains: [...job.result.domains], + ...(job.result.errors ? { errors: [...job.result.errors] } : {}), + partitions: [...job.result.partitions] + } + } + : {}) + }; +} + +async function readJsonRequest(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > maxImportRequestBytes) { + throw new Error("Chrome login import payload is too large."); + } + chunks.push(buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; +} + +function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { + if (!response.headersSent) { + response.writeHead(statusCode, { "content-type": "application/json" }); + } + response.end(`${JSON.stringify(body)}\n`); +} + +function sendHtml(response: ServerResponse, statusCode: number, body: string): void { + if (!response.headersSent) { + response.writeHead(statusCode, { + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; connect-src http://127.0.0.1:* http://localhost:*", + "content-type": "text/html; charset=utf-8" + }); + } + response.end(body); +} + +function confirmationPageHtml(job: StoredChromeLoginImportJob): string { + const domains = job.domains.map((domain) => `
  • ${escapeHtml(domain)}
  • `).join(""); + return ` + + + + + CCR Chrome Login Import + + + +
    +

    Import Chrome Login State into CCR

    +

    CCR is requesting permission to import cookies and localStorage for these domains into the in-app browser.

    +
      ${domains}
    + +
    Install or enable the CCR Login Import extension in Chrome to continue.
    +
    + +`; +} + +function setCorsHeaders(response: ServerResponse): void { + response.setHeader("access-control-allow-headers", "content-type, x-ccr-login-import"); + response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); + response.setHeader("access-control-allow-origin", "*"); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + default: + return "'"; + } + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeout) { + clearTimeout(timeout); + } + }); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/electron/src/main/electron-web-search-mcp.ts b/packages/electron/src/main/electron-web-search-mcp.ts index 0badf3b..e59ec3a 100644 --- a/packages/electron/src/main/electron-web-search-mcp.ts +++ b/packages/electron/src/main/electron-web-search-mcp.ts @@ -70,6 +70,7 @@ type BrowserSearchRequest = BrowserSearchInput & { type BrowserSearchResult = { content?: string; + diagnostics?: string[]; snippet?: string; title: string; url: string; @@ -597,11 +598,15 @@ class HiddenBrowserSearchPool { return { ...result, ...(content ? { content } : {}), + ...(!content ? { diagnostics: appendSearchResultDiagnostic(result.diagnostics, "No extractable page content found.") } : {}), ...(snippet ? { snippet } : {}), ...(page.title && page.title.length > result.title.length ? { title: page.title.slice(0, 240) } : {}) }; - } catch { - return result; + } catch (error) { + return { + ...result, + diagnostics: appendSearchResultDiagnostic(result.diagnostics, `Page extraction failed: ${formatError(error)}`) + }; } finally { if (worker) { this.release(worker); @@ -635,7 +640,16 @@ async function pollSearchResults(webContents: WebContents, request: BrowserSearc const deadline = Date.now() + request.timeoutMs; let last: BrowserSearchPageResult = { results: [] }; while (Date.now() < deadline) { - last = await webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as BrowserSearchPageResult; + try { + last = await withTimeout( + webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as Promise, + Math.max(100, Math.min(1000, deadline - Date.now())), + "Browser search result extraction timed out." + ); + } catch { + await sleep(150); + continue; + } if (last.blocked || (last.results?.length ?? 0) >= Math.min(request.count, 3)) { return last; } @@ -823,18 +837,46 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number const host = url.hostname.toLowerCase(); const path = url.pathname.toLowerCase(); if (host === location.hostname.toLowerCase() && (path === "/" || path === "/search" || path.startsWith("/search/"))) return true; - if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry)/.test(path)) return true; - if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news)/.test(path)) return true; - if (host.endsWith("duckduckgo.com") && path === "/") return true; + if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry|search|aclk|imgres)/.test(path)) return true; + if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news|aclick)/.test(path)) return true; + if (host.endsWith("bing.com") && path.startsWith("/ck/")) return true; + if (host.endsWith("duckduckgo.com") && (path === "/" || path.startsWith("/settings") || path.startsWith("/y.js"))) return true; return false; } function resultContainer(anchor, title) { + const root = anchor && anchor.closest("li.b_algo, li.b_ad, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result"); + if (root) return root; let node = anchor; for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) { + if (node.matches && node.matches("nav, header, footer, form, aside")) return undefined; + if (node.matches && node.matches("main, #search, #b_results, #rso")) break; const text = compact(node.textContent || ""); if (text.length > title.length + 40) return node; } - return anchor.closest("li.b_algo, article, div.g, div[data-sokoban-container], div[data-testid='result'], .result, li, div"); + return undefined; + } + function isLikelyOrganicResult(anchor, container) { + if (!anchor || !container) return false; + if (anchor.closest("nav, header, footer, form, aside")) return false; + if (anchor.closest("li.b_algo, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result")) return true; + const heading = anchor.closest("h1, h2, h3, [role='heading']"); + const searchRegion = anchor.closest("main, #search, #b_results, #rso"); + return Boolean(heading && searchRegion); + } + function isAdOrUtilityResult(anchor, container) { + let node = anchor; + for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) { + const attributes = [ + node.getAttribute && node.getAttribute("aria-label"), + node.getAttribute && node.getAttribute("data-text-ad"), + node.getAttribute && node.getAttribute("data-testid"), + node.className + ].filter(Boolean).join(" "); + if (/\\b(?:ad|ads|sponsored|promo|shopping)\\b|广告|赞助|推廣/i.test(attributes)) return true; + } + const firstText = compact(container && container.textContent).slice(0, 180); + if (/^(?:ad|ads|sponsored|promo|shopping)\\b|^(?:广告|赞助|推廣)/i.test(firstText)) return true; + return false; } function cleanSnippetCandidate(value, title, url) { const host = (() => { try { return new URL(url).hostname.replace(/^www\\./, ""); } catch (_) { return ""; } })(); @@ -881,6 +923,7 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number const title = titleFor(anchor, titleOverride); if (!title || title.length < 2) return; const container = resultContainer(anchor, title); + if (!isLikelyOrganicResult(anchor, container) || isAdOrUtilityResult(anchor, container)) return; seen.add(url); results.push({ snippet: snippetFor(anchor, container, title, url), @@ -908,19 +951,22 @@ function searchResultExtractionScript(engine: BrowserSearchEngine, count: number collect(".result__a"); collect("article h2 a[href]"); } + function collectGenericHeadings() { + collect("main h1 a[href], main h2 a[href], main h3 a[href], #search h3, #b_results h2 a[href], #rso h3, article h2 a[href], article h3 a[href]"); + } if (engine === "google") collectGoogle(); if (engine === "bing") collectBing(); if (engine === "duckduckgo") collectDuckDuckGo(); - collect("main a[href], #search a[href], #b_results a[href], article a[href], a[href]"); + collectGenericHeadings(); return { results, title: document.title || "" }; function detectBlocked() { const text = compact(document.body && document.body.innerText).toLowerCase(); if (!text) return ""; - if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha")) { + if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha") || text.includes("验证码") || text.includes("人机验证")) { return "Search engine returned an anti-bot or CAPTCHA page."; } - if (text.includes("before you continue to google") || text.includes("consent.google")) { + if (text.includes("before you continue to google") || text.includes("consent.google") || text.includes("cookie consent")) { return "Google returned a consent page instead of search results."; } return ""; @@ -996,11 +1042,16 @@ function formatSearchResponse( `${index + 1}. ${result.title}`, `URL: ${result.url}`, result.snippet ? `Snippet: ${result.snippet}` : "", - result.content ? `Extracted content: ${result.content}` : "" + result.content ? `Extracted content: ${result.content}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" ].filter(Boolean).join("\n")) ].filter(Boolean).join("\n\n"); } +function appendSearchResultDiagnostic(existing: string[] | undefined, message: string): string[] { + return uniqueStrings([...(existing ?? []), message]); +} + function uniqueSearchResults(results: BrowserSearchResult[]): BrowserSearchResult[] { const seen = new Set(); const unique: BrowserSearchResult[] = []; diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 53afe4c..a639fbc 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -1,7 +1,6 @@ import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron"; import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import net from "node:net"; import path from "node:path"; import { deflateSync, inflateSync } from "node:zlib"; import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; @@ -133,7 +132,6 @@ ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: st }); ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => { const config = await loadAppConfig(); - await ensureBuiltInBrowserProxyReady(config); await builtInBrowserService.open(config); }); ipcMain.handle(IPC_CHANNELS.appCloseTray, () => { @@ -361,157 +359,6 @@ function logProfileApplyResult(result: ProfileApplyResult): void { } } -type ProxyConnectProbeResult = { - detail?: string; - ok: boolean; -}; - -const browserProxyConnectProbeTarget = "claude.ai:443"; -const proxyConnectProbeTimeoutMs = 3000; - -async function ensureBuiltInBrowserProxyReady(config: AppConfig): Promise { - if (!config.proxy.enabled) { - return; - } - - let proxyStatus = proxyService.getStatus(); - if (proxyStatus.state === "running" && proxyStatus.endpoint) { - const probe = await probeProxyConnect(proxyStatus.endpoint); - if (probe.ok) { - return; - } - console.warn(`[browser] Proxy CONNECT probe failed at ${proxyStatus.endpoint}; restarting proxy: ${probe.detail || "unknown error"}`); - } - - const status = await gatewayService.start(config); - if (status.state === "error") { - throw new Error(status.lastError || "Failed to start proxy mode."); - } - - proxyStatus = proxyService.getStatus(); - if (proxyStatus.state !== "running" || !proxyStatus.endpoint) { - throw new Error(proxyStatus.lastError || "Proxy mode is not running."); - } - - const probe = await probeProxyConnect(proxyStatus.endpoint); - if (probe.ok) { - return; - } - - console.warn( - `[browser] Shared proxy endpoint ${proxyStatus.endpoint} still does not accept CONNECT after restart; starting dedicated proxy endpoint: ${probe.detail || "unknown error"}` - ); - const dedicatedProxyConfig = await createBuiltInBrowserProxyConfig(config); - const dedicatedProxyStatus = await proxyService.start(dedicatedProxyConfig); - if (dedicatedProxyStatus.state !== "running" || !dedicatedProxyStatus.endpoint) { - throw new Error(dedicatedProxyStatus.lastError || "Failed to start the dedicated proxy endpoint for the built-in browser."); - } - - const dedicatedProbe = await probeProxyConnect(dedicatedProxyStatus.endpoint); - if (!dedicatedProbe.ok) { - const detail = dedicatedProbe.detail ? `: ${dedicatedProbe.detail}` : ""; - throw new Error(`Proxy mode is running at ${dedicatedProxyStatus.endpoint}, but HTTPS CONNECT is not available${detail}.`); - } -} - -async function createBuiltInBrowserProxyConfig(config: AppConfig): Promise { - return { - ...config, - proxy: { - ...config.proxy, - host: "127.0.0.1", - port: await findAvailableLoopbackPort(), - systemProxy: false - } - }; -} - -function findAvailableLoopbackPort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - server.close((error) => { - if (error) { - reject(error); - return; - } - if (!address || typeof address === "string") { - reject(new Error("Failed to allocate a local proxy port.")); - return; - } - resolve(address.port); - }); - }); - }); -} - -function probeProxyConnect(endpoint: string): Promise { - return new Promise((resolve) => { - let parsed: URL; - try { - parsed = new URL(endpoint); - } catch { - resolve({ detail: `Invalid proxy endpoint: ${endpoint}`, ok: false }); - return; - } - - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - resolve({ detail: `Invalid proxy port in endpoint: ${endpoint}`, ok: false }); - return; - } - - const host = parsed.hostname.replace(/^\[|\]$/g, ""); - const socket = net.connect({ host, port }); - let response = ""; - let settled = false; - - const finish = (result: ProxyConnectProbeResult) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(result); - }; - const parseResponse = (): ProxyConnectProbeResult | undefined => { - const firstLine = response.split(/\r?\n/, 1)[0]?.trim(); - if (!firstLine) { - return undefined; - } - if (/^HTTP\/1\.[01]\s+200\b/i.test(firstLine)) { - return { ok: true }; - } - if (/^HTTP\/1\.[01]\s+\d{3}\b/i.test(firstLine)) { - return { detail: firstLine, ok: false }; - } - return { detail: `Unexpected response: ${firstLine}`, ok: false }; - }; - - socket.setTimeout(proxyConnectProbeTimeoutMs, () => { - finish({ detail: `Timed out after ${proxyConnectProbeTimeoutMs}ms`, ok: false }); - }); - socket.once("error", (error) => { - finish({ detail: formatError(error), ok: false }); - }); - socket.once("connect", () => { - socket.write(`CONNECT ${browserProxyConnectProbeTarget} HTTP/1.1\r\nHost: ${browserProxyConnectProbeTarget}\r\n\r\n`); - }); - socket.on("data", (chunk) => { - response += chunk.toString("latin1"); - const result = parseResponse(); - if (result) { - finish(result); - } - }); - socket.once("close", () => { - finish(parseResponse() ?? { detail: "Connection closed without a CONNECT response", ok: false }); - }); - }); -} - async function exportAppData(window: BrowserWindow | null): Promise { const exportedAt = new Date().toISOString(); const result = window diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index f71892a..9f3ab9a 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -11,6 +11,7 @@ import { syncLaunchAtLogin } from "./launch-at-login"; import { proxyService } from "@ccr/core/proxy/service"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; +import { browserAutomationMcpService } from "./browser-automation-mcp"; import { browserWebSearchMcpService } from "./electron-web-search-mcp"; import windowsManager from "./windows"; @@ -29,6 +30,7 @@ if (!gotTheLock) { } function startPrimaryInstance(): void { + gatewayService.setBrowserAutomationMcpIntegration(browserAutomationMcpService); gatewayService.setBrowserWebSearchMcpIntegration(browserWebSearchMcpService); deepLinkService.register(); deepLinkService.handleArgv(process.argv); diff --git a/packages/ui/src/pages/browser/main.tsx b/packages/ui/src/pages/browser/main.tsx index 578481e..11b5e90 100644 --- a/packages/ui/src/pages/browser/main.tsx +++ b/packages/ui/src/pages/browser/main.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { createRoot } from "react-dom/client"; -import { ArrowLeft, ArrowRight, Globe2, LoaderCircle, Plus, RotateCw, Search, X } from "lucide-react"; -import type { BuiltInBrowserState } from "@ccr/core/contracts/app"; +import { ArrowLeft, ArrowRight, Check, KeyRound, LoaderCircle, Plus, RotateCw, Search, UserRound, X } from "lucide-react"; +import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app"; declare global { interface Window { @@ -9,11 +9,14 @@ declare global { back: (tabId?: string) => Promise; closeTab: (tabId: string) => Promise; forward: (tabId?: string) => Promise; + getChromeLoginImport: (id: string) => Promise; getState: () => Promise; navigate: (url: string, tabId?: string) => Promise; newTab: (url?: string) => Promise; reload: (tabId?: string) => Promise; + resolveAutomationHandoff: (status: "completed" | "dismissed") => Promise; selectTab: (tabId: string) => Promise; + startChromeLoginImport: (request: ChromeLoginImportRequest) => Promise; onStateChanged: (callback: (state: BuiltInBrowserState) => void) => () => void; }; } @@ -29,10 +32,12 @@ function BrowserChrome() { const [state, setState] = useState(emptyState); const [addressDraft, setAddressDraft] = useState(""); const [homeDraft, setHomeDraft] = useState(""); + const [chromeImportJob, setChromeImportJob] = useState(); const activeTab = useMemo( () => state.tabs.find((tab) => tab.id === state.activeTabId), [state.activeTabId, state.tabs] ); + const handoff = state.automationHandoff; const homeVisible = activeTab?.url === browserHomeUrl; useEffect(() => { @@ -57,6 +62,25 @@ function BrowserChrome() { setHomeDraft(""); }, [activeTab?.id]); + useEffect(() => { + if (!chromeImportJob || chromeImportJob.status !== "pending") { + return; + } + const interval = window.setInterval(() => { + void window.ccrBrowser?.getChromeLoginImport(chromeImportJob.id).then((job) => { + if (!job) { + setChromeImportJob(undefined); + return; + } + setChromeImportJob(job); + if (job.status === "completed" && activeTab?.id && activeTabMatchesImport(activeTab.url, job.domains)) { + void run(window.ccrBrowser?.reload(activeTab.id)); + } + }); + }, 2000); + return () => window.clearInterval(interval); + }, [activeTab?.id, activeTab?.url, chromeImportJob]); + async function run(action: Promise | undefined): Promise { if (!action) { return; @@ -79,8 +103,53 @@ function BrowserChrome() { void run(window.ccrBrowser?.navigate(url, activeTab?.id)); } + async function startChromeLoginImport() { + const defaultDomain = activeTabDomain(activeTab?.url); + const rawDomains = window.prompt("Chrome domain(s) to import. Separate multiple domains with commas.", defaultDomain); + if (!rawDomains) { + return; + } + const domains = parseImportDomains(rawDomains); + if (domains.length === 0) { + return; + } + const job = await window.ccrBrowser?.startChromeLoginImport({ domains, openConfirmationPage: true }); + if (!job) { + return; + } + setChromeImportJob(job); + } + + function chromeImportTitle(): string { + if (!chromeImportJob) { + return "Import login from Chrome"; + } + if (chromeImportJob.status === "completed") { + return `Chrome import complete: ${chromeImportJob.result?.imported ?? 0} imported, ${chromeImportJob.result?.skipped ?? 0} skipped`; + } + if (chromeImportJob.status === "expired") { + return "Chrome import expired"; + } + if (chromeImportJob.status === "failed") { + return "Chrome import failed"; + } + return "Chrome import pending. Confirm it in the browser window."; + } + + async function handleChromeImportButton() { + if (chromeImportJob?.status === "pending") { + try { + await navigator.clipboard.writeText(chromeImportJob.confirmUrl); + } catch { + window.prompt("Open this Chrome import confirmation URL.", chromeImportJob.confirmUrl); + } + return; + } + await startChromeLoginImport(); + } + return ( -
    +
    @@ -149,8 +218,45 @@ function BrowserChrome() { spellCheck={false} value={addressDraft} /> + + {handoff ? ( +
    +
    + + {handoff.message} + {handoff.reason ? {handoff.reason} : null} +
    +
    + + +
    +
    + ) : null} + {homeVisible ? (
    @@ -189,6 +295,42 @@ function BrowserChrome() { const root = createRoot(document.getElementById("root") as HTMLElement); root.render(); +function activeTabDomain(url: string | undefined): string { + if (!url || url === browserHomeUrl) { + return ""; + } + try { + return new URL(url).hostname; + } catch { + return ""; + } +} + +function activeTabMatchesImport(url: string | undefined, domains: string[]): boolean { + const host = activeTabDomain(url).toLowerCase(); + return Boolean(host && domains.some((domain) => host === domain || host.endsWith(`.${domain}`))); +} + +function parseImportDomains(value: string): string[] { + return [...new Set(value + .split(/[,\n]/) + .map((item) => normalizeImportDomain(item)) + .filter((item): item is string => Boolean(item)))]; +} + +function normalizeImportDomain(value: string): string | undefined { + const raw = value.trim().toLowerCase(); + if (!raw) { + return undefined; + } + try { + return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname; + } catch { + const domain = raw.replace(/^\*\./, "").split("/")[0]; + return domain && !domain.includes(" ") ? domain : undefined; + } +} + const style = document.createElement("style"); style.textContent = ` :root { @@ -231,6 +373,10 @@ style.textContent = ` width: 100%; } + .browser-shell.has-handoff { + grid-template-rows: 38px 44px 44px minmax(0, 1fr); + } + .tabs-row { -webkit-app-region: drag; align-items: end; @@ -325,7 +471,7 @@ style.textContent = ` border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent); display: grid; gap: 4px; - grid-template-columns: 32px 32px 32px minmax(0, 1fr); + grid-template-columns: 32px 32px 32px minmax(0, 1fr) 32px; padding: 6px 10px; } @@ -339,6 +485,11 @@ style.textContent = ` opacity: 0.4; } + .icon-button.active-import { + background: color-mix(in srgb, #0f766e 16%, transparent); + color: color-mix(in srgb, #0f766e 82%, CanvasText); + } + input { background: color-mix(in srgb, CanvasText 4%, Canvas); border: 1px solid color-mix(in srgb, CanvasText 12%, transparent); @@ -355,6 +506,79 @@ style.textContent = ` border-color: color-mix(in srgb, #2563eb 70%, CanvasText 30%); } + .automation-handoff { + -webkit-app-region: drag; + align-items: center; + background: color-mix(in srgb, #f59e0b 16%, Canvas); + border-bottom: 1px solid color-mix(in srgb, #92400e 24%, transparent); + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + padding: 6px 10px; + } + + .handoff-copy { + align-items: center; + color: color-mix(in srgb, #78350f 76%, CanvasText); + display: flex; + gap: 8px; + min-width: 0; + } + + .handoff-message, + .handoff-reason { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .handoff-message { + font-size: 13px; + font-weight: 700; + } + + .handoff-reason { + color: color-mix(in srgb, CanvasText 58%, transparent); + font-size: 12px; + } + + .handoff-actions { + -webkit-app-region: no-drag; + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 6px; + } + + .handoff-button { + align-items: center; + background: color-mix(in srgb, CanvasText 5%, Canvas); + border: 1px solid color-mix(in srgb, CanvasText 12%, transparent); + border-radius: 7px; + display: inline-flex; + gap: 5px; + height: 30px; + justify-content: center; + min-width: 0; + padding: 0 9px; + white-space: nowrap; + } + + .handoff-button:hover { + background: color-mix(in srgb, CanvasText 9%, Canvas); + } + + .handoff-button.primary { + background: #166534; + border-color: #166534; + color: white; + } + + .handoff-button.primary:hover { + background: #14532d; + } + .home-page { align-items: flex-start; background: @@ -487,6 +711,24 @@ style.textContent = ` .installed-apps { grid-template-columns: 1fr; } + + .automation-handoff { + gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; + } + + .handoff-reason { + display: none; + } + + .handoff-button span { + display: none; + } + + .handoff-button { + padding: 0 8px; + width: 32px; + } } @keyframes spin { diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 0b12d40..2b6657f 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -4,6 +4,7 @@ import { botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Field, formatAppError, formatProviderAccountMeterValue, formatSystemOption, Gauge, + Globe, createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, createRouteModelOptions, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle, mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeToolHubConfig, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel, PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl, @@ -512,6 +513,13 @@ function ToolHubSettingsPage({
    {toolHub.enabled ? ( <> + onChange({ browserAutomation })} + />
    {t("MCP servers")}
    -
    {String(toolHub.mcpServers.length)}
    ## Embeddable Button Component diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index 703ad8e..ac471c7 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -78,6 +78,14 @@ lead: 快速添加常见模型供应商,确认无误后即可保存,减少 TeamoRouterAnthropic / Chat / Responses + + + code0.aiAnthropic / Chat / Responses + + + + claudeapiAnthropic Messages +
    ## 嵌入式按钮组件 diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 215d8c2..094438b 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1318,6 +1318,18 @@ h1 { --provider-brand-3: #f4f4f5; } +.doc-markdown a.provider-import-button.provider-code0 { + --provider-brand: #101214; + --provider-brand-2: #267dff; + --provider-brand-3: #d8e8ff; +} + +.doc-markdown a.provider-import-button.provider-claudeapi { + --provider-brand: #0d1b2a; + --provider-brand-2: #2f8f83; + --provider-brand-3: #d7fff8; +} + .doc-markdown a.provider-import-button.provider-deepseek { --provider-brand: #173aa8; --provider-brand-2: #4e69ff; diff --git a/packages/core/src/providers/presets/claudeapi/index.ts b/packages/core/src/providers/presets/claudeapi/index.ts new file mode 100644 index 0000000..85a7b65 --- /dev/null +++ b/packages/core/src/providers/presets/claudeapi/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const claudeApiProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["claudeapi", "claudeapi.com", "www.claudeapi.com"], + endpoints: [ + { + baseUrl: "https://gw.claudeapi.com", + protocols: ["anthropic_messages"] + } + ], + id: "claudeapi", + name: "claudeapi", + websiteUrl: "https://www.claudeapi.com?source=claudecoderouter" +}; diff --git a/packages/core/src/providers/presets/code0/index.ts b/packages/core/src/providers/presets/code0/index.ts new file mode 100644 index 0000000..b87be18 --- /dev/null +++ b/packages/core/src/providers/presets/code0/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const code0ProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["code0", "code0.ai", "code 0"], + endpoints: [ + { + baseUrl: "https://console.code0.ai", + protocols: ["anthropic_messages", "openai_chat_completions", "openai_responses"] + } + ], + id: "code0", + name: "code0.ai", + websiteUrl: "https://code0.ai?source=claudecoderouter" +}; diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts index 760c2b1..85c1c86 100644 --- a/packages/core/src/providers/presets/index.ts +++ b/packages/core/src/providers/presets/index.ts @@ -1,5 +1,7 @@ import { anthropicProviderPreset } from "@ccr/core/providers/presets/anthropic/index"; import { bailianProviderPreset } from "@ccr/core/providers/presets/bailian/index"; +import { claudeApiProviderPreset } from "@ccr/core/providers/presets/claudeapi/index"; +import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index"; import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index"; import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index"; import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index"; @@ -42,7 +44,9 @@ export const providerPresets: ProviderPreset[] = [ bailianProviderPreset, siliconFlowProviderPreset, runApiProviderPreset, - teamoRouterProviderPreset + teamoRouterProviderPreset, + code0ProviderPreset, + claudeApiProviderPreset ]; export function getProviderPresets(): ProviderPreset[] { diff --git a/packages/ui/src/assets/provider-icons/claudeapi.png b/packages/ui/src/assets/provider-icons/claudeapi.png new file mode 100644 index 0000000000000000000000000000000000000000..776ced8c7f16c712ffe861794957f6b31bcd56ff GIT binary patch literal 17658 zcmd_SeRpkPZQ1KuWq} z=EB4-N-RoZWx~}Wq5xUwC5+ZscEG#S%HB}{jEUX7e;0Gtb z1FqoW6HWtv2;Ei9ys)sG3;zAE+X-18U}3RfsVONK!qa!>qEzoYNbrh3D^r% z*^%-`1WE;}O2}VtBb7YEMn?%WIaJf=LwhKyzp%J?;I>LWxGkAwc%pfzkG18=KUno7 zd1lX*jys{S_H4=_%S|@qZWaqmp5}g?7aQwu;sF7cyhA%17FGr!@MLc}!4I(hrv6`i zaZ1jD_uEgazAO=~W7}<;z?Ax29RhHsiv?yDTXF6TSOe$Dp_MnA4| z`nuNS_;qB;^R#D>5UZWdkn_c|%dmQ>V=r7?{6q-% z%+Rkkls7$27k1&h0|Gl~dd!?m*-X1RCu1$J#uc1|^`~H+g6OeE&pp%O z+|T8JOvx_tZ!lh@ZW-#D{6A})fAu|O(iSshke^{#++Pj|R>4 zqPR*yRBhE~4a`!2&Hj%0x|JfG4fRBo1m5Ws(+HESh}KA%ghgLc$)klU&zqdHM_AGM zrLxmOBy^F+DLMI$R==&8z2P*2%jM-0QGVAG=Pq%uuWPgKZRZU^+42#_AciqWw?<$R z<32fUIDa*lP#mKnuiy!c+pY;eIck zIAdwmyt!(1y3U=5x-44Vd23n|E|hnNeGTRX{rWT=FYRHw5h6?w%Ydv&s*1T*e`n{+ zJ?by0bDMnaSz|#M_dJ2Xsz3%go7!$CE!(Ys_SO&-T2v_8PB6Vtb!v0RD%Phx|Qymlo3oxbooz@p?Rbn4br@31FYVYDPhLg zES3t+^qf~?^nF0-fBDSqY}8IQ7OM@gxk7SXDvi;cO22&kSS~~uFf{2QZ6ScmSto2F zrB$kSNG}$N-J6;6Oq}7a$}I%Hm?{$v_AN$I_fnqbR(CS00!w(c>8;BfiOwnd6PE~o zsrKbi1cb%z>NEF?o$s=u8`6Zx8FK4uWeEhZ_Ith2r(N+VnH7FK!`c!oxl4P_j=u*u z1W4VQ^yJbs({X9_Mekn&->2aqVTKIlj3-&o@X0};qh<}9(&h{{R#$r*J(Z`}z`&Xl zE^~vVlanl})0$wPdfM6k6TGGZWyA9NgsP+yR+N7pz9$i!(hZvZd_b_Wn8=G=6nQAE z{VAIscz0Nhg(daLo*}c^fWKe})v|Bq)t)Uxx-auCezgS8=`ACqzHP-udolH|Y7DR# z1DPNBebga2<9JvcGpfBx@hAEE+kp#LQh92m6Wa|Ea0=yLm?mP{S~Qh^qEzddzdxq~ zCeTD54s)m~0t7wdmI;lYCC;jV#Syh1;d2tV^-u(}j zWo{0=MvmDjO20LSys}ZSs@1Ku>GHOgf6u`Y&Nl|Z11t%-}5PvE;4HviNXC^=CIWk&Ar7#p*x;D_d zEjIia`nKcAu^*8Ka4g04Y*LzsXYNd$f88g|q{F!Ig123M44%U3g_aMo05|-R@);8O zpJf$tk{XJu(>&nZiuDn&ebPKxJM#v6E?~Wk=BI3#Rm#TMITtGSV1|Z8$vpW*%?&|h zwyN;(?|R8aZ_ALs3;K`{qg6uwAx{6@-^?mLsVs#50t@f&VuKf&Q(;cCLr$ zYEgl}k{s`dNGbRP*mP?Yj%;cyB-g88d6{3cBY&AJVXkL|M9s@e^-mPxn(~3&8A|u# zDZRXdZn%Gthpz+5gv@gFbqwL7vTy5oDk#V>4oOAzn9+xu9}gt5Pi^LnX%^TGN&M-p z0jGUwgxe#mP)X~Ocp9fez6D&jQ{2Q%Jy)|pH;$>%m|%Hh{SO& ziE-)XJy3cNp+MX>TJ;Io@MNBtHRRI{Eo@ZNf{wZ#ZzbDpV;j|dIa*IRgVhgzNiU>L zx-oBaJCqKpUwxMQ@RLWi5d@ubrn&0DlE|NTQ=i*x)hDN{$|{qG|4h#JL)0RiRbn{4 zco_?U##VAK_TyPak*DSp6?-O?J$h0inROyQJS5=H^pO>O_9M)$&5|JAv4QWRS*9kU zMAN)P@`_<|>5uLKh5#u;$+%xl7%w)St8g0s!?gc38CO>i2?V|qu{fzFO?`V#5MQh~ zGIXvgX7lpHps|mK*8Bt+eI z_*9nc70tv|2AU1L$CLDuq-b|RLt`q3J81JNUfsvaW&{)RW2J(tcZ`h^bWSe)L93K` zz@Fz?(eUX;RtqHI^~;C&l@>BW7!SECM)Xh(?u|op%A+lrD6W%|@AIP*2sAMeh8eWQ z<5*l6Rg-ARnja%v@IT4oTgv`o z&2d-LXD0rqL*1E5u!oQ;56Mv!3OqsylK!M66h59`)JzfdNsoC)dx7me731cRK-KD> z_+M01AZBuA|0KxB_*wSd+S{+qqr>OjN;Y=#?zv?94!w!j9uE-cj~SEfpJS5sK8Oq# z9lB^q!L%?9?=nKsvB=^-1 zT44c7oqdfP$+*^;Nzn=bdA)pD5T=>%sI^zWHCtX6t1)d*B@U9nA>@>>0D+@m; zJom;j_hNhqxpE&XqntF$5W^gX!^6Zr|A6gk?fEVTwiK?UI|m_UqPG3`C5AuC4gPq- zIKl-E)+%ZOwQjw?Nu&1>DV8|@9smAhM~XLA<;YeZe)F}ZumN_(@=!I+enZvW7Rz4J zUI{)+sWFcH?57Z&*Nh!3M5Yg4c^$@v^SoD3xP5t{9yPkHXUJ0GVh-&7S>Mf?ZLdIN zzQ9B0F!y`*ArCHmlYVTUajz+i2lCmG=-mG_NjV=sBS80P{l#w+Gyr;#(b?>Lo;vZ^ zEf@8+zx1huRApkz4WGoC2Aue6t}gpej7Z`M6JVT?KBR5&E!Ak!%!aR*l%Q5}P`#tD zZc|NGOfRlgN8FF+J1k+#Zc|OK^v?%S?=Pu9t?sr=wSJ6`yT2A2b(%1jioB(oP;<`} z2!paB&;qlXb-im!Uth|eoXAuLS&5z~xbuBFxcG3$1kR1jm=r9mJ&BhAJ=w=hD)@6s zJW+w)v{#B+HpN;Nl1`P$e3)CGRD>=0Zwz|;X8NLQFGdu|{ zFi`xp*#q1z6#`j1N8VXgShfhg{l3ufu1--$4Hxan_VhAW)7$Jzu3y(V|Yzk*J zYnZ2GXt&}z4(m?+3?V7())gu@%- z$>)c|$z>&FvOq|0fEM-qCMMDH>9WG`1^i|SX=p_-<&3kU#=4+_LFpdG9UGMHXIT-% zCITaW@#W8>Y=YLL=bBAnBlg9!BGC85GH*<_GYMk7|2Hw4GbJyyb9m~nOf6Rh#k$j{ zSURv}RXT?vE%Sh(0?P;@C?DANU{GCJ!rE~d@_k$ga2Wv!AI9s3>Er+hCz_!d!(yjm z>TGlf~D)J%nolhrM!mLFPusC6BQ&1zP1t|Lrku8ZSzk> zma%xi_OOyciZe{Q(;YBx(wO*cJ31DJ-Z8&6jR{dh(5Yl4^33-xprf^34Sv%&>@Xc4 zZt*NG?=F}+sNoL~IS;RQgT#ax9~)gn9@KGseU~B9K`VwWRFKiqDQoc<&VQM;w5~Ds z$ubqw-Hbq&)1~*CPGeoNiV3j~4tpmJ^n2Mtl2r~5$T5e`No8~+Ife3Tlve7ux7@Dd z%RuOuGZ@eGGY5_f0kMPU$y-JC6a4kQ@E2g2Il=jgk7(sI%BkQyE6WZ!@D0ErWaK$_ zB`Lq-gKu8>Jt)tnK~eM)5m{QIxk2YLQdA%}3a$3!p62K3$r=wUyI<-575w0)zM!D( zCMI3%Qf$N3zC1^)WJe^4s~Uuih_qamTfLK|{lGM~qXlnL?5oiavL#Z(?+!~Ys{&0d zQ`7kz%*Q&BvV!MzPUDMmtwQ+(b62eVsx7o=fCzIeez6-^I{`|khe4GBal?6xEFaJa z)3W>=A821hRA)Beb}yBo>S}o18=o807xPYddYLAYN;bc_2wx6#(Ox|T%f9-5?K3&o znmteB=L56Z819baE{5JQ@V7bI<36AgJJ{#BdD*Yq0_sum!$fw7`w1|T`oZbocr+HXrFX4SQ4B*?3g*9+>H$3SL_F z0+B5D1nLFx8qNDwx)Jd#xe%YW^9rjzon23M3g!Y?Cl~<1=;?CoG#ZwOAAUP%djopW zNaO=c{fa6AVu1XU?GW~dI#h*siH1R%ej|Mmi>iMYx^00FVk3n8u>cS`e!(uwJxE!} zPn~2xmI_U7KRFQ>;=vqZT`{Zml@vX&o&9aN^wBP#E#^)|p9Ku!P9p9DT7G`m9afC& zF#;{KrzR6NQRk18c?P)!v+ck3nzF;t;cOhKs<5-yM=d=Ytala z?1&+-c0$phJu%YfVFEk&jL$Vwj^;GKd+yR-kXe{G(Y+(>CgsJtiAgUlD1i%13K&wx zli_P+I#8!zIP{?^2F2`V$W(G%B?w-Thcl|46j?mETk4nL)qOnHi`y^AK%Zq+&5w|^^FB9_3Ghz{)}x%xmM%f7Oyn%I_J zf;0%X;`;PuP)O8_3IrkVrRZVuQyH_0b&q~Mt}$WI=O10Xe2ltpj{XDO4yZxNOf6@8 zXy2n85RG}2h`w7#QGUJd^|vjNY+x$dLpRX$_2_^`0S%W0k8kwe%3^ zc)~dLHx*D~VL1=`oj*Cn&{RGH14fL;0}2)r3NDUT5(%YQ3THB1Z@wfur$~0NHhcJ| zM{&8fg$8dtEsGhvKBpB!&17HZ=i9a~BXCtIg}ccQ3mYyZTNTqebGA%leFyUM-?lIR z{#P{0BO=LfiF~|a$kh9H;OI8{R&r> zb)H-W;1p4Us95C_Qr2;ZDF(~#oquU5F%-vMUfb1J$VAoW>psK?vOZvsaQyh`{E4g0 zA9+{wTT-!Y!IwC(;aA6W7Sdr`*HfOW*zdLokP#FtYJJ<84)SZpL4iVxSwWOC`6~{> zjfd^$vGjyULQ0_6P4Hs{S-nd-oe-GRuac~zPOOmvfwonC7ifO%%|f>xbaJn3ZK_SC z@}AEeB6@yE9wPSsl9mnL=bG!^4sEyZ9gD~zR#VDi3-m0OCH?tYWlT`O4QdcQ6=PUV z0UBB7gHaA^yo;7a^}cLHg@-xVRq?Flo;|{z0RnH>!|qw1V*Shl86#4!B3aU*WA0BY zZTYly?+;QU2;u~QQ9L;MOBZ)|9GKW9?)9lfNn_m6Sq>Uf&4ahc3ASgyaooA^OqT=&FX5Vy8U8~> z=`eIY?PSJ*>AN?3ck|jX&XbdOyf#~7==aH`P{)M`A;&i}L74!Oc|0wEyQ2Z;Q|fyf z@S2T;%E|ZD{;c>Djqp4&67d-i|AD;lOakP?Df(4|-AyLVz0*v#K>hh2f6?C#6n*l% z>iP*l>-vghEtfZ!RokZDfa7$q>Yd@W_bW=559^&}Y2@Qkmmf{ZW$m5RT-2LjBhK;v^77Op zi;c*L>0?!q82tqa6*=fx&Ch5TaWUwHu)NRBdHR#*_UdrPF8jq=CpJwb_>b9f*PYs* z){j&U-!`Yn)!|4FZ9yH9e&C}dtty!0?~8t&<4=);rud+>IsXMeH%zCfc6jAWmzSV_ z>M@ZCF+vART9H#e1s^4w;`uMZU@8aO{(`ON-L1cik&S2Iy`Xf~keq1(LQ2qOTH%Y9 zPyHgram1TV9TeeHr?OpTM`*&PFS9|>!ox-ktb*-qwUJ~y4nU2KHa246-Jn^!e2&`d z&cm=ceoVad&t4^<=tnO_IFBK-@221AeqC$L^u-*kUi~mdorM4iR(+fhIv0R={d5ei zErU$@LH2!`Ae9`nsRUeajy)9f?a4bIQc0aF5_@5ffcShT>UlS?C)NHuXC87J+oc>`$!Btd8Nd@{p8$2HJiS5CpkUOqG#Qy@7dCa+_}A}fWB0(6c@^&TKWsUl z@^-|NE+|OjbEJsd`>5?G1OXr>1|-sO8s;^0 z*m5C%PA%$B+WNTX(VDX~`<3L6UUccJ`XXavgAX(yHSIw8TBDfc7U?*IQ*n&BMsM1y zi>G&6)T|A4bi8t3E-E;{34GF3tc1$O3~q?u4(5qUkCnpR>3Zi~zBSeP9Voa0x8qW3 zXD?$D$y^V<%%Sn1BDW@pU|)nHdZHLZ&0o6I9>JRCw zM;Vl$o>+3i_w@kqY-acZSD9$!?v81-;!p+7Zcg!32lgY>swrj*k}%e_p*PDXY3`qI zE0=Hefg044Ng?MRS7Q2Iduuf2qB$6KYN!2FR82~r2uTfnrUWWdmFH@$hNAQf#kwd@ zd`wTIHFsNdPPvsa@k_st#v`C)7;Wa9X$dp8#0mnk24D}@(QndhXHzy8SVQZ@RIfa| zXJ{QUreBB-`fn+FuUklDhrMUj857S7SYuA1(sYa(VOh;fkp@>PVi;3yh5kkE>oY7IBXO;8qB)EC(H@_r zW1VtWg*}fByYP}f2l(URGoLY$*+LGla~lY511Qk+oQkXQTd6c^;deio2mm6?Tq~sc zR18n%<#Vw{#?Itl1Reg;=4IOY4PEVQh{A7-=wv5$p6k`M{AA#aJ}uI(qj)N&@(LXL zYvUZJ%S+O{%qsV7^9M+sGOR+DP!gmkOuH^(yYBA-9?I;NEmu)tLDhwS{vHH5Y44}ejz27%t~Y?`*u zR9h+>LluFY+6{EoipERTDUOYcd0)UotPDrs{*Jlr9QSK;J=j?S0x<9W*gjf;)3g#I zvx_Z);V&D02wb3{+;^JN+i6(c6s}tD40z?47)I&;(*h`e^Z-H1p4wt~$E?sK7z#>i z2G{9wadUKM^Cy8c*SaE&L1;mX>MKvQo~~-r0YO9*S$tK$-dnxIz-*I*|19lg{5Rg6 z+#cyZQYShAzgSy>*Y_v0t?{1z%}+hG+3Nrep9R-g*bP}d1;2QW=yAH;F3T&sNWEx2 zdxT4PJsE6Y&|=a2dWxQiN&^(~UjV`9F?iAvUEQzul|->I04>(Yq3L?ADLe8A|9R?8 z7!=^Xf@*v`?yx13+J>S4g{BE}rU?`*fT_5#@eX}}Bgl%s z;|Iv%GY|Ih0`mu}p9wyvxjVj-l=q#`bLnf4GlSzwO%Z(wx&GV8VJNFtgnRL)G2c*5f4+voXRLpO?Kfi_jko5V9?=goV3o2Nr_V<2V!s8$)R|~LP>`u-!$6fucZZ%># zvw0JigXUu;y{1IB$J^Ot0}^9veWW{WZ|glWXv=Z&os)BCK5iV-^G5q#{yQW&wg_>n zA__vfq#NiS)RqL-$JW)yVpT~J9U?EL4H=A@!p9sJ-P1YWGVbCGNPhfi=_)&+zV~_- z&~OAyzucN54WCi?+nx!)}%K$cWov+`LAoW-s}^h`})&`OD0zDnG%k)voxpVY+nwrerf3+%p*)(v?V}r zr!`mf)S>9MTaseVvgFR*cVo$RDd445T&U@Sqe+CRx05*P`>p@uGDy%4GBppX4T-L2 ztaZzpjFH7$^Jc^>IM(n+%y3!7Tvb_7B|<7|el|4)wN!4gieA13EMVa@c-ePlDQO|1 zq%>`(ps>rfm31+R=i&JW-LZi{3pcIDb(2>m?@vz@V6IG>7-|d5)d}Ykb?)(-+ zO#%8`de3KpG?DqKp|OWoi{1QYe`~ARAuN!TX~%P$Y!cBfR}*G46LkTx-!FoAfWUG|o?pvbzCX7@AOu50_9=g$8Ax}$F2I%TR-zOP%& za!ZQ8zY4ew2cXeZL)=a+`9KU0$mi%bh}9J!0FN2(WfnP_r|F-BWUGrbn~qypg6R5`N2@^AkiC(35+6iP|tP%HXAVlF|T=0Q%L!^U75X~^&p z?!>i6U^Adh@IG!Gd;nDXexxekTEt7Af!*O4FpxP*^XvQi-Z`Qb-$WcwWIG$7SqQF7 zCWsQKX^HL%Jxup0oSkco=fp-rX)dy)+W-$w)sZeo@Fh+x5h6wLgq6ZIXGYsHCG!uP@AQ@#xRRKpM`H8fMZ=Ao=V9RWF~EyhyZz^DU8voDVF20DuOJlk*5ko4EUzb+u4#360}mR_ zGHz&JAH7IH+!cY1-#mK`+O|Z%{%5g!0jNY{rbcqkY)Ctl16QSc`aPOlY=q?DVEl7J zfQNp>`TPaN^ta=s>~~ZQvv;7#;MWpSfSw_ z_=L5qWBF~F8=r^VMbP_MLS*Wvw2cTmN20fM*@q=W0L$%GvUOj!kv7kd1o;xj>ex71 zyKDB2*+-bSvgZNk`6tcMXbO5GIv%Zn&oTgPyI$K|esOuPJ2V)~@EzHJAYrhrJQ56B zsV`>FBL`7YKmH)@PscNAWJtfy2xsa=IXwQ5^N(K|@f!_;j+com=o=-Z0|$7Nh!*Bw z>@Gi=$ zi@Jv4==A+uiF(WBKAVJ3vFsG1K~o7qfj6&a_D_G#c}7#i?&P5sI+|0qFI3(lx8i#q^x@zYD9c*S>#x2$)n zOxzDmG91s+!w&GDQv`~bGa|?bX30899KU@nvd5$<1q9~_lv@jp zv5!N0@P!soQio9U{?Y?em9Ez}WX%*HvfT3c7cQNc*2-v*=zM2WkoesADf7U(=!!yP*+e$+Js_DXt(IpvMWFdiQAb*FgNj1 zz5lDj4F3x&zppx*-VU;5H!)Wjrigg^6uifpefQr+uiY%zXByYbfE7YqBR=w3R&fPq ztSc>eikK46d(jw+(hk|p&YVCz1u!1?C}Q8Wj`XmmDBk5Z4U>l;!HbS;?4x%I8)>WC zTtoL83W=!Z5#}CC01r+-lUFzcSp_9CJ#((F`xo4j)^QFl2}84D1egJ6yQ*szlJ8)a zR!)FqqRmiXokx>=8(f_nM$+E+WHH$5$Yjm*PH3mIK5VY8jg!M1V*AJ>)VCGm;Tw;8 z9^RI*MfwL$HpTV}XDtoh>gsA>`8Cc@n-V$BonFpKJe=R%YN03@4A5A6Lz zTmwZBjyGA`!CaX5(Lh-8b12X0o9M+!euHwF<74$}hQ|4wE{W%hcCdNpCC)&`$7-rm z%NIKrqsDzuW#j|XnwAa#DpiC^5nj}JFLe|KDT(qMEF4S}rZ+}g#NTdjw5!~+T{c7u zANzi_k$=f&zM$o9%lr9ZR}ArBKB&EUB6vIS8U>E!*p|D`*ya3MsInEp&b6CxwhzGU z&=ck9ybM+h#qe{#SFk^lX$=BoLCmq71QX>mVqvBIdRNRnM%TjEGyljI0U^1h(tQAj zx1`)eeZx-0Cq!)W;*vzNb}c%J$N4L zoC{k33)ft?-Kx1bIo#SG4C@QY)!eu7ipgG%7&?a6lC`r1)XgJ0&&qj1HaaJxy?RG$ z{ogEJWwd#+i#y2G^zNWhh}~;7p6QR-Cfl7BpERQn;I)s&Xr&sWvy!7Mx^$FQcV>l7 zGw@_e3%EiCf}0M7eW}am&Jz==S&z7l)%YB^epQ~z0Kiy5q{FO7ZNL5r=X3j0g(r;+ z)?5er`e(O6WDf`}JN`XexEwD(|maO;9yTb*0 zIkEeI-F0B(OB1PxPPsv|+SBSQitlge!v8Gea5AFXM9SQ7JD+MRxWDYv-kWor%Qc(3 zKC_og(F!qK;=N)eVPKB`v)pk)p6F7AI~R)@B^&pXTh`_3lxAI5Scx2*$tUMa+G9!+q^^20d<_m_AdwlbY)dQ@gP-=yyk; znL4$WO+RUN+41!UMXs*v{FL*c@9?T9zruHmbiZ4`wY~<_Yu=v6%=9eUd1c>;{PsIo z8Uf)Ibz*nUhOqx@wE$fc3Q{@P{m-*FI0F<;>hmw2n_4APySTaDcRkzdS|9tME|TaJ zMU38~yQKgHP$qtIFt^qT)b*{I!rY+yTa)TS?FcAHr?-ZCkLKqJ_hgdX&D$}D%zhJE@zItX;Aa^dXVl>=@c=bhIk_Oa026?&Y1TuawbW=8=LCQSCM8SY-y*99y# zHVn(zvP+HzG5JS1lgilLKeEvWDRBhYX&hC%aegxk;%pxV`x6dpo3ol!Ba78 zT?XmJoTqE^pY3WmWFd#+{lYQhc$09N+qgZW0CEtkfyY3Jfzs7!d71cU;&Atc2ETkk zc}B6a93n@>{@6Q#5|c+aQwy}>A#EsY6KP{seYg4x9%ErwFX|E^-725XwFSUvz*k+H zfZ{J&iT2`~lgKB_fN!1~6Ee-a%qps~k$)bImd(An;PDUJ1cbVpN+HW1P`dAVJRI(i zFX}JVHHGTD%@}yAi(X_FzmV00BZz-(~Xv41e52!`py$WA!jp()|BieF8EjO zI=zrndcZL}xs6%(L6ep*4l(WGpfi>~0@J=~c`svFXgPPO1FrUZ1RO&-B9x9#0YWp?!X*(s-uux9 z>H1K0(I?Hov3TYUC%7FK7OmFhy(2W=%X+_nJ8K>UYiVj_77j)}4_|P46xYrc-QAV4 za)R1PcXnGG;TH!(XkTy{0c0G7N~O0=bd2EUMviC7_OSQy-;K-|iS~orH|0b}qj=r; z&#pMgL9cPcpp{pJ;I%-`vz(7A=P=0{90t&d0m);a2fQcD?Dr@^P8lFVL@p+KEkLQ( z&t*j#(4?@9vI`CvPy&xH22)_18j>})FUO1}bgWF$@Fs&4$)08{|&@-~oD=LvDM zWeuXC8$$A7Id|rJ_ zsq>uzlnoh@wufrs`EU8oa=+ee7y(`aa0^?+Fu3l_cChiZ*e%D((Rr!?^@et!2Fuk;{++@Bm4#(*bwVWae8T}O)Q%525famVq5wzW zbm}kche3_~NsuxEp*p*{y&IXO=XVfXRp|;$aJj~22sH?U1+1js+?hKTWYlNsW-Ttv zVLrui0QRDzav{&P&T%3~gsdo_?L02oPR__KM&$8X)>`ep1Q2+9DZi%e4zox=XrhP< z$$=k<7{d7+Hyftx-VUlnA6$A=nBdx7@SI;F+u3$l>?Ht|hna!j&f2A{cpX8U%EhnS z%K4!Gz%L*}1;cpA^)j=XvT!L1&a1mJqDHHLxkVcU5{u9`ApLmH8ABm8Nfc`@vF5Uo zw>q3u#=IXO>xQe!7P#mtttF-!aX>)!{HBq69j7&QxE)%xy?DT2MG)s+kio@}3+Pt@ zl-!#>F?p`IZMd7fT!2=sFCNHs692Dxx#vF=(fwoT;+qR!I)1i3V$LfbjqL!Kjh{>T zRt^>+*`Km2ZU7^|ML#8Jwr$p18a~Yl0xU|Y@CggXXUd2fK=)oZr!W)7D$_q3O{4{- zDZ06bf9hziECxER;P6ShGk513c*W-Ls^fv7qV`c?D&IY zTYp`~wMpv4Hku$#?)<8lCB&u8rW|EzKn_xH|Is!%_G_qp3zyW&Dh)vRfZk0IknRtQ zrcK)bU+k0e%x}}b9Qw%3lZYg6)sdgU0RbuIlrVmM!XmC<$}}@CU0zEVcaX?e49vtB zC1Z)8QCxua!NcH&ebk3q2@fKYn^o?Q8mwS_jy4pp!3T6ga#>o7N)(KQf`$bXLT2By z-|z%f6uW4eh*Zqt^+E#j9_DkSAZiwj8|fYo=<9*15iWWwUF$R)YOAyPdPB?Fh!UIf z4nWR$anZb5mbcFU^`S>rL6_1yuxB)DVnEW>3=OF0MY_>{6632-!~1*@vsEu8uAs9w zo#%a$lcRt?E>d>qYku&1f|JbEgWYD6bwh8#!5HmL#ZokuGb-LsfTVVLCLvYQAX@}} z{-wnue-p?`Ac%{5d#VS|H4TG~l|2p2{T6<3QCQxFnZZ?6{d}Mr|DDK_U&8oV>aQmS z{iEc;RwN$PU$NBk5@hpr;kgnNi~=@X+}`RgIqD>1=zO4^IANS2u)&~U577nu{y+6! zR?Zi4fJya(U_ghF2da+R57Ap3G^4yP8(hPSWyKz_vn#@LX#v#4vN&*6`en1B!?R-9 zGx>LjO+q9!U?o`uvFr_i5oy3-;iLx-*dM1{y?Y-(h_n@mWx5&jP57eowg|R)^)F67 z3ADmaXeUHI|B|vgc^xQOnRw;#PSl7Qe>-TFoaHqDtxZL?@GAbAQpNCXe;jomKkZr^ zL#w>YwJ54txKV?%YlOe^iduO;1*>;r_9>%;^FZmE{teNWb^jWR+xVkKfDjm~j|b1j z^(MG$X-wo8GBaQ&RI*!o!w?Y9e;yEoUT^V(`DL{woT>m7`c22sYx~%h`CI&}fD6+> zRb6q>Br;SMtttPyO!=ij*ZT&j(0dwWJSCdXM^n@akT9^{16vD$=sIOrjkaPWPGm5O z<^g*uAk!u@cQEeI+ShmEZ&h}|c#LjA+2j+-k&=OcWO${0z-cdG4)05yWRuQfUbG`n zdVG#`={oF7I%jwIiU*tkX0WY88>P=Cu#KT9sm{&n;dbw5s3^V| z@cK&63Qk}Qihb*D`ByMRw8mcZUvawdo`j;l;N|{ret^B9JZ3zVJc|2~IlND0R5AUa zZ{^>RIg04?h1uHddpfsKfYf4SAr0K6Cc38{PtB`XoSm6jn9PirfWiC%;4xIkjGK#4 z!)SH=<*rorVq|}^SzLI8@Q0mZ0CiU7U7Fq6$!tkc@=z!ljuF;!YCRVGv%ZdNMReMI z)WQW$h!pM}d#gLFlA7jZ(V(4wRVN6r8PG4t7!l(CdW!*+Iw1^5bm=k$N%7j}^l70y z)_6-zL?c&t1rD1lsM1(}%9(T3TfP3ruJH(!2hnU4o{Zy-p_L{h`hRJA1(QDk0jeYL zTW_;p5kh1|r{}CaAHzyBCdG#T768(g#MAc`2i>+iW%iIx{1O7RC|=?^%8_3ZMb$W` zdaMyyj^txnzjFa_L?vRJ+QzSCW#7q4@qQXJf8M)~R39R{Vv+oNEiuRK+|FizYW!+x zNL9Ghr4P-TE7=Iu9tU!~kan=3xSiz&#J^V&`tT+;WOHd`$oqq0wKF6A{7MBq1%u=C zUSp+u)gv>BjWxu}f7? z9}VSPX)?2)OYrWx<6zm6wW)%pTYAyweqUyK7y#@VSfV&9Gq_fyj+c3e zxiRrgKU->4)KP1hnDhm$_==O!O~8n;Mb?UGshPkUBwvku`aMa4v`i5hFEYB*Q?#+T zW~)x`@bAG+F_J-W04*f8D|y-pivv`Mw(f#o*At)hlRD88-Nn5uSFB+mu1P{=s2db) zQ(iOH5+b7&#^Nn6QwfIzyiF$+GL)Rj+M?$ngFBJ2~f{ygLMdVV^_5_+u=6Z`{ntO2)u!>hWZ?%u@uKs9Ep1dYPxaNa$#BW~~&^O>3- zJ6e1O7NRwRoarv>AuPwPSdvz|QfGeKf8~l57gI^(-YR>I7o4va?u`v5hh;YcVI(u8NalycVNmEl+} zaU`K%r|Fs*rfoi!8=M2;&k*QE>uWWn^Dq2E6v~cyC;$zR7kcAencWw6s z$P+DKpe>4k3Ts=?J;;;2r%9DFR)cQ4e=EV4djm91H^mBPyXdw z3jeuVb{ZbCGt`y%3m~+L7u!VT%K(bHW$>UJ>N$je)n(slbwe4HKMQcAErn8*kC+E! zA{Z=V#<=7ZvrFTOlt4Sso%}PevCsom>l5kWF}w6ag%GDE%wrjmKxT8+qk#8a(v7j% z3xhlPd)n}oS14PTdPJFFQGoGg>ICwJZ*H%v>#HwMOP5kp6a~2J!>%ekm&|`+W2;p5 zXWOW0&|B&)nWsu1bb5EK_K!;q^TgYhW-(IkNhFCdBcOq-&ATfoV<)SNEeK>D#{pJK z7O%y_i+@ZC*MU?ed75+Vvy8`6#_J1{%0U1{N=hjM31hQtqF}@~Sr#hG+h$U-UWqDo zvL)FiH4&^j1X4c8{?*b!wbn<1zfBt`bkzXaE4)8=>1Mp%0oV+68VE@wjoJ_z7%)lQ zo05z47uQs1WwR_KjVag-x@k@lXV5;8+Y@LX%ffaB$k6WUhu>Y0l2Ly*LTy-hk&L;+ zpl3eE0Le(VD)JpT#^Hq~k2Sr%t$zPWjb32s*=kn7HbLuparCmTpRF4Zq(>}v!puxYK#u zyo%iY25bZnBLJ0VV9)w(5^#G@2cWpG{Ftz7ZkKMC4VPDCi zk}t8emY!xq=3)lac%0x$0S4B({s6D>1VaPSJrCjrJiMpCMNE8JnNiR%_3_ob(Mi&O zmK{J!;~59oa|E@zm7AgTtGw-)mI8-Z9&T$b+8?|8+6{=6{|mcyRCY5vrTEu_uykpC z+uoL@p;!at(L-V-xGnQqA*$cg+scX12)RUH^bN@TzW%%4-6aqny3WyJz5mRwv7PN- zkIcMC5f7iAt6Si)O24iyI@)&!-Li%t$M+x2{8J;3!=0zV&}HHiO*rnE2O~B@)2Q07 zOOf>7|2}qnI+9Ksgm0JrSL`!%CixPNPf0n=0hS&LB1FoYxMbzfWvxnfcjaOVjP?X*N1ydmbo)a70RfQK75cF$%3Wbfoy

    K!}f5Qjf?D;}Dsfk#(;d=GK6jJ3(o(vej z!#1O5^FK+1P9yexdlJ`t)^eyS7lA}%>8RCG`hm{UDZRj~BF%?Y%s*fM0#Yf@RuS=@ zw{IN0Pt`MT-)z#8vj3Zy@0u>|;F8;?qh$xH&KeW$ESUIarC$|qEt7o1*cd#5{fc@g zlFBex?Q9>(MgANPsDfPewvK-CJaEUt#4FM=TH>|En?DYGi!pGp?6?E&0*I!j{gC?> zZ9fQOXP;@)H1HkpZU!J^2$76iO8d|nT!_+j2A~DN{qpa-01dnzmoMS}E6rWBD;*|` zefy+@IPW&oye9l#6TYOM>L6YV`nLFt$8~}Kf!i|?kSkA%i`2S2az@GDdJ4ePmA(dI z!{$$`+X=3G9F&Zbst+BkahL$t$vN=C2dXwQ7{(UF9SE2B3wXEkrLLo#lCNKwbF4|O z^xOBGAwT|g=}!ZhC5#(!cH3K!^o(mUYYA@vcDAPvde8k?xQl0#~Sc@bAx49>nfmkH1}zhK|TTkq;qz&f7dA{iQ5mv_c;?wDw@Mm z`!l}p{RjIf2-otJj*q!;J>b+x8C-Sb{Z<-1{%OwmL=qY%LxbWs_D_&_>m9FwfS6o7 zO?WwQ&lZ==Oie`I3V5Lq&I2hH=cH1#7mc%Ht~cE`zav?zrA=EOGxqe4Q~Icqog@XNlubW&q(@yvB!)gK zI|8oHTqZ;YGxI;0%I&K*4DbYiQ%Bke_b<1%8cZC@>g?j?8KR2arS#mYivKM;c0auj z;M6)NLF(-IEv3gtw!g=zjUXHG2dZKK4F1{lk9M~2madBqGyq42hQ3G-OkG2!U5*!g zJt0VAG)Ce76r7M@dgcGw@l#VYZ;n?XrdJVj_%2ZuTK3P`O#R_jvVwG;7@p#|(f|-J zK|;{2wl%QEx%eedG~nz3fHI46HuY9EDQ`DDoJmRah`WW9Fkykg_u(xSh^K4(XyOpj zShaxGlx~jx`x!1j8z{3~*W^-95BNL%TnYMJ30E=2*oXD86>!$)?Gj50hiW&$_reeC zsP2EbkVqOFegx7UsRdj`(jq&naFW|(#k7uDTlkj<#IOp(4^$Itcxh&gqGkdk%jIyD zvB*K#Sd(q{^`^j=F@WUr|GU48@&Ef(5UmlBd**D`UHfb%rgq?CCRl39+Dc_FUWNTX DcEeu; literal 0 HcmV?d00001 diff --git a/packages/ui/src/assets/provider-icons/code0.png b/packages/ui/src/assets/provider-icons/code0.png new file mode 100644 index 0000000000000000000000000000000000000000..32d81c1e5b44077d6fcb83e675350cbfee6f84a2 GIT binary patch literal 12992 zcmeHNdr(tX8b1MSgeBrYmGBs#+uc&zQ3)sz2|=nh_^8|6I!IhZAdsLbO_)?(K}pet zN>g#mOCAMB*5d98f;=J;LI5E?LV#8why-XB0tFHvJQou7+?3A#xlU(0vv=oa=FWHT zot%5m_xpa|>zsS#!{ao9m7^5^fN(hI;2!|k)C7;Z&9A{bJ0>sOhr*H@MCSp(E=LYr ztK}PJ0Jwp}2Ln$e7Eisz#g@^ChkADB;w)M{t8gJtcHzz6zU(=^>(YJ?z>9guVZYpM zYhKHl+`ydJn~F~I*0H3hi5G>9u>;5}0I<4P^J~+|wwpdC>3~TL2Z#X11I!-~N5FW1 z@c`oi<_B0Dz~aCw#R0#M%z$)q>TdbntC^ephpy(ArkexmuX>yS2-EFmwWmos<=G=Z z(ABDc3wa=j`do!cG~qCH%o2dRi=Dr(zQHQPfzro=^|7zv5gWR-pv|U;E;*2_vH~(O zx4oDnd)gnvQLAd+UUC7dE8_(4Y|TmHa_328iS^>qm;B&=sLZ8)vtkGU{9!Q>Wic^f zpq_4rSWaNEK5uG(-438cGFV4INS`rzpIqK#NtZvJPD~;>ikl4!^U+L+@44-IeY}|B zL=cE%&mzOa~sa^7uWw@E(5+59b0cuo7i-~^ed73Ej zB8gdY>``P2b3}cgb7#E$99YYQt2xeYh)IDZ+H8yHWYjJ8eV6^`kZxCzdbFQAV}zG# zxwG@$FUYWbsObU2&F;zORcZKlt8ehjS~AGXmEnOGpWy`w-zS;88F(J#nAFE4N^sED z$JYE<{aG3(2#D?Wr^9562HBy=0acZTg-@>+@a^th&mWkbi>wvJ>F7Cp8<0YTb#CvX zua}K?6e)l?*4U$xH?a++ue zSedj;et_`h)gSt*FaM?fhbRSB&nG3E_wPuIqLWEFRlr5V%WrUi`vVNn0>i*2oqO*# z0ETD8Dhot4fi=<=ViGkt2pf9u6(JS_zghkUWyL?Xv1KwG*0lbQ@z%cHcKB%_kLtvr z)DrN#n%)Ni*$rlE8Dxz%K~ZBD=CBm!`(A#+$u1djsK4maiqW9%>tlJI6s#AG6J|!I zTifhbxgH{hb+v^rt8v`${bh}gxegVhwR>dEcZB?j&vyFW zrtuUNR}-2c9*VQ(K%<@0{?L=&RZ?=+X0l~;@Of}*SxVyi@fou#71AAjvA&$gHJ5Dlxr*?j#^O5u!eTF*8rP_$%qPpI{R``J8 zJ+Npgj3djlQ3mF23fM^%7G!5<*LA&S;CX7q(8qn)6;AfA!;GFnhMub#dz`Kx^t^2jZW!Y0J}#RVd#<4dY`q#rnw zH%^e!>1_WxHN5KH+jpwoe%M}47FplwV+fD(xgJB6b)QUT7xSo&42m))v2QA($(2DF z8voq2_mu8x#8*5;21A(PL`dD+vE24PgO^B)b1mg*wbfbPl!rwG*;rLrMO~OnTj3ei z=_Km7a(-nPW>ea13YcL-t4Q6Ib6JyQ4W2KR#5#DMuE8&jI8$%a*@K*g%o|y*W-V}d z{8H=AQk##7d{OixvAA(Ag&Wf7@pUQ{EZ42k^5Dc#u%#8MJR;bwWeVfiPFu!|$Hp3N z=^8)sJJP$VH$d-OeCA$FJM$$;qT;SK3rQE1+%;Z9R`b3zUU-0Fl-#BJd)qw~SZ@nu z(_Kya+G712*2h97pr((-)*UduVtmE?6^jE{9KhnhzvDn^Ed##?0P!w^~6P8;yV2SWNqNgGUn^n|MEcRe=rvzkn`A86QRSIJ%^M{VRkH OhYuY;SovOf+P?sKzeTD5 literal 0 HcmV?d00001 diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 2415bb1..18ad4c8 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -43,6 +43,8 @@ import type { } from "@ccr/core/contracts/app"; import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png"; import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico"; +import claudeapiProviderIconUrl from "@/assets/provider-icons/claudeapi.png"; +import code0ProviderIconUrl from "@/assets/provider-icons/code0.png"; import deepseekProviderIconUrl from "@/assets/provider-icons/deepseek.ico"; import geminiProviderIconUrl from "@/assets/provider-icons/gemini.svg"; import mistralProviderIconUrl from "@/assets/provider-icons/mistral.webp"; @@ -319,6 +321,8 @@ export const mcpStdioMessageModeOptions: Array<{ label: string; value: GatewayMc export const providerPresetIconUrls: Record = { anthropic: anthropicProviderIconUrl, bailian: bailianProviderIconUrl, + claudeapi: claudeapiProviderIconUrl, + code0: code0ProviderIconUrl, deepseek: deepseekProviderIconUrl, gemini: geminiProviderIconUrl, "kimi-coding": moonshotProviderIconUrl, From c8cbc09b96c97e768b6c8ee94d87cd4c553b4d1b Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 7 Jul 2026 20:29:32 +0800 Subject: [PATCH 069/101] add new api probe --- packages/core/src/contracts/app.ts | 8 +- packages/core/src/gateway/service.ts | 19 ++- .../core/src/providers/account-service.ts | 134 ++++++++++++++++++ packages/core/src/providers/new-api.ts | 92 ++++++++++++ packages/core/src/providers/probe.ts | 35 ++++- .../src/pages/home/components/providers.tsx | 67 ++++++--- packages/ui/src/pages/home/shared/i18n.tsx | 2 + .../ui/src/pages/home/shared/providers.ts | 53 ++++++- tests/main/gateway-virtual-models.test.mjs | 51 +++++++ tests/main/provider-probe.test.mjs | 67 +++++++++ tests/renderer/providers.test.ts | 58 ++++++++ 11 files changed, 559 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/providers/new-api.ts diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 685a35e..0dbfa56 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -160,6 +160,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests"; export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string; export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string; +export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self"; export type ProviderAccountConfig = { connectors?: ProviderAccountConnectorConfig[]; @@ -193,7 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas headers?: Record; mapping: ProviderAccountMappingConfig; method?: "GET" | "POST"; - parser?: "kimi-code-usages"; + parser?: ProviderAccountHttpJsonParser; type: "http-json"; }; @@ -405,6 +406,8 @@ export type GatewayProviderCapability = { type: GatewayProviderProtocol; }; +export type GatewayProviderDetectedProvider = "new-api"; + export type GatewayProviderProbeRequest = { apiKey?: string; baseUrl: string; @@ -445,6 +448,7 @@ export type ProviderIconDetectionResult = { export type GatewayProviderProbeProtocolResult = { baseUrl?: string; + detectedProvider?: GatewayProviderDetectedProvider; endpoint: string; message: string; protocol: GatewayProviderProtocol; @@ -453,7 +457,9 @@ export type GatewayProviderProbeProtocolResult = { }; export type GatewayProviderProbeResult = { + account?: ProviderAccountConfig; capabilities?: GatewayProviderCapability[]; + detectedProvider?: GatewayProviderDetectedProvider; detectedProtocol?: GatewayProviderProtocol; modelDisplayNames?: Record; modelSource?: "anthropic" | "gemini" | "openai"; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index ef274df..5936e7a 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -391,7 +391,7 @@ class GatewayService { } if (shouldRunGateway) { - await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration); + await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, this.browserWebSearchMcpIntegration); await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint); if (await isCoreGatewayHealthy(this.status.coreEndpoint)) { throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`); @@ -1150,6 +1150,7 @@ export const gatewayService = new GatewayService(); async function writeCoreGatewayConfig( config: AppConfig, rawTraceSyncToken: string, + coreAuthToken: string, browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration ): Promise { assertLoopbackCoreHost(config.gateway.coreHost); @@ -1165,7 +1166,7 @@ async function writeCoreGatewayConfig( ...pluginService.getVirtualModelProfiles() ])), config); const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); - const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, browserWebSearchMcpIntegration); + const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); const providers = [ ...config.Providers .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) @@ -1459,6 +1460,7 @@ function hasOwn(value: Record, key: string): boolean { async function fusionBuiltinToolArtifacts( profiles: unknown[], coreEndpoint: string, + coreAuthToken: string, browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration ): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { const providers: CoreGatewayProvider[] = []; @@ -1481,12 +1483,14 @@ async function fusionBuiltinToolArtifacts( const toolServerKey = `vision:${visionConfig.toolName}`; if (!toolServerKeys.has(toolServerKey)) { toolServerKeys.add(toolServerKey); + const useGatewayVisionRuntime = !visionConfig.baseUrl; mcpServers.push(fusionBuiltinMcpServer({ entry, env: { FUSION_BUILTIN_TOOL_KIND: "vision", FUSION_TOOL_NAME: visionConfig.toolName, - ...(visionConfig.baseUrl ? { VISION_BASE_URL: visionConfig.baseUrl } : { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` }), + ...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }), + ...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}), ...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}), ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) @@ -1534,6 +1538,15 @@ async function fusionBuiltinToolArtifacts( return { mcpServers, providers }; } +export async function fusionBuiltinToolArtifactsForTest( + profiles: unknown[], + coreEndpoint: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); +} + function fusionBuiltinMcpServer({ entry, env, diff --git a/packages/core/src/providers/account-service.ts b/packages/core/src/providers/account-service.ts index 31df114..adeb61d 100644 --- a/packages/core/src/providers/account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -146,6 +146,26 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR status: statusFromMeters(meters, [], 1) }; } + if (connector.parser === "new-api-key-usage") { + const meters = newApiKeyUsageMeters(payload); + return { + meters, + message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload), + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + if (connector.parser === "new-api-user-self") { + const meters = newApiUserSelfMeters(payload); + return { + meters, + message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload), + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } const meters = mappedMetersFromPayload(connector, payload); @@ -158,6 +178,18 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR }; } +export function newApiKeyUsageMetersForTest(payload: unknown): ProviderAccountMeter[] { + return newApiKeyUsageMeters(payload); +} + +export function newApiKeyUsageFallbackMessageForTest(payload: unknown): string { + return newApiKeyUsageFallbackMessage(payload); +} + +export function newApiUserSelfMetersForTest(payload: unknown): ProviderAccountMeter[] { + return newApiUserSelfMeters(payload); +} + export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise { const providerName = request.provider?.trim(); const creditId = request.creditId?.trim(); @@ -586,6 +618,24 @@ async function resolveHttpJsonConnector( source: "http-json" }; } + if (connector.parser === "new-api-key-usage") { + const meters = newApiKeyUsageMeters(payload); + return { + errors: [], + message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload), + meters, + source: "http-json" + }; + } + if (connector.parser === "new-api-user-self") { + const meters = newApiUserSelfMeters(payload); + return { + errors: [], + message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload), + meters, + source: "http-json" + }; + } const meters = mappedMetersFromPayload(connector, payload); return { @@ -758,6 +808,90 @@ function normalizeRemoteSnapshot( }; } +function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] { + const meter = newApiKeyUsageMeter(payload); + return meter ? [meter] : []; +} + +function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined { + const data = newApiKeyUsageData(payload); + if (!data) { + return undefined; + } + + const unlimited = readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true; + const remaining = normalizeNumber(readJsonRecordValue(data, "total_available")); + const limit = normalizeNumber(readJsonRecordValue(data, "total_granted")); + const used = normalizeNumber(readJsonRecordValue(data, "total_used")); + if (unlimited || (limit === undefined && remaining === undefined && used === undefined)) { + return undefined; + } + + return { + id: "new_api_key_quota", + kind: "quota", + label: "API key quota", + limit, + remaining, + source: "http-json", + unit: "quota", + used + }; +} + +function newApiKeyUsageFallbackMessage(payload: unknown): string { + const data = newApiKeyUsageData(payload); + if (data && readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true) { + return "API key has no dedicated quota limit. Configure a New API user balance connector with an access token and New-Api-User to read account balance."; + } + return readMappedString("$.message", payload) ?? "No API key quota data available."; +} + +function newApiUserSelfMeters(payload: unknown): ProviderAccountMeter[] { + const meter = newApiUserSelfMeter(payload); + return meter ? [meter] : []; +} + +function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined { + const data = newApiUserSelfData(payload); + if (!data) { + return undefined; + } + + const remaining = normalizeNumber(readJsonRecordValue(data, "quota")); + const used = normalizeNumber(readJsonRecordValue(data, "used_quota")); + if (remaining === undefined && used === undefined) { + return undefined; + } + + return { + id: "new_api_user_balance", + kind: "balance", + label: "User balance", + limit: remaining !== undefined && used !== undefined ? remaining + used : undefined, + remaining, + source: "http-json", + unit: "quota", + used + }; +} + +function newApiKeyUsageData(payload: unknown): Record | undefined { + if (!isRecord(payload)) { + return undefined; + } + const data = readJsonRecordValue(payload, "data"); + return isRecord(data) ? data : payload; +} + +function newApiUserSelfData(payload: unknown): Record | undefined { + if (!isRecord(payload)) { + return undefined; + } + const data = readJsonRecordValue(payload, "data"); + return isRecord(data) ? data : payload; +} + function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] { if (!isRecord(payload)) { return []; diff --git a/packages/core/src/providers/new-api.ts b/packages/core/src/providers/new-api.ts new file mode 100644 index 0000000..44c2609 --- /dev/null +++ b/packages/core/src/providers/new-api.ts @@ -0,0 +1,92 @@ +import type { ProviderAccountConfig, ProviderAccountHttpJsonConnectorConfig } from "@ccr/core/contracts/app"; +import { compactProviderUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; + +export type DetectedProviderKind = "new-api"; + +const newApiHeaderNames = ["x-new-api-version", "x-oneapi-request-id"]; + +export function detectedProviderFromHeaders(headers: Record): DetectedProviderKind | undefined { + return hasNewApiHeaders(headers) ? "new-api" : undefined; +} + +export function hasNewApiHeaders(headers: Record): boolean { + const normalized = new Set(Object.keys(headers).map((key) => key.toLowerCase())); + return newApiHeaderNames.some((header) => normalized.has(header)); +} + +export function newApiKeyUsageAccountConfig(baseUrl: string): ProviderAccountConfig { + return { + connectors: [ + { + auth: "provider-api-key", + endpoint: newApiKeyUsageEndpoint(baseUrl), + mapping: { + message: "$.message", + meters: [ + { + id: "new_api_key_quota", + kind: "quota", + label: "API key quota", + limit: "$.data.total_granted", + remaining: "$.data.total_available", + unit: "quota", + used: "$.data.total_used", + } + ] + }, + method: "GET", + parser: "new-api-key-usage", + type: "http-json" + } + ], + enabled: true + }; +} + +export function newApiKeyUsageEndpoint(baseUrl: string): string { + const root = newApiRootBaseUrl(baseUrl); + return `${root}/api/usage/token/`; +} + +export function newApiUserSelfConnectorConfig(baseUrl: string): ProviderAccountHttpJsonConnectorConfig { + return { + auth: "none", + endpoint: newApiUserSelfEndpoint(baseUrl), + headers: { + Authorization: "Bearer ", + "New-Api-User": "" + }, + mapping: { + meters: [ + { + id: "new_api_user_balance", + kind: "balance", + label: "User balance", + remaining: "$.data.quota", + unit: "quota", + used: "$.data.used_quota" + } + ] + }, + method: "GET", + parser: "new-api-user-self", + type: "http-json" + }; +} + +export function newApiUserSelfEndpoint(baseUrl: string): string { + const root = newApiRootBaseUrl(baseUrl); + return `${root}/api/user/self`; +} + +export function newApiRootBaseUrl(baseUrl: string): string { + try { + const url = new URL(providerUrlWithDefaultScheme(baseUrl.trim())); + url.pathname = url.pathname.replace(/\/+(v1|api)$/i, "").replace(/\/+$/, "") || "/"; + url.search = ""; + url.hash = ""; + return compactProviderUrl(url); + } catch { + return baseUrl.trim().replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\/(v1|api)$/i, ""); + } +} diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index e5b24d4..0a82346 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -19,6 +19,11 @@ import { providerBaseUrlForProtocol, type ParsedProviderBaseUrl } from "@ccr/core/providers/url"; +import { + detectedProviderFromHeaders, + newApiKeyUsageAccountConfig, + type DetectedProviderKind +} from "@ccr/core/providers/new-api"; type ModelSource = NonNullable; @@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & { }; type FetchJsonResult = { + detectedProvider?: DetectedProviderKind; + headers?: Record; payload?: unknown; status?: number; text: string; @@ -226,16 +233,21 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) : typedModels; const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode); const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols); + const normalizedBaseUrl = detectedProtocol + ? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe) + : parsed.normalizedInputBaseUrl; + const detectedProvider = detectProvider(protocolResults); + const account = detectedProvider === "new-api" ? newApiKeyUsageAccountConfig(normalizedBaseUrl) : undefined; return { + ...(account ? { account } : {}), capabilities: capabilitiesFromProtocolResults(protocolResults), + ...(detectedProvider ? { detectedProvider } : {}), detectedProtocol, modelDisplayNames: modelProbe.modelDisplayNames, modelSource: modelProbe.source, models: modelProbe.models, - normalizedBaseUrl: detectedProtocol - ? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe) - : parsed.normalizedInputBaseUrl, + normalizedBaseUrl, protocols: protocolResults }; } @@ -491,6 +503,7 @@ async function probeProtocolSupport( const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints); const probeResult = { baseUrl: candidate.baseUrl, + ...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}), endpoint: candidate.endpoint, message, protocol, @@ -539,6 +552,7 @@ async function probeProtocolConnectivity( const supported = isProtocolSupported(result.status, message, protocol); const probeResult = { baseUrl: candidate.baseUrl, + ...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}), endpoint: candidate.endpoint, message, protocol, @@ -663,7 +677,10 @@ async function requestJson(url: string, init: RequestInit): Promise item.detectedProvider)?.detectedProvider; +} + +function responseHeadersRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +} + function parseProviderUrl(value: string): ParsedProviderUrl { const parsed = parseProviderBaseUrl(value); const url = new URL(parsed.normalizedInputBaseUrl); diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 040617c..5f1753a 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -9,7 +9,7 @@ import { Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion, Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot, - providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, + providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, @@ -2085,7 +2085,11 @@ function ProviderUsageSettings({ const [testLoading, setTestLoading] = useState(false); const [testResult, setTestResult] = useState(); const [testError, setTestError] = useState(""); + const [newApiUserId, setNewApiUserId] = useState(""); const modeOptions = translateOptions(providerAccountModeOptions, t); + const showNewApiUserBalanceTemplate = probe?.detectedProvider === "new-api" || + draft.accountConnectorsText.includes("new-api-key-usage") || + draft.accountConnectorsText.includes("new-api-user-self"); useEffect(() => { setTestResult(undefined); @@ -2135,6 +2139,16 @@ function ProviderUsageSettings({ onChange(providerUsageFieldPatch(target, path)); } + function insertNewApiUserBalanceTemplate() { + onChange({ + accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate( + draft.accountConnectorsText, + probe?.normalizedBaseUrl || draft.baseUrl, + newApiUserId + ) + }); + } + return (
    @@ -2253,23 +2267,40 @@ function ProviderUsageSettings({ ) : null} {draft.accountMode === "raw" ? ( - -