Refactor router config and model selection flow

This commit is contained in:
musistudio
2026-06-19 23:00:24 +08:00
parent d7fc9726fb
commit f0525105f8
29 changed files with 3108 additions and 660 deletions
+5
View File
@@ -6,3 +6,8 @@ release
.env
.env.*
!.env.example
.idea
.claude
.bot-gateway-state
.agent-data
tmp
+54 -7
View File
@@ -1,13 +1,13 @@
import os from "node:os";
import { createRequire } from "node:module";
import path from "node:path";
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig } from "../shared/app";
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app";
import { CONFIGDIR } from "./constants";
const requireFromHere = createRequire(__filename);
export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig): Record<string, string> {
const bot = normalizeBotGatewayForWebSocket(resolveBotGatewayConfig(config, profile));
export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig, surface?: ProfileOpenSurface): Record<string, string> {
const bot = normalizeBotGatewayForWebSocket(resolveBotGatewayConfig(config, profile, surface));
if (!bot?.enabled || !bot.platform || bot.platform === "none") {
return disabledBotGatewayEnv();
}
@@ -29,7 +29,7 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig):
CCR_BOT_GATEWAY_AUTO_START_INTEGRATION: boolEnv(bot.autoStartIntegration),
CCR_BOT_GATEWAY_COMMAND: bot.command ?? "",
CCR_BOT_GATEWAY_CONFIG_JSON: JSON.stringify(bot.integrationConfig ?? {}),
CCR_BOT_GATEWAY_CREATE_INTEGRATION: boolEnv(bot.createIntegration),
CCR_BOT_GATEWAY_CREATE_INTEGRATION: boolEnv(shouldCreateBotGatewayIntegration(bot)),
CCR_BOT_GATEWAY_CREDENTIALS_JSON: JSON.stringify(bot.credentials ?? {}),
CCR_BOT_GATEWAY_CWD: bot.cwd ?? "",
CCR_BOT_GATEWAY_ENABLED: "true",
@@ -38,7 +38,7 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig):
CCR_BOT_GATEWAY_PLATFORM: bot.platform,
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: String(bot.pollIntervalMs ?? 2000),
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: String(bot.requestTimeoutMs ?? 600000),
CCR_BOT_GATEWAY_SOURCE_DIR: bot.sourceDir ?? "",
CCR_BOT_GATEWAY_SOURCE_DIR: "",
...botGatewaySdkEnv(),
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000),
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
@@ -73,11 +73,51 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig):
return env;
}
function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig): BotGatewayRuntimeConfig {
function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig, surface?: ProfileOpenSurface): BotGatewayRuntimeConfig {
const runtimeSurface = surface ?? normalizeProfileSurface(profile.surface);
if (runtimeSurface !== "app") {
return {
...config.botGateway,
enabled: false,
platform: "none"
};
}
const savedBot = profile.botConfigId
? (config.botConfigs ?? []).find((item) => item.id === profile.botConfigId)
: undefined;
return savedBot?.botGateway ?? profile.botGateway ?? config.botGateway;
return mergeBotGatewayRuntimeConfig(
mergeBotGatewayRuntimeConfig(config.botGateway, savedBot?.botGateway),
profile.botGateway
);
}
function mergeBotGatewayRuntimeConfig(
base: BotGatewayRuntimeConfig,
override?: BotGatewayRuntimeConfig
): BotGatewayRuntimeConfig {
if (!override) {
return base;
}
return {
...base,
...override,
credentials: {
...base.credentials,
...override.credentials
},
handoff: {
...base.handoff,
...override.handoff
},
integrationConfig: {
...base.integrationConfig,
...override.integrationConfig
}
};
}
function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli" | "app" {
return value === "cli" || value === "app" ? value : "auto";
}
function botGatewaySdkEnv(): Record<string, string> {
@@ -228,3 +268,10 @@ function sanitizePathSegment(value: string): string {
function boolEnv(value: boolean): string {
return value ? "true" : "false";
}
function shouldCreateBotGatewayIntegration(bot: BotGatewayRuntimeConfig): boolean {
if (bot.authType === "qr_login") {
return false;
}
return bot.createIntegration;
}
+79 -9
View File
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -20,6 +20,7 @@ type BotGatewayClientWithRequest = {
};
type BotGatewaySdkModule = {
bundledStdioPath?: () => string;
createBotGatewayClient: (options?: unknown) => unknown;
};
@@ -87,13 +88,18 @@ export async function startBotGatewayQrLogin(
});
registered = true;
const qrCodeUrl = qrCodeUrlFromAuth(auth);
if (!qrCodeUrl) {
throw new Error("Bot Gateway QR start response missing qrCodeUrl.");
}
return {
botConfigId: savedConfig.id,
expiresAt: stringValue(auth.expiresAt),
integrationId,
message: stringValue(auth.message),
platform: bot.platform,
qrCodeUrl: stringValue(auth.qrCodeUrl),
qrCodeUrl,
sessionId,
stateDir,
tenantId: bot.tenantId
@@ -165,6 +171,7 @@ export function cancelBotGatewayQrLogin(
async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise<BotGatewayClientWithRequest> {
const sdk = await loadBotGatewaySdk();
const command = resolveBotGatewayCommand(sdk, bot);
const client = sdk.createBotGatewayClient({
transport: "stdio",
env: {
@@ -172,13 +179,7 @@ async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): P
BOT_GATEWAY_STATE_DIR: stateDir,
CODEXL_HOME: CONFIGDIR
},
...(bot.command
? {
args: bot.args,
command: resolveUserPath(bot.command),
cwd: bot.cwd ? resolveUserPath(bot.cwd) : process.cwd()
}
: {})
...command
}) as BotGatewayClientWithRequest;
if (!client || typeof client.request !== "function" || typeof client.health !== "function") {
throw new Error("Bot Gateway SDK client does not expose request().");
@@ -186,6 +187,53 @@ async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): P
return client;
}
function resolveBotGatewayCommand(sdk: BotGatewaySdkModule, bot: BotGatewayRuntimeConfig): { args?: string[]; command: string; cwd?: string } | undefined {
if (bot.command) {
return {
args: bot.args,
command: resolveUserPath(bot.command),
cwd: bot.cwd ? resolveUserPath(bot.cwd) : process.cwd()
};
}
if (typeof sdk.bundledStdioPath !== "function") {
return undefined;
}
const bundledPath = sdk.bundledStdioPath();
return {
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
command: process.execPath,
cwd: path.dirname(bundledPath)
};
}
function sanitizedBotGatewayStdioRunnerPath(sourcePath: string): string {
const source = readFileSync(sourcePath, "utf8");
const normalized = normalizeDuplicateShebangs(source);
if (normalized === source) {
return sourcePath;
}
const targetDir = path.join(CONFIGDIR, "bot-gateway", "runners");
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
mkdirSync(targetDir, { recursive: true });
if (!existsSync(targetPath) || readFileSync(targetPath, "utf8") !== normalized) {
writeFileSync(targetPath, normalized);
}
return targetPath;
}
function normalizeDuplicateShebangs(source: string): string {
const lines = source.split("\n");
if (!lines[0]?.startsWith("#!")) {
return source;
}
let index = 1;
while (lines[index]?.startsWith("#!")) {
index += 1;
}
return [lines[0], ...lines.slice(index)].join("\n");
}
async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
if (!sdkPromise) {
sdkPromise = importBotGatewaySdk();
@@ -349,6 +397,28 @@ function unwrapGatewayResult(value: unknown): Record<string, unknown> {
return isRecord(result) ? result : value;
}
function qrCodeUrlFromAuth(auth: Record<string, unknown>): string {
const direct = stringValue(auth.qrCodeUrl) ||
stringValue(auth.qrCodeURL) ||
stringValue(auth.qrcodeUrl) ||
stringValue(auth.qrcodeURL) ||
stringValue(auth.qrcode_img_content) ||
stringValue(auth.url);
if (direct) {
return direct;
}
const raw = auth.raw;
if (isRecord(raw)) {
return stringValue(raw.qrCodeUrl) ||
stringValue(raw.qrCodeURL) ||
stringValue(raw.qrcodeUrl) ||
stringValue(raw.qrcodeURL) ||
stringValue(raw.qrcode_img_content) ||
stringValue(raw.url);
}
return "";
}
function botGatewayClientRequest(
client: BotGatewayClientWithRequest,
method: string,
+126
View File
@@ -0,0 +1,126 @@
import { BrowserWindow, shell } from "electron";
import type {
BotGatewayQrWindowCloseRequest,
BotGatewayQrWindowCloseResult,
BotGatewayQrWindowOpenRequest,
BotGatewayQrWindowOpenResult
} from "../shared/app";
const qrWindows = new Map<string, BrowserWindow>();
export async function openBotGatewayQrWindow(
request: BotGatewayQrWindowOpenRequest
): Promise<BotGatewayQrWindowOpenResult> {
const sessionId = request.sessionId.trim();
if (!sessionId) {
throw new Error("QR window sessionId is required.");
}
const url = parseQrWindowUrl(request.url);
const existing = qrWindows.get(sessionId);
if (existing && !existing.isDestroyed()) {
if (existing.webContents.getURL() !== url) {
await loadQrWindowUrl(existing, url, Boolean(request.waitForScan));
}
existing.show();
existing.focus();
if (request.waitForScan) {
return { opened: true, ...await waitForQrWindowClose(existing) };
}
return { opened: true };
}
const window = new BrowserWindow({
height: 760,
minHeight: 560,
minWidth: 380,
show: true,
title: request.title?.trim() || "Weixin Login",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
},
width: 460
});
qrWindows.set(sessionId, window);
window.on("closed", () => {
if (qrWindows.get(sessionId) === window) {
qrWindows.delete(sessionId);
}
});
window.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
if (isHttpUrl(targetUrl)) {
void shell.openExternal(targetUrl);
}
return { action: "deny" };
});
window.show();
window.focus();
await loadQrWindowUrl(window, url, Boolean(request.waitForScan));
if (!window.isDestroyed()) {
window.show();
window.focus();
}
if (request.waitForScan) {
return { opened: true, ...await waitForQrWindowClose(window) };
}
return { opened: true };
}
export function closeBotGatewayQrWindow(
request: BotGatewayQrWindowCloseRequest
): BotGatewayQrWindowCloseResult {
const sessionId = request.sessionId.trim();
const window = qrWindows.get(sessionId);
if (!window || window.isDestroyed()) {
qrWindows.delete(sessionId);
return { closed: false };
}
qrWindows.delete(sessionId);
window.close();
return { closed: true };
}
function parseQrWindowUrl(value: string): string {
const trimmed = value.trim();
if (!isHttpUrl(trimmed)) {
throw new Error("Only http and https QR login URLs can be opened.");
}
return new URL(trimmed).toString();
}
async function loadQrWindowUrl(window: BrowserWindow, url: string, allowClosed: boolean) {
try {
await window.loadURL(url);
} catch (error) {
if (allowClosed && window.isDestroyed()) {
return;
}
throw error;
}
}
async function waitForQrWindowClose(
window: BrowserWindow
): Promise<Omit<BotGatewayQrWindowOpenResult, "opened">> {
if (window.isDestroyed()) {
return { observed: true, reason: "closed" };
}
return new Promise((resolve) => {
const onClosed = () => resolve({ observed: true, reason: "closed" });
window.once("closed", onClosed);
});
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
+293
View File
@@ -0,0 +1,293 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { BotHandoffScanTarget } from "../shared/app";
const execFileAsync = promisify(execFile);
export async function scanBotHandoffWifiTargets(): Promise<BotHandoffScanTarget[]> {
const output = await commandStdout("arp", ["-a"]);
if (!output.trim()) {
throw new Error("No Wi-Fi/LAN targets found.");
}
return parseArpScanTargets(output);
}
export async function scanBotHandoffBluetoothTargets(): Promise<BotHandoffScanTarget[]> {
const targets: BotHandoffScanTarget[] = [];
if (process.platform === "darwin") {
await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--connected"], "blueutil connected");
await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--paired"], "blueutil paired");
await collectBluetoothTargetsFromCommand(targets, "blueutil", ["--format", "json", "--recent"], "blueutil recent");
await collectBluetoothTargetsFromCommand(targets, "/usr/sbin/system_profiler", ["SPBluetoothDataType", "-json"], "system_profiler bluetooth json");
await collectBluetoothTargetsFromCommand(targets, "/usr/sbin/system_profiler", ["SPBluetoothDataType"], "system_profiler bluetooth");
await collectBluetoothTargetsFromCommand(targets, "ioreg", ["-r", "-c", "IOBluetoothDevice", "-l"], "ioreg IOBluetoothDevice");
} else if (process.platform === "win32") {
await collectBluetoothTargetsFromCommand(targets, "powershell.exe", [
"-NoProfile",
"-Command",
"Get-PnpDevice -Class Bluetooth | Where-Object { $_.FriendlyName } | ForEach-Object { $_.FriendlyName }"
], "Windows Bluetooth device");
}
return uniqueTargets(targets);
}
async function collectBluetoothTargetsFromCommand(
targets: BotHandoffScanTarget[],
command: string,
args: string[],
sourceDetail: string
) {
const output = await commandStdout(command, args).catch(() => "");
if (!output.trim()) {
return;
}
for (const target of parseBluetoothScanTargets(output)) {
pushUniqueTarget(targets, {
...target,
detail: target.detail || sourceDetail
});
}
}
async function commandStdout(command: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync(command, args, {
maxBuffer: 4 * 1024 * 1024,
timeout: 12_000
});
return stdout;
}
function parseArpScanTargets(output: string): BotHandoffScanTarget[] {
const targets: BotHandoffScanTarget[] = [];
for (const line of output.split(/\r?\n/)) {
const target = parseArpScanTarget(line);
if (target) {
pushUniqueTarget(targets, target);
}
}
return targets;
}
function parseArpScanTarget(line: string): BotHandoffScanTarget | undefined {
const trimmed = line.trim();
if (!trimmed || trimmed.includes("(incomplete)")) {
return undefined;
}
const windowsTarget = parseWindowsArpScanTarget(trimmed);
if (windowsTarget) {
return windowsTarget;
}
const open = trimmed.indexOf("(");
const close = open >= 0 ? trimmed.indexOf(")", open + 1) : -1;
if (open < 0 || close < 0) {
return undefined;
}
const host = trimmed.slice(0, open).trim().replace(/\.$/, "");
const ip = trimmed.slice(open + 1, close).trim();
const afterAt = trimmed.split(" at ")[1]?.trim() ?? "";
const mac = afterAt.split(/\s+/)[0]?.replace(/,$/, "") ?? "";
const networkInterface = trimmed.split(" on ")[1]?.split(/\s+/)[0] ?? "";
const target = ip || mac;
if (!target) {
return undefined;
}
const detailParts = [];
if (mac && mac !== "(incomplete)") {
detailParts.push(`MAC ${mac}`);
}
if (networkInterface) {
detailParts.push(`interface ${networkInterface}`);
}
return {
detail: detailParts.join(" / "),
id: `wifi:${target}`,
label: host && host !== "?" ? `${host} (${target})` : target,
source: "wifi",
target
};
}
function parseWindowsArpScanTarget(line: string): BotHandoffScanTarget | undefined {
const [ip, mac] = line.split(/\s+/);
if (!looksLikeIpv4(ip) || !looksLikeMac(mac)) {
return undefined;
}
return {
detail: `MAC ${mac}`,
id: `wifi:${ip}`,
label: `${ip} (${mac})`,
source: "wifi",
target: ip
};
}
function parseBluetoothScanTargets(output: string): BotHandoffScanTarget[] {
const targets: BotHandoffScanTarget[] = [];
try {
collectBluetoothScanTargets(JSON.parse(output), targets);
} catch {
// Text output is parsed below.
}
if (targets.length === 0) {
collectBluetoothScanTargetsFromText(output, targets);
}
return uniqueTargets(targets);
}
function collectBluetoothScanTargets(value: unknown, targets: BotHandoffScanTarget[]) {
if (Array.isArray(value)) {
for (const item of value) {
collectBluetoothScanTargets(item, targets);
}
return;
}
if (!isRecord(value)) {
return;
}
const target = bluetoothScanTargetFromObject(value);
if (target) {
pushUniqueTarget(targets, target);
}
for (const item of Object.values(value)) {
if (typeof item === "object" && item !== null) {
collectBluetoothScanTargets(item, targets);
}
}
}
function bluetoothScanTargetFromObject(record: Record<string, unknown>): BotHandoffScanTarget | undefined {
const name = firstStringField(record, [
"device_name",
"device_title",
"name",
"_name",
"displayName",
"deviceName",
"DeviceName",
"localName",
"Product"
])?.trim() ?? "";
const address = firstStringField(record, [
"device_address",
"address",
"bd_addr",
"macAddress",
"deviceAddress",
"BD_ADDR",
"BTAddress",
"DeviceAddress"
])?.trim();
const identifier = firstStringField(record, ["identifier", "id", "uuid", "UUID", "peripheralIdentifier"])?.trim();
const hasDeviceMarker = Boolean(address || identifier || firstStringField(record, ["device_rssi", "rssi", "RSSI"])) ||
Object.keys(record).some((key) => key.toLowerCase().includes("device"));
if (!hasDeviceMarker || name.toLowerCase() === "bluetooth" || name.toLowerCase() === "bluetooth-incoming-port") {
return undefined;
}
const target = address || identifier || name;
if (!target) {
return undefined;
}
const detailParts = [];
if (address) {
detailParts.push(`address ${address}`);
}
if (identifier && identifier !== address) {
detailParts.push(`id ${identifier}`);
}
const connected = firstStringField(record, ["device_connected", "connected"]);
if (connected) {
detailParts.push(`connected ${connected}`);
}
const rssi = firstStringField(record, ["device_rssi", "rssi", "RSSI"]);
if (rssi) {
detailParts.push(`RSSI ${rssi}`);
}
return {
detail: detailParts.join(" / "),
id: `bluetooth:${target}`,
label: name || bluetoothFallbackLabel(target),
source: "bluetooth",
target
};
}
function collectBluetoothScanTargetsFromText(output: string, targets: BotHandoffScanTarget[]) {
const blocks: Array<Record<string, string>> = [];
let current: Record<string, string> = {};
for (const line of output.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const quoted = trimmed.match(/"([^"]+)"\s*=\s*"([^"]*)"/);
if (quoted) {
current[quoted[1]] = quoted[2];
continue;
}
const keyValue = trimmed.match(/^([^:]+):\s*(.+)$/);
if (keyValue) {
current[keyValue[1].trim()] = keyValue[2].trim();
continue;
}
const heading = trimmed.match(/^(.+):$/);
if (heading) {
if (Object.keys(current).length > 0) {
blocks.push(current);
}
current = { name: heading[1].trim() };
}
}
if (Object.keys(current).length > 0) {
blocks.push(current);
}
for (const block of blocks) {
const target = bluetoothScanTargetFromObject(block);
if (target) {
pushUniqueTarget(targets, target);
}
}
}
function firstStringField(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if ((typeof value === "number" || typeof value === "boolean") && String(value).trim()) {
return String(value);
}
}
return undefined;
}
function bluetoothFallbackLabel(target: string): string {
const short = target.length > 12 ? `${target.slice(0, 8)}...${target.slice(-4)}` : target;
return `Bluetooth device ${short}`;
}
function pushUniqueTarget(targets: BotHandoffScanTarget[], target: BotHandoffScanTarget) {
if (!targets.some((item) => item.id === target.id || (item.source === target.source && item.target === target.target))) {
targets.push(target);
}
}
function uniqueTargets(targets: BotHandoffScanTarget[]): BotHandoffScanTarget[] {
const result: BotHandoffScanTarget[] = [];
for (const target of targets) {
pushUniqueTarget(result, target);
}
return result;
}
function looksLikeIpv4(value: string | undefined): value is string {
return Boolean(value && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value));
}
function looksLikeMac(value: string | undefined): value is string {
return Boolean(value && /^[0-9a-f]{2}(?:[:-][0-9a-f]{2}){5}$/i.test(value));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+8 -41
View File
@@ -5,7 +5,6 @@ import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { saveAppConfig } from "./config";
import { CONFIGDIR } from "./constants";
import { buildCodexModelCatalog } from "./codex-model-catalog";
import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app";
const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311";
@@ -86,8 +85,8 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp
const state = ensureClaudeAppGatewayState(config);
const paths = getClaudeAppGatewayPaths(options.dataDir);
const endpoint = gatewayEndpoint(state.config);
const model = inferClaudeAppGatewayModel(state.config);
const models = buildClaudeAppGatewayModels(state.config, model);
const model = inferClaudeAppGatewayModel();
const models = buildClaudeAppGatewayModels(model);
const gatewayConfig: ClaudeAppGatewayConfig = {
inferenceCredentialKind: "static",
inferenceGatewayApiKey: state.apiKey,
@@ -290,47 +289,15 @@ function gatewayEndpoint(config: AppConfig): string {
return `http://${formattedHost}:${port}`;
}
function inferClaudeAppGatewayModel(config: AppConfig): string {
const routerModel = normalizeGatewayModelSelector(config.Router.default);
if (routerModel) {
return routerModel;
}
for (const provider of Array.isArray(config.Providers) ? config.Providers : []) {
for (const model of Array.isArray(provider.models) ? provider.models : []) {
const modelName = stringValue(model);
if (modelName) {
return provider.name ? `${provider.name}/${modelName}` : modelName;
}
}
}
function inferClaudeAppGatewayModel(): string {
// Claude App validates gateway model routes as Anthropic-looking model IDs.
// Keep the App picker schema-valid; CCR routes this placeholder to the
// profile's real provider/model through Router.default at request time.
return CLAUDE_APP_FALLBACK_MODEL;
}
function buildClaudeAppGatewayModels(config: AppConfig, selectedModel: string): string[] {
const models = buildCodexModelCatalog(config, selectedModel);
if (models.length > 0) {
return models;
}
return [CLAUDE_APP_FALLBACK_MODEL];
}
function normalizeGatewayModelSelector(value: unknown): string {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const commaIndex = trimmed.indexOf(",");
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
const provider = trimmed.slice(0, commaIndex).trim();
const model = trimmed.slice(commaIndex + 1).trim();
return provider && model ? `${provider}/${model}` : "";
}
return trimmed;
function buildClaudeAppGatewayModels(selectedModel: string): string[] {
return [selectedModel || CLAUDE_APP_FALLBACK_MODEL];
}
function applyClaudeAppConfigMeta(metaFile: string): void {
+4 -2
View File
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ProfileConfig } from "../shared/app";
import type { AppConfig, ProfileConfig } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
type ClaudeAppLookupResult = {
@@ -20,7 +21,7 @@ const macClaudeAppNames = ["Claude.app", "Claude Desktop.app"];
const windowsClaudeAppDirs = ["Claude", "Claude Desktop", "ClaudeDesktop", "AnthropicClaude"];
const windowsClaudeExeNames = ["Claude.exe", "claude.exe", "Claude Desktop.exe"];
export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig): ClaudeAppLaunchResult {
export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): ClaudeAppLaunchResult {
const lookup = findInstalledClaudeAppExecutable();
if (!lookup.executable) {
throw new Error([
@@ -37,6 +38,7 @@ export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig
const env: NodeJS.ProcessEnv = {
...process.env,
...profileEnv(profile),
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
CLAUDE_CONFIG_DIR: settingsDir,
CLAUDE_USER_DATA_DIR: userDataDir,
CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir,
+1 -1
View File
@@ -43,7 +43,7 @@ async function main(): Promise<void> {
const childEnv = {
...process.env,
...plan.env,
...botGatewayProfileEnv(config, profile)
...botGatewayProfileEnv(config, profile, resolvedSurface)
};
delete childEnv.ELECTRON_RUN_AS_NODE;
+1 -1
View File
@@ -44,7 +44,7 @@ export function launchCodexAppProfile(configDir: string, profile: ProfileConfig,
const env: NodeJS.ProcessEnv = {
...process.env,
...plan.env,
...(config ? botGatewayProfileEnv(config, profile) : {}),
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
...codexProfileEnv(profile),
CODEX_CLI_PATH: plan.command,
CODEX_ELECTRON_USER_DATA_PATH: userDataDir,
+616 -58
View File
@@ -17,10 +17,21 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60
const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000);
const CONFIG_DIR = path.join(os.homedir(), ".claude-code-router");
const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || path.join(CONFIG_DIR, "codex-cli-middleware.log");
const BOT_BRIDGE = createBotGatewayBridge();
let BOT_BRIDGE_INSTANCE = null;
function botBridge() {
if (!BOT_BRIDGE_INSTANCE) {
BOT_BRIDGE_INSTANCE = createBotGatewayBridge();
}
return BOT_BRIDGE_INSTANCE;
}
async function main() {
const args = process.argv.slice(2);
if (process.env.CCR_CLAUDE_CODE_BOT_WORKER === "1" || args[0] === "claude-bot-worker") {
await runClaudeCodeBotWorker(args);
return;
}
if (process.env.CCR_CLAUDE_CODE_WRAPPER === "1") {
await runClaudeCodeCliWrapper(args);
return;
@@ -49,12 +60,12 @@ async function runClaudeCodeCliWrapper(args) {
const lines = pending.split(/\r?\n/g);
pending = lines.pop() || "";
for (const line of lines) {
BOT_BRIDGE.handleClaudeCliLine(line);
botBridge().handleClaudeCliLine(line);
}
});
const code = await waitForChild(child);
if (pending.trim()) {
BOT_BRIDGE.handleClaudeCliLine(pending);
botBridge().handleClaudeCliLine(pending);
}
log("claude_code_wrapper_exit", { code });
process.exitCode = code;
@@ -104,7 +115,7 @@ async function runCodexCliMiddleware(args) {
const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity, terminal: false });
stdoutRl.on("line", (line) => {
const rewritten = rewriteCodexStdoutLine(line, requestMap);
BOT_BRIDGE.handleJsonRpcLine(rewritten);
botBridge().handleJsonRpcLine(rewritten);
if (!shouldSuppressBotBridgeLine(rewritten)) {
process.stdout.write(rewritten + "\n");
}
@@ -276,6 +287,32 @@ async function runClaudeCodeAppServer(args) {
await server.run();
}
async function runClaudeCodeBotWorker(args) {
const options = parseAppServerOptions(args);
const server = new ClaudeCodeAppServer(options);
server.ensureBotBridgeRegistered();
log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid });
await waitForTerminationSignal();
await botBridge().stop();
log("claude_bot_worker_stop", { pid: process.pid });
}
function waitForTerminationSignal() {
return new Promise((resolve) => {
const timer = setInterval(() => {}, 2147483647);
const done = () => {
clearInterval(timer);
process.off("SIGINT", done);
process.off("SIGTERM", done);
process.off("SIGHUP", done);
resolve();
};
process.once("SIGINT", done);
process.once("SIGTERM", done);
process.once("SIGHUP", done);
});
}
function parseAppServerOptions(args) {
let workspaceName = nonEmptyEnv("CCR_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") || nonEmptyEnv("CODEXL_CODEX_INSTANCE_NAME") || "Claude Code";
for (let i = 0; i < args.length; i += 1) {
@@ -299,7 +336,13 @@ class ClaudeCodeAppServer {
this.threads = new Map();
this.active = new Map();
this.appResponses = new Map();
this.botBridgeRegistered = false;
this.botSessionStore = { version: 1, conversations: {} };
this.botSessionStoreLoaded = false;
this.botThreadKeys = new Map();
this.botThreads = new Map();
this.configValues = {};
this.pollingEvents = false;
this.stdin = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
}
@@ -364,6 +407,7 @@ class ClaudeCodeAppServer {
platformFamily: process.platform === "win32" ? "windows" : "unix",
platformOs: process.platform
});
this.ensureBotBridgeRegistered();
return undefined;
case "thread/start": {
const thread = this.createThread(params);
@@ -551,6 +595,173 @@ class ClaudeCodeAppServer {
}
}
async handleBotInbound(event, _queued, eventId, bridge) {
const text = botEventText(event);
if (!text) {
log("bot_gateway_inbound_skip", { eventId, reason: "empty_text" });
return;
}
const thread = this.botThreadForEvent(event, text);
const prepared = this.startTurn({
cwd: thread.cwd,
input: [{ type: "text", text }],
threadId: thread.id
});
for (const notification of prepared.notifications) writeRaw(notification);
bridge.suppressTurn(prepared.turn.id);
try {
await this.runTurn(prepared.work);
} finally {
bridge.unsuppressTurn(prepared.turn.id);
}
const completed = thread.turns.find((turn) => turn.id === prepared.turn.id) || prepared.turn;
const responseText = completed.error
? "Agent turn failed: " + completed.error
: (completed.agentText || "").trim() || "Claude Code completed the turn without a text response.";
await bridge.sendReplyToEvent(event, responseText, "ccr:claude-code:" + eventId + ":" + prepared.turn.id);
log("bot_gateway_inbound_replied", { eventId, threadId: thread.id, turnId: prepared.turn.id, textLen: responseText.length });
}
ensureBotBridgeRegistered() {
if (this.botBridgeRegistered) return;
this.botBridgeRegistered = true;
botBridge().setInboundHandler((event, queued, eventId, bridge) => this.handleBotInbound(event, queued, eventId, bridge));
}
botThreadForEvent(event, text) {
const key = botConversationKey(event);
const mappedThreadId = this.botThreads.get(key);
if (mappedThreadId && this.threads.has(mappedThreadId)) {
return this.threads.get(mappedThreadId);
}
const restoredThread = this.restoreBotThreadForConversation(key);
if (restoredThread) {
if (!restoredThread.preview) restoredThread.preview = text.slice(0, 160);
return restoredThread;
}
const appThread = this.inferBotThreadFromClaudeAppSession(key, text);
if (appThread) return appThread;
const thread = this.createThread({ cwd: process.cwd(), workspaceKind: "local" });
if (!thread.preview) thread.preview = text.slice(0, 160);
this.botThreads.set(key, thread.id);
this.botThreadKeys.set(thread.id, key);
this.persistBotThread(thread.id);
return thread;
}
restoreBotThreadForConversation(key) {
const entry = this.loadBotSessionStore().conversations[key];
if (!entry || typeof entry !== "object") return null;
if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null;
const thread = this.createThread({
cwd: entry.cwd || process.cwd(),
model: entry.model || undefined,
workspaceKind: "local",
claudeConfigDir: entry.claudeConfigDir || null
});
this.replaceThreadId(thread, entry.threadId || thread.id);
thread.sessionId = entry.sessionId || thread.id;
thread.claudeSessionId = entry.claudeSessionId || null;
thread.claudeConfigDir = entry.claudeConfigDir || null;
thread.claudeAppSessionId = entry.claudeAppSessionId || null;
thread.preview = entry.preview || "";
thread.updatedAt = entry.updatedAtSeconds || nowSeconds();
this.botThreads.set(key, thread.id);
this.botThreadKeys.set(thread.id, key);
log("bot_gateway_session_restored", {
conversationKeyPrefix: key.slice(0, 80),
threadId: thread.id,
claudeSessionIdPrefix: thread.claudeSessionId ? thread.claudeSessionId.slice(0, 8) : ""
});
return thread;
}
inferBotThreadFromClaudeAppSession(key, text) {
const session = latestClaudeAppLocalAgentSession();
if (!session) return null;
const thread = this.createThread({
cwd: session.cwd || process.cwd(),
model: session.model || undefined,
workspaceKind: "local",
claudeConfigDir: session.claudeConfigDir || null
});
thread.sessionId = session.sessionId || thread.id;
thread.claudeSessionId = session.cliSessionId || null;
thread.claudeConfigDir = session.claudeConfigDir || null;
thread.claudeAppSessionId = session.sessionId || null;
thread.preview = session.title || session.initialMessage || text.slice(0, 160);
thread.name = session.title || this.workspaceName;
thread.updatedAt = Math.floor((session.lastActivityAt || Date.now()) / 1000);
this.botThreads.set(key, thread.id);
this.botThreadKeys.set(thread.id, key);
this.persistBotThread(thread.id);
log("bot_gateway_session_inferred", {
conversationKeyPrefix: key.slice(0, 80),
threadId: thread.id,
appSessionId: thread.claudeAppSessionId,
claudeSessionIdPrefix: thread.claudeSessionId ? thread.claudeSessionId.slice(0, 8) : "",
cwd: thread.cwd
});
return thread;
}
replaceThreadId(thread, id) {
const nextId = String(id || "").trim();
if (!nextId || thread.id === nextId) return;
this.threads.delete(thread.id);
thread.id = nextId;
this.threads.set(thread.id, thread);
}
loadBotSessionStore() {
if (this.botSessionStoreLoaded) return this.botSessionStore;
this.botSessionStoreLoaded = true;
try {
this.botSessionStore = normalizeBotSessionStore(JSON.parse(fs.readFileSync(botSessionStorePath(), "utf8")));
} catch {
this.botSessionStore = { version: 1, conversations: {} };
}
return this.botSessionStore;
}
saveBotSessionStore() {
const file = botSessionStorePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(this.botSessionStore, null, 2));
}
persistBotThread(threadId) {
const key = this.botThreadKeys.get(threadId);
if (!key) return;
const thread = this.threads.get(threadId);
if (!thread) return;
const store = this.loadBotSessionStore();
store.conversations[key] = {
threadId: thread.id,
sessionId: thread.sessionId || thread.id,
claudeSessionId: thread.claudeSessionId || null,
claudeAppSessionId: thread.claudeAppSessionId || null,
claudeConfigDir: thread.claudeConfigDir || null,
cwd: thread.cwd || process.cwd(),
model: thread.model || "",
preview: thread.preview || "",
updatedAt: Date.now(),
updatedAtSeconds: thread.updatedAt || nowSeconds()
};
this.saveBotSessionStore();
}
rememberClaudeSession(message, work) {
const sessionId = claudeSessionIdFromMessage(message);
if (!sessionId) return;
const thread = this.threads.get(work.threadId);
if (!thread || thread.claudeSessionId === sessionId) return;
thread.claudeSessionId = sessionId;
log("claude_session_remembered", { threadId: work.threadId, turnId: work.turnId, sessionIdPrefix: sessionId.slice(0, 8) });
this.persistBotThread(work.threadId);
}
createThread(params) {
const id = uuid();
const cwd = normalizeCwd(params.cwd);
@@ -558,7 +769,9 @@ class ClaudeCodeAppServer {
const thread = {
id,
sessionId: id,
claudeSessionId: id,
claudeSessionId: null,
claudeConfigDir: params.claudeConfigDir || null,
claudeAppSessionId: params.claudeAppSessionId || null,
path: null,
preview: "",
cwd,
@@ -656,7 +869,9 @@ class ClaudeCodeAppServer {
cwd: thread.cwd,
prompt,
input,
resumeExisting: thread.turns.length > 1,
resumeExisting: Boolean(thread.claudeSessionId),
claudeSessionId: thread.claudeSessionId,
claudeConfigDir: thread.claudeConfigDir,
model: thread.model
};
const userItem = userItemJson(turn);
@@ -691,7 +906,7 @@ class ClaudeCodeAppServer {
this.active.set(key, { key, threadId: work.threadId, turnId: work.turnId, child });
try {
child.stdin.write(JSON.stringify({ type: "control_request", request_id: uuid(), request: { subtype: "initialize" } }) + "\n");
child.stdin.write(JSON.stringify(claudeInputMessage(work.input.length ? work.input : [{ type: "text", text: work.prompt }])) + "\n");
child.stdin.write(JSON.stringify(claudeInputMessage(work.input.length ? work.input : [{ type: "text", text: work.prompt }], work.claudeSessionId || "")) + "\n");
} catch (error) {
childSpawnError = error;
log("claude_stdin_error", { threadId: work.threadId, turnId: work.turnId, error: formatError(error) });
@@ -742,6 +957,7 @@ class ClaudeCodeAppServer {
turn.toolItems = Array.from(stream.tools.values()).map((tool) => toolItemJson(work.threadId, work.cwd, tool));
thread.updatedAt = turn.completedAt;
thread.latestTokenUsageInfo = stream.latestUsage;
this.persistBotThread(work.threadId);
if (!stream.agentStarted && text) {
writeNotification("item/completed", {
threadId: thread.id,
@@ -762,6 +978,7 @@ class ClaudeCodeAppServer {
} catch {
return;
}
this.rememberClaudeSession(message, work);
rememberUsage(message, work, stream);
if (message.type === "control_request") {
this.handleControlRequest(message, work, child);
@@ -858,6 +1075,22 @@ function handleClaudeToolResults(content, work, stream) {
}
}
function claudeSessionIdFromMessage(message) {
return (
objectSessionId(message) ||
objectSessionId(message && message.message) ||
objectSessionId(message && message.event) ||
objectSessionId(message && message.result) ||
objectSessionId(message && message.response) ||
""
);
}
function objectSessionId(value) {
if (!value || typeof value !== "object") return "";
return stringValue(value.session_id) || stringValue(value.sessionId);
}
function handleClaudeContentBlock(block, work, stream) {
const type = block && block.type;
if (type === "text" && typeof block.text === "string") {
@@ -923,25 +1156,29 @@ function claudeCommand(work) {
];
const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || work.model;
if (model) args.push("--model", model);
if (work.resumeExisting) args.push("--resume", work.threadId);
if (work.resumeExisting && work.claudeSessionId) args.push("--resume", work.claudeSessionId);
const extra = splitShellLike(nonEmptyEnv("CCR_CLAUDE_CODE_EXTRA_ARGS") || nonEmptyEnv("CODEXL_CLAUDE_CODE_EXTRA_ARGS") || "");
args.push(...extra);
const env = withoutKeys({
...process.env,
CODEX_SESSION_ID: work.threadId,
CODEX_THREAD_ID: work.threadId,
CODEX_TURN_ID: work.turnId
}, ["CCR_CLAUDE_CODE_BOT_WORKER", "ELECTRON_RUN_AS_NODE"]);
if (work.claudeConfigDir) {
env.CLAUDE_CONFIG_DIR = work.claudeConfigDir;
}
return {
command,
args,
env: {
...process.env,
CODEX_SESSION_ID: work.threadId,
CODEX_THREAD_ID: work.threadId,
CODEX_TURN_ID: work.turnId
}
env
};
}
function claudeInputMessage(input) {
function claudeInputMessage(input, sessionId = "") {
return {
type: "user",
session_id: "",
session_id: sessionId || "",
message: { role: "user", content: claudeContentFromInput(input) },
parent_tool_use_id: null
};
@@ -1013,6 +1250,7 @@ function threadJson(thread, includeTurns) {
threadId: thread.id,
conversationId: thread.id,
sessionId: thread.sessionId,
claudeSessionId: thread.claudeSessionId,
path: thread.path,
preview: thread.preview,
cwd: thread.cwd,
@@ -1415,7 +1653,7 @@ function writeNotification(method, params) {
}
function writeRaw(value) {
BOT_BRIDGE.handleJsonRpcValue(value);
botBridge().handleJsonRpcValue(value);
writeLine(process.stdout, value);
}
@@ -1429,7 +1667,12 @@ function createBotGatewayBridge() {
return {
handleClaudeCliLine() {},
handleJsonRpcLine() {},
handleJsonRpcValue() {}
handleJsonRpcValue() {},
sendReplyToEvent: async () => {},
setInboundHandler() {},
stop: async () => {},
suppressTurn() {},
unsuppressTurn() {}
};
}
const bridge = new BotGatewayBridge(config);
@@ -1539,14 +1782,43 @@ class BotGatewayBridge {
this.child = null;
this.client = null;
this.forwarded = new Set();
this.inboundHandler = null;
this.inboundEvents = new Set();
this.latestEvent = null;
this.messageCounter = 0;
this.pollTimer = null;
this.startPromise = null;
this.suppressedTurnIds = new Set();
this.claudeCliCapture = { finalText: "", resultCount: 0, text: "" };
this.turnCaptures = new Map();
}
setInboundHandler(handler) {
this.inboundHandler = typeof handler === "function" ? handler : null;
if (this.inboundHandler) {
this.ensureStarted().catch((error) => this.logError("start_failed", error));
}
}
suppressTurn(turnId) {
if (turnId) this.suppressedTurnIds.add(String(turnId));
}
unsuppressTurn(turnId) {
if (turnId) this.suppressedTurnIds.delete(String(turnId));
}
async stop() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
const client = this.client;
this.client = null;
this.startPromise = null;
await closeBotGatewayClient(client);
}
handleClaudeCliLine(line) {
if (!line || !this.config.enabled) return;
let message;
@@ -1619,7 +1891,6 @@ class BotGatewayBridge {
handleJsonRpcValue(value) {
if (!this.config.enabled || !value || typeof value !== "object") return;
this.ensureStarted().catch((error) => this.logError("start_failed", error));
const method = typeof value.method === "string" ? value.method : "";
const params = value.params && typeof value.params === "object" ? value.params : {};
if (method === "item/completed") {
@@ -1681,6 +1952,11 @@ class BotGatewayBridge {
forwardAgentText(key, text, params) {
if (this.forwarded.has(key)) return;
const turnId = params && (params.turnId || params.turn_id || (params.turn && params.turn.id));
if (turnId && this.suppressedTurnIds.has(String(turnId))) {
log("bot_gateway_forward_skip", { key, reason: "bot_inbound_turn" });
return;
}
const decision = this.forwardDecision();
if (!decision.shouldForward) {
log("bot_gateway_forward_skip", { key, reason: decision.reason });
@@ -1735,6 +2011,27 @@ class BotGatewayBridge {
});
}
async sendReplyToEvent(event, text, key) {
if (!text || !String(text).trim()) return;
await this.ensureStarted();
const conversationRef = conversationRefFromEvent(event) || this.config.conversationRef;
if (!conversationRef) {
throw new Error("No Bot Gateway conversationRef is available for inbound bot response.");
}
this.messageCounter += 1;
const outbound = {
tenantId: eventString(event, "tenantId") || this.config.tenantId || "ccr",
integrationId: eventString(event, "integrationId") || this.config.integrationId,
conversationRef,
intent: {
type: "text",
text
},
idempotencyKey: key + ":" + this.messageCounter
};
await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: inbound outbound.send");
}
resolveTenantId() {
return eventString(this.latestEvent, "tenantId") || this.config.tenantId || "ccr";
}
@@ -1746,14 +2043,7 @@ class BotGatewayBridge {
resolveConversationRef() {
if (this.config.conversationRef) return this.config.conversationRef;
const event = this.latestEvent;
if (!event || !event.conversation || typeof event.conversation !== "object") return null;
const id = eventString(event.conversation, "id");
if (!id) return null;
const type = ["dm", "group", "channel", "thread"].includes(event.conversation.type) ? event.conversation.type : "dm";
const ref = { platformConversationId: id, type };
const threadId = event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : "";
if (threadId) ref.threadId = threadId;
return ref;
return conversationRefFromEvent(event);
}
async ensureStarted() {
@@ -1771,7 +2061,7 @@ class BotGatewayBridge {
BOT_GATEWAY_STATE_DIR: this.config.stateDir || path.join(CONFIG_DIR, "bot-gateway", safePathSegment(this.config.profileId)),
CODEXL_HOME: CONFIG_DIR
});
const clientOptions = botGatewaySdkClientOptions(this.config, env);
const clientOptions = botGatewaySdkClientOptions(this.config, env, sdk);
this.client = sdk.createBotGatewayClient(clientOptions);
await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out.");
await this.ensureIntegration();
@@ -1784,7 +2074,7 @@ class BotGatewayBridge {
async ensureIntegration() {
if (!this.config.integrationId) return;
if (this.config.createIntegration) {
if (this.config.createIntegration && this.config.authType !== "qr_login") {
await botGatewayClientRequest(this.client, "integrations.create", {
id: this.config.integrationId,
tenantId: this.config.tenantId,
@@ -1805,24 +2095,48 @@ class BotGatewayBridge {
async pollEvents() {
if (!this.client) return;
const result = await withTimeout(this.client.events(20), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.list");
const events = Array.isArray(result && result.events) ? result.events : [];
for (const queued of events) {
const event = queued && queued.event && typeof queued.event === "object" ? queued.event : null;
if (!event || !this.matchesEvent(event)) continue;
if (event.actor && event.actor.isBot === true) continue;
this.latestEvent = event;
if (this.config.acknowledgeEvents) {
const eventId = eventString(queued, "id") || eventString(event, "id");
if (eventId) {
await withTimeout(this.client.ackEvent(eventId), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.ack").catch((error) => {
log("bot_gateway_ack_failed", { eventId, error: formatError(error) });
});
if (this.pollingEvents) return;
this.pollingEvents = true;
try {
const result = await withTimeout(this.client.events(20), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.list");
const events = Array.isArray(result && result.events) ? result.events : [];
for (const queued of events) {
const event = queued && queued.event && typeof queued.event === "object" ? queued.event : null;
if (!event || !this.matchesEvent(event)) continue;
if (event.actor && event.actor.isBot === true) continue;
this.latestEvent = event;
const eventId = eventIdFromQueued(queued, event);
if (this.inboundHandler) {
await this.dispatchInboundEvent(queued, event, eventId);
} else {
await this.ackEvent(eventId);
}
}
} finally {
this.pollingEvents = false;
}
}
async dispatchInboundEvent(queued, event, eventId) {
const key = eventId || botEventDedupeKey(event);
if (this.inboundEvents.has(key)) return;
this.inboundEvents.add(key);
try {
await this.inboundHandler(event, queued, eventId || key, this);
await this.ackEvent(eventId);
} catch (error) {
this.inboundEvents.delete(key);
throw error;
}
}
async ackEvent(eventId) {
if (!this.config.acknowledgeEvents || !eventId) return;
await withTimeout(this.client.ackEvent(eventId), this.config.requestTimeoutMs, "Bot Gateway request timed out: events.ack").catch((error) => {
log("bot_gateway_ack_failed", { eventId, error: formatError(error) });
});
}
matchesEvent(event) {
if (this.config.integrationId && event.integrationId !== this.config.integrationId) return false;
if (this.config.platform && this.config.platform !== "none" && event.platform !== this.config.platform) return false;
@@ -1885,8 +2199,8 @@ function botGatewaySdkImportSpecifier(value) {
return trimmed;
}
function botGatewaySdkClientOptions(config, env) {
const command = resolveBotGatewayCommand(config);
function botGatewaySdkClientOptions(config, env, sdk) {
const command = resolveBotGatewayCommand(config) || resolveBundledBotGatewayCommand(sdk);
return {
transport: "stdio",
...(command || {}),
@@ -1902,23 +2216,49 @@ function resolveBotGatewayCommand(config) {
cwd: config.cwd || process.cwd()
};
}
const sourceDir = expandHome(config.sourceDir || "");
const candidates = sourceDir ? [
path.join(sourceDir, "dist-bundle", "stdio", "stdio.js"),
path.join(sourceDir, "dist", "src", "stdio.js")
] : [];
for (const entry of candidates) {
if (fs.existsSync(entry)) {
return {
command: process.execPath,
args: [entry],
cwd: sourceDir
};
}
}
return undefined;
}
function resolveBundledBotGatewayCommand(sdk) {
if (!sdk || typeof sdk.bundledStdioPath !== "function") {
return undefined;
}
const bundledPath = sdk.bundledStdioPath();
return {
command: process.execPath,
args: [sanitizedBotGatewayStdioRunnerPath(bundledPath)],
cwd: path.dirname(bundledPath)
};
}
function sanitizedBotGatewayStdioRunnerPath(sourcePath) {
const source = fs.readFileSync(sourcePath, "utf8");
const normalized = normalizeDuplicateShebangs(source);
if (normalized === source) {
return sourcePath;
}
const targetDir = path.join(CONFIG_DIR, "bot-gateway", "runners");
const targetPath = path.join(targetDir, "bot-gateway-stdio.mjs");
fs.mkdirSync(targetDir, { recursive: true });
if (!fs.existsSync(targetPath) || fs.readFileSync(targetPath, "utf8") !== normalized) {
fs.writeFileSync(targetPath, normalized);
}
return targetPath;
}
function normalizeDuplicateShebangs(source) {
const lines = source.split("\n");
if (!lines[0] || !lines[0].startsWith("#!")) {
return source;
}
let index = 1;
while (lines[index] && lines[index].startsWith("#!")) {
index += 1;
}
return [lines[0], ...lines.slice(index)].join("\n");
}
function botGatewayClientRequest(client, method, params, timeoutMs) {
if (!client || typeof client.request !== "function") {
return Promise.reject(new Error("Bot Gateway SDK client does not expose request()."));
@@ -1926,6 +2266,19 @@ function botGatewayClientRequest(client, method, params, timeoutMs) {
return withTimeout(client.request(method, params), timeoutMs, "Bot Gateway request timed out: " + method);
}
async function closeBotGatewayClient(client) {
if (!client || typeof client !== "object") return;
for (const method of ["close", "dispose", "stop"]) {
if (typeof client[method] !== "function") continue;
try {
await Promise.resolve(client[method]());
} catch (error) {
log("bot_gateway_client_close_failed", { method, error: formatError(error) });
}
return;
}
}
function withTimeout(promise, timeoutMs, message) {
const timeout = Math.max(1000, timeoutMs || 30000);
let timer = null;
@@ -2039,6 +2392,201 @@ function turnErrorText(turn) {
return "";
}
function botSessionStorePath() {
const stateDir = nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") ||
nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") ||
nonEmptyEnv("BOT_GATEWAY_STATE_DIR") ||
path.join(CONFIG_DIR, "bot-gateway", safePathSegment(nonEmptyEnv("CCR_BOT_PROFILE_ID") || "default"));
return path.join(expandHome(stateDir), "claude-bot-sessions.json");
}
function normalizeBotSessionStore(value) {
const conversations = value && typeof value === "object" && value.conversations && typeof value.conversations === "object"
? value.conversations
: {};
return { version: 1, conversations };
}
function latestClaudeAppLocalAgentSession() {
const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR");
if (!baseDir) return null;
const root = path.join(expandHome(baseDir), "local-agent-mode-sessions");
const files = listClaudeAppSessionFiles(root, 6);
let latest = null;
for (const file of files) {
let value;
try {
value = JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
continue;
}
if (!value || typeof value !== "object" || value.isArchived === true || value.archived === true) continue;
const cliSessionId = stringValue(value.cliSessionId) || stringValue(value.cli_session_id);
if (!cliSessionId) continue;
const sessionId = stringValue(value.sessionId) || path.basename(file, ".json");
const lastActivityAt = numberValue(value.lastActivityAt) || numberValue(value.updatedAt) || numberValue(value.createdAt) || fileMtimeMs(file);
const item = {
file,
sessionId,
cliSessionId,
cwd: stringValue(value.cwd) || process.cwd(),
model: stringValue(value.model) || "",
title: stringValue(value.title) || "",
initialMessage: stringValue(value.initialMessage) || "",
lastActivityAt,
claudeConfigDir: claudeAppSessionConfigDir(file, value)
};
if (!latest || item.lastActivityAt > latest.lastActivityAt) latest = item;
}
return latest;
}
function listClaudeAppSessionFiles(root, maxDepth) {
const files = [];
const visit = (dir, depth) => {
if (depth < 0) return;
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath, depth - 1);
} else if (entry.isFile() && entry.name.startsWith("local_") && entry.name.endsWith(".json")) {
files.push(fullPath);
}
}
};
visit(root, maxDepth);
return files;
}
function claudeAppSessionConfigDir(file, value) {
const candidates = [];
const cwd = stringValue(value && value.cwd);
if (cwd) candidates.push(path.join(path.dirname(expandHome(cwd)), ".claude"));
const sessionId = stringValue(value && value.sessionId) || path.basename(file, ".json");
if (sessionId) candidates.push(path.join(path.dirname(file), sessionId, ".claude"));
candidates.push(path.join(path.dirname(file), ".claude"));
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
return "";
}
function fileMtimeMs(file) {
try {
return fs.statSync(file).mtimeMs;
} catch {
return 0;
}
}
function numberValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function botEventText(event) {
const direct = valueStringAtPaths(event, [
"/message/text",
"/message/content",
"/raw/message/text",
"/raw/message/content",
"/raw/text/content",
"/raw/content/text",
"/raw/content",
"/text",
"/content"
]);
if (direct) return direct;
return valueStringAtPaths(event, [
"/message/transcript",
"/message/transcription",
"/message/voiceText",
"/message/voice_text",
"/message/audioText",
"/message/audio_text",
"/raw/transcript",
"/raw/transcription",
"/raw/voiceText",
"/raw/voice_text",
"/raw/audioText",
"/raw/audio_text"
]) || "";
}
function conversationRefFromEvent(event) {
if (!event || !event.conversation || typeof event.conversation !== "object") return null;
const conversation = event.conversation;
const platformConversationId = eventString(conversation, "id") || eventString(conversation, "platformConversationId");
const gatewayConversationId = eventString(conversation, "gatewayConversationId");
if (!platformConversationId && !gatewayConversationId) return null;
const rawType = eventString(conversation, "type");
const type = ["dm", "group", "channel", "thread"].includes(rawType) ? rawType : "dm";
const ref = {
...(gatewayConversationId ? { gatewayConversationId } : {}),
...(platformConversationId ? { platformConversationId } : {}),
type
};
const threadId = event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : "";
if (threadId) ref.threadId = threadId;
const contextToken = valueStringAtPaths(event, ["/raw/context_token", "/raw/sessionWebhook", "/raw/contextToken"]);
if (contextToken) ref.contextToken = contextToken;
return ref;
}
function eventIdFromQueued(queued, event) {
return eventString(queued, "id") ||
eventString(event, "id") ||
valueStringAtPaths(event, ["/message/id", "/message/messageId", "/raw/message/id", "/raw/messageId", "/raw/msgId"]);
}
function botEventDedupeKey(event) {
const conversation = event && event.conversation && typeof event.conversation === "object" ? event.conversation : {};
return [
eventString(event, "tenantId"),
eventString(event, "integrationId"),
eventString(conversation, "id") || eventString(conversation, "gatewayConversationId"),
valueStringAtPaths(event, ["/message/id", "/message/messageId", "/raw/message/id", "/raw/messageId", "/raw/msgId"]),
botEventText(event),
valueStringAtPaths(event, ["/message/createdAt", "/message/timestamp", "/raw/createAt", "/raw/timestamp"])
].join(":");
}
function botConversationKey(event) {
const conversation = event && event.conversation && typeof event.conversation === "object" ? event.conversation : {};
return [
eventString(event, "tenantId"),
eventString(event, "integrationId"),
eventString(conversation, "id") || eventString(conversation, "gatewayConversationId") || "default",
event.message && typeof event.message === "object" ? eventString(event.message, "threadId") : ""
].join(":");
}
function valueStringAtPaths(value, paths) {
for (const path of paths) {
const candidate = valueAtPointer(value, path);
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (Number.isFinite(candidate) || typeof candidate === "boolean") return String(candidate);
}
return "";
}
function valueAtPointer(value, pointer) {
if (!value || typeof pointer !== "string" || !pointer.startsWith("/")) return undefined;
let current = value;
for (const rawPart of pointer.slice(1).split("/")) {
if (current === null || current === undefined) return undefined;
const part = rawPart.replace(/~1/g, "/").replace(/~0/g, "~");
current = current[part];
}
return current;
}
function eventString(value, key) {
return value && typeof value[key] === "string" ? value[key].trim() : "";
}
@@ -2093,6 +2641,16 @@ function activeKey(threadId, turnId) {
return String(threadId || "") + "\0" + String(turnId || "");
}
function latestThread(threads) {
let latest = null;
for (const thread of threads.values()) {
if (!latest || (thread.updatedAt || 0) > (latest.updatedAt || 0)) {
latest = thread;
}
}
return latest;
}
function findActiveForThread(active, threadId) {
for (const [key, value] of active) {
if (value.threadId === threadId) return { ...value, key };
+139 -5
View File
@@ -27,6 +27,9 @@ import type {
OverviewWidgetVariant,
ProviderAccountConfig,
ProviderAccountConnectorConfig,
ProviderCredentialConfig,
ProviderFailoverConfig,
ProviderFailoverStrategy,
ProfileConfig,
ProfileRuntimeConfig,
ProxyRouteTarget,
@@ -36,6 +39,7 @@ import type {
RouterFallbackMode,
RouterRule,
RouterRuleType,
TrayBalanceProgressConfig,
TrayComponentVariants,
TrayIconPreference,
TrayWidgetConfig,
@@ -127,7 +131,7 @@ const DEFAULT_CONFIG: AppConfig = {
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "/Users/jinhuilee/products/bot-gateway",
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
@@ -443,6 +447,18 @@ function assertProviderApiKeysAreSafe(config: AppConfig): void {
throw new Error(issue.message);
}
assertProviderAccountApiKeyTargetsAreSafe(provider, apiKey, baseUrl);
for (const credential of provider.credentials ?? []) {
const credentialApiKey = providerCredentialApiKey(credential);
const credentialIssue = providerApiKeySafetyIssue({
apiKey: credentialApiKey,
baseUrl,
name: provider.name
});
if (credentialIssue) {
throw new Error(credentialIssue.message);
}
assertProviderCredentialAccountApiKeyTargetsAreSafe(provider, credential, credentialApiKey, baseUrl);
}
}
}
@@ -468,6 +484,33 @@ function assertProviderAccountApiKeyTargetsAreSafe(provider: GatewayProviderConf
}
}
function assertProviderCredentialAccountApiKeyTargetsAreSafe(
provider: GatewayProviderConfig,
credential: ProviderCredentialConfig,
apiKey: string,
baseUrl: string
): void {
if (!apiKey || credential.account?.enabled === false) {
return;
}
const presetId = findProviderPresetByBaseUrl(baseUrl)?.id;
for (const connector of credential.account?.connectors ?? []) {
const endpoints = providerAccountConnectorApiKeyEndpoints(connector);
for (const endpoint of endpoints) {
const issue = providerEndpointCanReceiveProviderApiKey({
apiKey,
endpoint,
providerName: provider.name,
providerPresetId: presetId
});
if (issue) {
throw new Error(issue.message);
}
}
}
}
function providerAccountConnectorApiKeyEndpoints(connector: ProviderAccountConnectorConfig): string[] {
if ("auth" in connector && connector.auth === "none") {
return [];
@@ -492,6 +535,10 @@ function providerApiKey(provider: GatewayProviderConfig): string {
return provider.api_key || provider.apiKey || provider.apikey || "";
}
function providerCredentialApiKey(credential: ProviderCredentialConfig): string {
return credential.api_key || credential.apiKey || credential.apikey || "";
}
export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise<AppConfig> {
const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
await replacePersistedApiKeys(normalized);
@@ -643,6 +690,12 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
if (trayIcon) {
config.trayIcon = trayIcon;
}
const trayBalanceProgress = parseTrayBalanceProgress((value as Record<string, unknown>).trayBalanceProgress);
if (trayBalanceProgress) {
config.trayBalanceProgress = trayBalanceProgress;
} else if (config.trayIcon === "progress") {
config.trayIcon = "random";
}
const trayProgressTargetTokens = readNumber((value as Record<string, unknown>).trayProgressTargetTokens);
if (trayProgressTargetTokens && trayProgressTargetTokens > 0) {
config.trayProgressTargetTokens = clampNumber(trayProgressTargetTokens, 1000, 1_000_000_000);
@@ -782,6 +835,15 @@ function parseTrayIconPreference(value: unknown): TrayIconPreference | undefined
return undefined;
}
function parseTrayBalanceProgress(value: unknown): TrayBalanceProgressConfig | undefined {
if (!isObject(value)) {
return undefined;
}
const provider = readString(value.provider);
const meterId = readString(value.meterId);
return provider && meterId ? { meterId, provider } : undefined;
}
function parseTrayWindowModules(value: unknown): TrayWindowModuleId[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -944,8 +1006,10 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
baseurl: readString(item.baseurl),
billing: item.billing,
capabilities: parseProviderCapabilities(item.capabilities),
credentials: parseProviderCredentials(item.credentials ?? item.keys ?? item.apiKeys),
extraBody: item.extraBody,
extraHeaders: item.extraHeaders,
failover: parseProviderFailover(item.failover ?? item.credentialFailover),
icon: readString(item.icon),
models,
name,
@@ -960,6 +1024,73 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined {
return providers;
}
function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const credentials = value
.map((item, index): ProviderCredentialConfig | undefined => {
if (!isObject(item)) {
return undefined;
}
const apiKey = readString(item.api_key) || readString(item.apiKey) || readString(item.apikey) || readString(item.key) || readString(item.token);
if (!apiKey) {
return undefined;
}
const id = readString(item.id) || readString(item.name) || readString(item.label) || `key-${index + 1}`;
const priority = readNumber(item.priority);
const weight = readNumber(item.weight);
return {
account: parseProviderAccount(item.account),
api_key: apiKey,
enabled: typeof item.enabled === "boolean" ? item.enabled : undefined,
id,
label: readString(item.label) || readString(item.name),
limits: parseApiKeyLimits(item.limits),
priority: priority !== undefined ? priority : undefined,
weight: weight !== undefined && weight > 0 ? weight : undefined
};
})
.filter((item): item is ProviderCredentialConfig => Boolean(item));
return credentials.length > 0 ? credentials : undefined;
}
function parseProviderFailover(value: unknown): ProviderFailoverConfig | undefined {
if (!isObject(value)) {
return undefined;
}
const strategy = parseProviderFailoverStrategy(readString(value.strategy) || readString(value.mode));
const cooldownMs = readNumber(value.cooldownMs ?? value.cooldown);
const spilloverThreshold = readNumber(value.spilloverThreshold ?? value.threshold);
const failover: ProviderFailoverConfig = {
...(strategy ? { strategy } : {}),
...(cooldownMs !== undefined && cooldownMs > 0 ? { cooldownMs } : {}),
...(spilloverThreshold !== undefined && spilloverThreshold > 0 ? { spilloverThreshold } : {})
};
return Object.keys(failover).length > 0 ? failover : undefined;
}
function parseProviderFailoverStrategy(value: string | undefined): ProviderFailoverStrategy | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase().replace(/_/g, "-");
if (
normalized === "failover-only" ||
normalized === "least-utilized" ||
normalized === "priority-spillover" ||
normalized === "weighted-round-robin"
) {
return normalized;
}
return undefined;
}
function parseProviderAccount(value: unknown): ProviderAccountConfig | undefined {
if (!isObject(value)) {
return undefined;
@@ -1809,9 +1940,12 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
const name = readString(item.name) || (agent === "claude-code" ? "Claude Code" : "Codex");
const model = readString(item.model) ?? "";
const env = parseStringRecord(item.env) ?? {};
const botConfigId = readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id);
const surface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto";
const botConfigId = surface !== "cli"
? readString(item.botConfigId) || readString(item.bot_config_id) || readString(item.savedBotConfigId) || readString(item.saved_bot_config_id)
: "";
const parsedBotGateway = parseBotGateway(item.botGateway ?? item.bot_gateway ?? item.bot);
const botGateway = parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined;
const botGateway = surface !== "cli" && parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined;
if (agent === "claude-code") {
return {
@@ -1826,7 +1960,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
scope: parseProfileScope(readString(item.scope) || readString(item.applyScope) || readString(item.effectScope)) || "global",
settingsFile: readString(item.settingsFile) || readString(item.configFile) || "~/.claude/settings.json",
smallFastModel: readString(item.smallFastModel) || readString(item.smallModel) || "",
surface: parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"
surface
};
}
@@ -1853,7 +1987,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
: typeof item.show_all_sessions === "boolean"
? item.show_all_sessions
: false,
surface: parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"
surface
};
})
.filter((item): item is ProfileConfig => Boolean(item));
+484 -11
View File
@@ -7,11 +7,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path";
import type {
ApiKeyConfig,
ApiKeyLimitConfig,
AppConfig,
GatewayMcpServerConfig,
GatewayProviderCapability,
GatewayProviderConfig,
GatewayProviderProtocol,
ProviderCredentialConfig,
ProviderFailoverStrategy,
GatewayStatus,
RouterFallbackConfig,
RouterFallbackMode,
@@ -110,11 +113,16 @@ type CursorOpenAICompatPreparation = {
type UpstreamAttempt = {
body?: Buffer;
credentialChain?: string[];
credentialProtocol?: GatewayProviderProtocol;
headers?: Record<string, string>;
index: number;
logicalProvider?: string;
model?: string;
};
type UpstreamFailedAttempt = {
credentialChain?: string[];
error?: string;
model?: string;
statusCode?: number;
@@ -146,7 +154,10 @@ const localObservabilityHeaderNames = new Set([
"x-claude-design-project-id",
"x-ccr-claude-model-discovery",
"x-ccr-codex-model-rewrite",
"x-ccr-cursor-openai-compat"
"x-ccr-cursor-openai-compat",
"x-ccr-logical-provider",
"x-ccr-provider-credential-chain",
"x-ccr-provider-credential-saturated"
]);
const proxyHeaderDenyList = new Set(["connection", "host", "upgrade"]);
const responseHeaderDenyList = new Set(["connection", "content-encoding", "transfer-encoding"]);
@@ -159,6 +170,7 @@ let warnedMissingCursorOpenAICompatContext = false;
const rawTraceSyncPath = "/__ccr/raw-trace-sync";
const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"];
const apiKeyLimitCounters = new Map<string, ApiKeyWindowCounter>();
const providerCredentialCooldowns = new Map<string, { reason: string; until: number }>();
class GatewayService {
private child?: ChildProcess;
@@ -577,9 +589,11 @@ class GatewayService {
try {
upstreamResult = await fetchUpstreamWithFallback({
body: bodyToForward,
config: this.config,
fallback: routeFallback,
headers,
method,
path,
routedModel,
upstreamUrl
});
@@ -614,6 +628,7 @@ class GatewayService {
this.config
);
const upstreamResponse = upstreamResult.response;
recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders);
response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders)));
if (!upstreamResponse.body) {
if (shouldCaptureUsage) {
@@ -1459,6 +1474,12 @@ function findProviderByPublicOrInternalName(config: AppConfig, name: string): Ga
if (!normalized) {
return undefined;
}
const credentialInternalName = parseProviderCredentialInternalName(name);
if (credentialInternalName) {
return config.Providers.find((provider) =>
provider.name.trim().toLowerCase() === credentialInternalName.providerName.toLowerCase()
);
}
return config.Providers.find((provider) =>
provider.name.trim().toLowerCase() === normalized ||
provider.provider?.trim().toLowerCase() === normalized ||
@@ -1473,6 +1494,20 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig):
if (!providerName) {
return headers;
}
const credentialInternalName = parseProviderCredentialInternalName(providerName);
if (credentialInternalName) {
const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerName);
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-ccr-provider-protocol", credentialInternalName.protocol);
rewritten.set("x-ccr-provider-credential-provider", provider.name);
rewritten.set("x-ccr-provider-credential-id", credential?.id ?? credentialInternalName.credentialSlug);
return rewritten;
}
const provider = findProviderByPublicOrInternalName(config, providerName);
if (!provider || provider.name === providerName) {
return headers;
@@ -1490,9 +1525,11 @@ function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig):
async function fetchUpstreamWithFallback(input: {
body?: Buffer;
config: AppConfig;
fallback: RouterFallbackConfig;
headers: Record<string, string>;
method: string;
path: string;
routedModel?: string;
upstreamUrl: string;
}): Promise<UpstreamFetchResult> {
@@ -1501,18 +1538,25 @@ async function fetchUpstreamWithFallback(input: {
const failedAttempts: UpstreamFailedAttempt[] = [];
for (let index = 0; index < attempts.length; index += 1) {
const attempt = attempts[index];
const attempt = prepareUpstreamCredentialAttempt({
attempt: attempts[index],
config: input.config,
headers: input.headers,
method: input.method,
path: input.path
});
const hasNextAttempt = index < attempts.length - 1;
try {
const response = await fetch(input.upstreamUrl, {
body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined,
headers: omitLocalObservabilityHeaders(input.headers),
headers: omitLocalObservabilityHeaders(attempt.headers ?? input.headers),
method: input.method
});
if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) {
failedAttempts.push({
credentialChain: attempt.credentialChain,
model: attempt.model,
statusCode: response.status
});
@@ -1528,6 +1572,7 @@ async function fetchUpstreamWithFallback(input: {
} catch (error) {
const message = formatError(error);
failedAttempts.push({
credentialChain: attempt.credentialChain,
error: message,
model: attempt.model
});
@@ -1547,6 +1592,221 @@ async function fetchUpstreamWithFallback(input: {
});
}
function prepareUpstreamCredentialAttempt(input: {
attempt: UpstreamAttempt;
config: AppConfig;
headers: Record<string, string>;
method: string;
path: string;
}): UpstreamAttempt {
const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body);
if (!target) {
return {
...input.attempt,
headers: input.headers
};
}
const credentials = activeProviderCredentials(target.provider);
if (credentials.length === 0) {
return {
...input.attempt,
headers: input.headers
};
}
const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0));
const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage);
if (selection.credentials.length === 0) {
return {
...input.attempt,
headers: input.headers
};
}
const headers: Record<string, string> = {
...input.headers,
"x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","),
"x-ccr-logical-provider": target.provider.name,
"x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credential.id).join(",")
};
delete headers["x-target-provider"];
if (selection.saturated) {
headers["x-ccr-provider-credential-saturated"] = "true";
}
return {
...input.attempt,
body: target.body ?? input.attempt.body,
credentialChain: selection.credentials.map((candidate) => candidate.internalName),
credentialProtocol: target.protocol,
headers,
logicalProvider: target.provider.name
};
}
function resolveProviderCredentialRoutingTarget(
config: AppConfig,
headers: Record<string, string>,
path: string,
body: Buffer | undefined
): { body?: Buffer; model?: string; provider: GatewayProviderConfig; protocol: GatewayProviderProtocol } | undefined {
const protocol = requestProtocolForPath(path);
if (!protocol) {
return undefined;
}
const parsedBody = parseJsonObjectSafe(body);
const bodyModel = stringValue(parsedBody?.model);
const parsedModel = parseProviderModelSelector(bodyModel);
if (parsedModel) {
const provider = findProviderByPublicOrInternalName(config, parsedModel.provider);
if (provider && activeProviderCredentials(provider).length > 0) {
return {
body: parsedBody ? serializeJsonBodyWithModel(parsedBody, parsedModel.model) : body,
model: parsedModel.model,
provider,
protocol
};
}
}
const targetProviderName = firstTargetProviderHeader(headers);
if (!targetProviderName) {
return undefined;
}
const provider = findProviderByPublicOrInternalName(config, targetProviderName);
if (!provider || activeProviderCredentials(provider).length === 0) {
return undefined;
}
return {
body,
model: bodyModel,
provider,
protocol
};
}
function parseProviderModelSelector(value: string | undefined): { model: string; provider: string } | undefined {
const normalized = normalizeRouteSelector(value);
if (!normalized) {
return undefined;
}
const separator = normalized.indexOf("/");
if (separator <= 0 || separator >= normalized.length - 1) {
return undefined;
}
const provider = normalized.slice(0, separator).trim();
const model = normalized.slice(separator + 1).trim();
return provider && model ? { model, provider } : undefined;
}
function firstTargetProviderHeader(headers: Record<string, string>): string | undefined {
const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"];
if (provider?.trim()) {
return provider.trim();
}
const providers = headers["x-target-providers"];
return providers
?.split(",")
.map((item) => item.trim())
.find(Boolean);
}
function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] {
return (provider.credentials ?? []).filter((credential) =>
credential.enabled !== false &&
Boolean(providerCredentialApiKey(credential))
);
}
function selectProviderCredentials(
provider: GatewayProviderConfig,
protocol: GatewayProviderProtocol,
credentials: ProviderCredentialConfig[],
usage: ApiKeyLimitUsage
): { credentials: Array<{ credential: ProviderCredentialConfig; internalName: string }>; saturated: boolean } {
const candidates = credentials.map((credential, index) => {
const limitState = providerCredentialLimitState(provider, credential, usage);
const cooldown = readProviderCredentialCooldown(provider, credential);
return {
cooldown,
credential,
index,
internalName: providerCredentialInternalName(provider.name, protocol, credential),
limitState,
priority: providerCredentialPriority(credential, index),
weight: Math.max(1, credential.weight ?? 1)
};
});
const available = candidates.filter((candidate) => !candidate.cooldown && !candidate.limitState.blocked);
const sorted = sortProviderCredentialCandidates(
available.length > 0 ? available : candidates,
provider.failover?.strategy,
provider.failover?.spilloverThreshold
);
return {
credentials: sorted.map((candidate) => ({
credential: candidate.credential,
internalName: candidate.internalName
})),
saturated: available.length === 0 && candidates.length > 0
};
}
function sortProviderCredentialCandidates<T extends {
index: number;
limitState: { utilization: number };
priority: number;
weight: number;
}>(
candidates: T[],
strategy: ProviderFailoverStrategy | undefined,
spilloverThreshold: number | undefined
): T[] {
const normalizedStrategy = strategy ?? "least-utilized";
if (normalizedStrategy === "priority-spillover") {
const prioritySorted = [...candidates].sort((left, right) =>
left.priority - right.priority ||
left.index - right.index
);
const primary = prioritySorted[0];
const threshold = Number.isFinite(spilloverThreshold) && spilloverThreshold !== undefined && spilloverThreshold > 0
? spilloverThreshold
: 0.8;
if (!primary || primary.limitState.utilization < threshold) {
return prioritySorted;
}
return prioritySorted.sort((left, right) =>
left.limitState.utilization - right.limitState.utilization ||
left.priority - right.priority ||
right.weight - left.weight ||
left.index - right.index
);
}
return [...candidates].sort((left, right) => {
if (normalizedStrategy === "weighted-round-robin") {
return right.weight - left.weight || left.priority - right.priority || left.index - right.index;
}
if (normalizedStrategy === "least-utilized") {
return left.limitState.utilization - right.limitState.utilization ||
left.priority - right.priority ||
right.weight - left.weight ||
left.index - right.index;
}
return left.priority - right.priority ||
left.limitState.utilization - right.limitState.utilization ||
right.weight - left.weight ||
left.index - right.index;
});
}
function providerCredentialPriority(credential: ProviderCredentialConfig, index: number): number {
return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1;
}
function buildUpstreamAttempts(fallback: RouterFallbackConfig, method: string, body: Buffer | undefined, routedModel: string | undefined): UpstreamAttempt[] {
const initialAttempt: UpstreamAttempt = {
body,
@@ -1749,18 +2009,33 @@ function appendNodeRequireOption(current: string | undefined, preloadFile: strin
function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] {
const capabilities = normalizedProviderCapabilities(provider);
if (capabilities.length === 0) {
const coreProvider = toCoreGatewayProvider(provider);
return coreProvider ? [coreProvider] : [];
return toCoreGatewayProvidersForCapability(provider);
}
return capabilities
.map((capability) => toCoreGatewayProvider(provider, capability))
.flatMap((capability) => toCoreGatewayProvidersForCapability(provider, capability))
.filter((item): item is CoreGatewayProvider => Boolean(item));
}
function toCoreGatewayProvidersForCapability(
provider: GatewayProviderConfig,
capability?: GatewayProviderCapability
): CoreGatewayProvider[] {
const credentials = activeProviderCredentials(provider);
if (credentials.length === 0) {
const coreProvider = toCoreGatewayProvider(provider, capability);
return coreProvider ? [coreProvider] : [];
}
return sortProviderCredentialsForConfig(credentials)
.map((credential) => toCoreGatewayProvider(provider, capability, credential))
.filter((item): item is CoreGatewayProvider => Boolean(item));
}
function toCoreGatewayProvider(
provider: GatewayProviderConfig,
capability?: GatewayProviderCapability
capability?: GatewayProviderCapability,
credential?: ProviderCredentialConfig
): CoreGatewayProvider | undefined {
const type =
capability?.type ??
@@ -1768,7 +2043,7 @@ function toCoreGatewayProvider(
normalizeProviderProtocol(provider.provider) ??
inferProtocol(provider);
const baseurl = normalizeProviderRuntimeBaseUrl(capability?.baseUrl ?? readBaseUrl(provider), type);
const apikey = provider.apikey || provider.apiKey || provider.api_key;
const apikey = credential ? providerCredentialApiKey(credential) : provider.apikey || provider.apiKey || provider.api_key;
if (!provider.name || provider.models.length === 0) {
return undefined;
@@ -1789,11 +2064,22 @@ function toCoreGatewayProvider(
extraBody: provider.extraBody,
extraHeaders: provider.extraHeaders,
models: provider.models,
name: capability ? providerCapabilityInternalName(provider.name, type) : provider.name,
name: credential
? providerCredentialInternalName(provider.name, type, credential)
: capability
? providerCapabilityInternalName(provider.name, type)
: provider.name,
type
};
}
function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] {
return [...credentials].sort((left, right) =>
providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) ||
providerCredentialSlug(left.id).localeCompare(providerCredentialSlug(right.id))
);
}
function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] {
const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : [];
const normalized: GatewayProviderCapability[] = [];
@@ -1822,6 +2108,54 @@ function providerCapabilityInternalName(providerName: string, protocol: GatewayP
return `${providerName}::${protocol}`;
}
function providerCredentialInternalName(
providerName: string,
protocol: GatewayProviderProtocol,
credential: ProviderCredentialConfig
): string {
return `${providerCapabilityInternalName(providerName, protocol)}::cred:${providerCredentialSlug(credential.id)}`;
}
function parseProviderCredentialInternalName(value: string | undefined): {
credentialSlug: string;
providerName: string;
protocol: GatewayProviderProtocol;
} | undefined {
const marker = "::cred:";
const markerIndex = value?.lastIndexOf(marker) ?? -1;
if (!value || markerIndex <= 0) {
return undefined;
}
const baseName = value.slice(0, markerIndex);
const credentialSlug = value.slice(markerIndex + marker.length).trim();
const protocolSeparator = baseName.lastIndexOf("::");
if (!credentialSlug || protocolSeparator <= 0) {
return undefined;
}
const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2));
const providerName = baseName.slice(0, protocolSeparator).trim();
return protocol && providerName ? { credentialSlug, providerName, protocol } : undefined;
}
function providerCredentialSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "") || "key";
}
function providerCredentialApiKey(credential: ProviderCredentialConfig): string {
return credential.api_key || credential.apiKey || credential.apikey || "";
}
function findProviderCredentialBySlug(
provider: GatewayProviderConfig,
credentialSlug: string
): ProviderCredentialConfig | undefined {
return (provider.credentials ?? []).find((credential) => providerCredentialSlug(credential.id) === credentialSlug);
}
function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
if (typeof value !== "string") {
return undefined;
@@ -1865,6 +2199,10 @@ function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | u
if (!providerName) {
return undefined;
}
const credentialInternalName = parseProviderCredentialInternalName(providerName);
if (credentialInternalName) {
return credentialInternalName.protocol;
}
const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined;
if (!provider) {
return normalizeProviderProtocol(providerName);
@@ -2162,7 +2500,10 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming
}
function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] {
const limits = apiKey.limits;
return limitRules(apiKey.limits, usage);
}
function limitRules(limits: ApiKeyLimitConfig | undefined, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] {
if (!limits) {
return [];
}
@@ -2181,6 +2522,134 @@ function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKey
return rules;
}
function providerCredentialLimitState(
provider: GatewayProviderConfig,
credential: ProviderCredentialConfig,
usage: ApiKeyLimitUsage
): { blocked: boolean; utilization: number } {
const rules = limitRules(credential.limits, usage);
if (rules.length === 0) {
return {
blocked: false,
utilization: 0
};
}
const now = Date.now();
let blocked = false;
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);
blocked = blocked || counter.value + rule.requested > rule.limit;
utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit);
}
return {
blocked,
utilization
};
}
function recordProviderCredentialOutcome(
config: AppConfig,
method: string,
attempt: UpstreamAttempt,
statusCode: number,
responseHeaders: Headers
): void {
if (!attempt.logicalProvider || !attempt.credentialProtocol || !attempt.credentialChain?.length) {
return;
}
const provider = findProviderByPublicOrInternalName(config, attempt.logicalProvider);
if (!provider) {
return;
}
const responseCredentialId = responseHeaders.get("x-ccr-provider-credential-id")?.trim();
const responseCredential = responseCredentialId
? (provider.credentials ?? []).find((credential) => credential.id === responseCredentialId)
: undefined;
const fallbackCredential = providerCredentialFromInternalName(provider, attempt.credentialChain[0]);
const credential = responseCredential ?? fallbackCredential;
if (!credential) {
return;
}
if (statusCode >= 200 && statusCode < 500 && statusCode !== 401 && statusCode !== 403 && statusCode !== 429) {
incrementProviderCredentialCounters(provider, credential, estimateLimitUsage(method, attempt.body ?? Buffer.alloc(0)));
clearProviderCredentialCooldown(provider, credential);
return;
}
if (statusCode === 401 || statusCode === 403 || statusCode === 429 || statusCode >= 500) {
setProviderCredentialCooldown(provider, credential, providerCredentialCooldownMs(provider), `HTTP ${statusCode}`);
}
}
function providerCredentialFromInternalName(
provider: GatewayProviderConfig,
internalName: string | undefined
): ProviderCredentialConfig | undefined {
const parsed = parseProviderCredentialInternalName(internalName);
return parsed ? findProviderCredentialBySlug(provider, parsed.credentialSlug) : undefined;
}
function incrementProviderCredentialCounters(
provider: GatewayProviderConfig,
credential: ProviderCredentialConfig,
usage: ApiKeyLimitUsage
): void {
const rules = limitRules(credential.limits, usage);
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;
}
}
function providerCredentialCounterKey(
provider: GatewayProviderConfig,
credential: ProviderCredentialConfig,
rule: ApiKeyLimitRule,
windowStart: number
): string {
return ["provider-credential", provider.name, credential.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|");
}
function readProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): { reason: string; until: number } | undefined {
const key = providerCredentialStateKey(provider, credential);
const cooldown = providerCredentialCooldowns.get(key);
if (!cooldown) {
return undefined;
}
if (cooldown.until > Date.now()) {
return cooldown;
}
providerCredentialCooldowns.delete(key);
return undefined;
}
function setProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, cooldownMs: number, reason: string): void {
providerCredentialCooldowns.set(providerCredentialStateKey(provider, credential), {
reason,
until: Date.now() + cooldownMs
});
}
function clearProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): void {
providerCredentialCooldowns.delete(providerCredentialStateKey(provider, credential));
}
function providerCredentialStateKey(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): string {
return `${provider.name}::${credential.id}`;
}
function providerCredentialCooldownMs(provider: GatewayProviderConfig): number {
return clampNumber(provider.failover?.cooldownMs ?? 60_000, 1_000, 3_600_000);
}
function addApiKeyLimitRule(
rules: ApiKeyLimitRule[],
name: string,
@@ -2212,7 +2681,11 @@ function readApiKeyWindowCounter(key: string, windowStart: number): ApiKeyWindow
}
function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage {
if ((request.method ?? "GET").toUpperCase() !== "POST" || requestBody.byteLength === 0) {
return estimateLimitUsage(request.method ?? "GET", requestBody);
}
function estimateLimitUsage(method: string, requestBody: Buffer): ApiKeyLimitUsage {
if (method.toUpperCase() !== "POST" || requestBody.byteLength === 0) {
return {
imageCount: 0,
totalTokens: 0
+15 -1
View File
@@ -3,7 +3,9 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
import net from "node:net";
import path from "node:path";
import { builtInBrowserService } from "./built-in-browser";
import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service";
import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service";
import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service";
import { syncClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config";
import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
@@ -23,7 +25,7 @@ import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "./usage-store";
import windowsManager from "./windows";
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
const pluginMarketplace: PluginMarketplaceEntry[] = [
{
@@ -165,6 +167,18 @@ ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginWait, (_event, request: BotGatew
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginCancel, (_event, request: BotGatewayQrLoginCancelRequest) => {
return cancelBotGatewayQrLogin(request);
});
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrWindowOpen, (_event, request: BotGatewayQrWindowOpenRequest) => {
return openBotGatewayQrWindow(request);
});
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrWindowClose, (_event, request: BotGatewayQrWindowCloseRequest) => {
return closeBotGatewayQrWindow(request);
});
ipcMain.handle(IPC_CHANNELS.appBotHandoffWifiTargetsScan, () => {
return scanBotHandoffWifiTargets();
});
ipcMain.handle(IPC_CHANNELS.appBotHandoffBluetoothTargetsScan, () => {
return scanBotHandoffBluetoothTargets();
});
ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => {
const config = await loadAppConfig();
return applyProfileConfig(config);
+9
View File
@@ -13,6 +13,11 @@ import type {
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
BotGatewayQrWindowCloseRequest,
BotGatewayQrWindowCloseResult,
BotGatewayQrWindowOpenRequest,
BotGatewayQrWindowOpenResult,
BotHandoffScanTarget,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
GatewayMcpToolInfo,
@@ -48,6 +53,7 @@ contextBridge.exposeInMainWorld("ccr", {
applyClaudeAppGateway: (config?: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise<ClaudeAppGatewayApplyResult>,
applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise<ProfileApplyResult>,
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise<BotGatewayQrLoginCancelResult>,
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise<BotGatewayQrWindowCloseResult>,
clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise<void>,
detectProviderIcon: (request: ProviderIconDetectionRequest) => ipcRenderer.invoke(IPC_CHANNELS.appDetectProviderIcon, request) as Promise<ProviderIconDetectionResult>,
@@ -70,6 +76,7 @@ contextBridge.exposeInMainWorld("ccr", {
installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise<ProxyCertificateInstallResult>,
listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise<GatewayMcpToolInfo[]>,
openBuiltInBrowser: () => ipcRenderer.invoke(IPC_CHANNELS.appOpenBuiltInBrowser) as Promise<void>,
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrWindowOpen, request) as Promise<BotGatewayQrWindowOpenResult>,
openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.appOpenExternal, url) as Promise<void>,
openProfile: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appOpenProfile, request) as Promise<ProfileOpenResult>,
probeProvider: (request: GatewayProviderProbeRequest) => ipcRenderer.invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
@@ -87,6 +94,8 @@ contextBridge.exposeInMainWorld("ccr", {
startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise<GatewayStatus>,
startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginStart, request) as Promise<BotGatewayQrLoginStartResult>,
stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise<GatewayStatus>,
scanBotHandoffBluetoothTargets: () => ipcRenderer.invoke(IPC_CHANNELS.appBotHandoffBluetoothTargetsScan) as Promise<BotHandoffScanTarget[]>,
scanBotHandoffWifiTargets: () => ipcRenderer.invoke(IPC_CHANNELS.appBotHandoffWifiTargetsScan) as Promise<BotHandoffScanTarget[]>,
testProviderAccountConnector: (request: ProviderAccountTestRequest) => ipcRenderer.invoke(IPC_CHANNELS.appTestProviderAccountConnector, request) as Promise<ProviderAccountTestResult>,
updateCheck: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateCheck) as Promise<AppUpdateStatus>,
updateDownload: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateDownload) as Promise<AppUpdateStatus>,
+123 -4
View File
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -7,12 +7,17 @@ import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { launchCodexAppProfile } from "./codex-app-launch";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { CONFIGDIR } from "./constants";
import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveProfileOpenSurface } from "./profile-launch-core";
import { gatewayService } from "./gateway/service";
import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core";
import { applyProfileConfig } from "./profile-service";
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
let claudeAppBotWorker: ChildProcess | undefined;
process.once("exit", stopClaudeAppBotWorker);
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenCommandResult> {
await applyProfileConfig(config);
@@ -28,6 +33,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO
}
export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenResult> {
await applyProfileConfig(config);
const profile = findProfileForOpen(config, request.profileId);
const surface = resolveProfileOpenSurface(profile, request.surface);
if (profile.agent === "claude-code" && surface === "app") {
@@ -46,7 +52,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
env: {
...process.env,
...plan.env,
...botGatewayProfileEnv(config, profile)
...botGatewayProfileEnv(config, profile, surface)
},
stdio: "ignore"
});
@@ -97,7 +103,17 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
backup: false,
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile)
});
launchClaudeAppProfile(CONFIGDIR, profile);
const gatewayStatus = gatewayService.getStatus();
if (gatewayStatus.state === "running") {
gatewayService.updateConfig(profileGatewayConfig);
} else {
const startedStatus = await gatewayService.start(profileGatewayConfig);
if (startedStatus.state !== "running") {
throw new Error(startedStatus.lastError || "CCR gateway did not start.");
}
}
launchClaudeAppProfile(CONFIGDIR, profile, config);
startClaudeAppBotWorker(config, profile);
return {
message: `Opened Claude App with ${profile.name || profile.id}.`,
profileId: profile.id,
@@ -106,6 +122,102 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
};
}
function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): void {
const botEnv = botGatewayProfileEnv(config, profile, "app");
stopClaudeAppBotWorker();
if (botEnv.CCR_BOT_GATEWAY_ENABLED !== "true") {
return;
}
const runtimeFile = path.join(CONFIGDIR, "bin", "ccr-codex-cli-middleware.js");
ensureClaudeBotWorkerRuntime(runtimeFile);
const settingsFile = resolveClaudeCodeSettingsFile(CONFIGDIR, profile);
const claudeAppUserDataDir = resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile);
const nodeLaunch = nodeRuntimeLaunch();
const env: NodeJS.ProcessEnv = {
...process.env,
...stringRecord(profile.env),
...botEnv,
...(nodeLaunch.electronRunAsNode ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CLAUDE_USER_DATA_DIR: claudeAppUserDataDir,
CCR_CLAUDE_APP_USER_DATA_PATH: claudeAppUserDataDir,
CCR_CLAUDE_CODE_BOT_WORKER: "1",
CCR_CLAUDE_CODE_MODEL: profile.model.trim(),
CCR_CODEX_MODEL: profile.model.trim(),
CCR_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CCR_PROFILE_SURFACE: "app",
CODEXL_CODEX_WORKSPACE_NAME: profile.name || profile.id,
CODEXL_PROFILE_SURFACE: "app"
};
delete env.ELECTRON_NO_ATTACH_CONSOLE;
const child = spawn(nodeLaunch.command, [runtimeFile, "claude-bot-worker", "--workspace-name", profile.name || profile.id], {
detached: false,
env,
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true
});
claudeAppBotWorker = child;
child.stderr?.on("data", (chunk) => {
console.warn(`[profile] Claude App bot worker stderr: ${chunk.toString("utf8").trim()}`);
});
child.once("exit", (code, signal) => {
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
}
if (code && code !== 0) {
console.warn(`[profile] Claude App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`);
}
});
child.once("error", (error) => {
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
}
console.warn(`[profile] Claude App bot worker failed: ${formatError(error)}`);
});
}
function ensureClaudeBotWorkerRuntime(runtimeFile: string): void {
const content = codexCliMiddlewareRuntimeScript();
const existing = existsSync(runtimeFile) ? readFileSync(runtimeFile, "utf8") : "";
if (existing !== content) {
mkdirSync(path.dirname(runtimeFile), { recursive: true });
writeFileSync(runtimeFile, content);
if (process.platform !== "win32") {
chmodSync(runtimeFile, 0o755);
}
}
if (!content.includes("CCR_CLAUDE_CODE_BOT_WORKER") || !content.includes("claude-bot-worker")) {
throw new Error("Claude bot worker runtime does not contain the bot worker entrypoint.");
}
}
function stopClaudeAppBotWorker(): void {
const child = claudeAppBotWorker;
claudeAppBotWorker = undefined;
if (!child || child.killed) {
return;
}
try {
child.kill("SIGTERM");
} catch {
// The worker may have already exited.
}
}
function nodeRuntimeLaunch(): { command: string; electronRunAsNode: boolean } {
const configured = process.env.CCR_NODE_BIN?.trim();
if (configured) {
return { command: configured, electronRunAsNode: false };
}
return {
command: process.execPath,
electronRunAsNode: Boolean(process.versions.electron)
};
}
function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
const name = profile.name?.trim();
if (!name) {
@@ -407,6 +519,13 @@ function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stringRecord(value: Record<string, string> | undefined): Record<string, string> {
if (!value || typeof value !== "object") {
return {};
}
return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string"));
}
function findProfileApiKey(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
const keyId = profileApiKeyId(profile);
const key = config.APIKEYS.find((apiKey) => apiKey.id === keyId)?.key.trim();
+50 -19
View File
@@ -47,9 +47,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
const endpoint = gatewayEndpoint(config);
const settings = readJsonObject(settingsFile);
const env = {
...Object.fromEntries(stringRecord(settings.env)),
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
...withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))),
...profileEnv(profile)
};
env.ANTHROPIC_BASE_URL = endpoint;
env.ANTHROPIC_API_BASE_URL = endpoint;
@@ -421,15 +420,17 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string {
function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string {
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
})
const surface = normalizeProfileSurface(profile.surface);
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const botEnvExports = shellBotGatewayEnvExports(config, profile);
return [
"#!/bin/sh",
...envExports,
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
"export CCR_PROFILE_SURFACE",
...botEnvExports,
`export CCR_CLAUDE_CODE_WRAPPER=1`,
`export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
`export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
@@ -441,15 +442,16 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig,
function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string {
const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
})
const surface = normalizeProfileSurface(profile.surface);
const envExports = Object.entries(profileEnv(profile))
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
const botEnvExports = cmdBotGatewayEnvExports(config, profile);
return [
"@echo off",
...envExports,
`if not defined CCR_PROFILE_SURFACE set "CCR_PROFILE_SURFACE=${surface}"`,
...botEnvExports,
`set "CCR_CLAUDE_CODE_WRAPPER=1"`,
`set "CCR_REAL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`,
`set "CODEXL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`,
@@ -523,10 +525,8 @@ function codexMiddlewareShellScript(
const codexHome = profile.codexHome?.trim() || path.dirname(values.configFile);
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
const surface = normalizeProfileSurface(profile.surface);
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
}).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const botEnvExports = shellBotGatewayEnvExports(config, profile);
return [
"#!/bin/sh",
...envExports,
@@ -541,6 +541,7 @@ function codexMiddlewareShellScript(
`export CCR_PROFILE_SCOPE=${shellQuote(normalizeProfileScope(profile.scope))}`,
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
"export CCR_PROFILE_SURFACE",
...botEnvExports,
`export CODEXL_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
`export CODEXL_CODEX_PROFILE=${shellQuote(values.providerId)}`,
`export CODEXL_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`,
@@ -573,10 +574,8 @@ function codexMiddlewareCmdScript(
const surface = normalizeProfileSurface(profile.surface);
const providerId = values.providerId.replace(/"/g, '\\"');
const workspaceName = (profile.name || values.providerId).replace(/"/g, '\\"');
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
}).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
const botEnvExports = cmdBotGatewayEnvExports(config, profile);
return [
"@echo off",
...envExports,
@@ -590,6 +589,7 @@ function codexMiddlewareCmdScript(
`set "CCR_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`,
`set "CCR_PROFILE_SCOPE=${normalizeProfileScope(profile.scope)}"`,
`if not defined CCR_PROFILE_SURFACE set "CCR_PROFILE_SURFACE=${surface}"`,
...botEnvExports,
`set "CODEXL_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`,
`set "CODEXL_CODEX_PROFILE=${providerId}"`,
`set "CODEXL_CODEX_MODEL_PROVIDER=${providerId}"`,
@@ -608,6 +608,37 @@ function codexMiddlewareCmdScript(
].join("\r\n");
}
function shellBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] {
return [
'if [ "$CCR_PROFILE_SURFACE" = "app" ]; then',
...Object.entries(botGatewayProfileEnv(config, profile, "app")).map(([key, value]) => ` export ${key}=${shellQuote(value)}`),
"else",
...Object.entries(botGatewayProfileEnv(config, profile, "cli")).map(([key, value]) => ` export ${key}=${shellQuote(value)}`),
"fi"
];
}
function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] {
return [
`if /I "%CCR_PROFILE_SURFACE%"=="app" (`,
...Object.entries(botGatewayProfileEnv(config, profile, "app")).map(([key, value]) => ` set "${key}=${value.replace(/"/g, '\\"')}"`),
") else (",
...Object.entries(botGatewayProfileEnv(config, profile, "cli")).map(([key, value]) => ` set "${key}=${value.replace(/"/g, '\\"')}"`),
")"
];
}
function withoutBotGatewayEnv(values: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(values).filter(([key]) => !isBotGatewayEnvKey(key)));
}
function isBotGatewayEnvKey(key: string): boolean {
return key === "BOT_GATEWAY_STATE_DIR" ||
key.startsWith("CCR_BOT_") ||
key.startsWith("CODEXL_BOT_") ||
key === "CCR_BOT_GATEWAY_SDK_MODULE";
}
function removeRootTomlKeys(source: string, keys: string[]): string {
const keyPattern = keys.map(escapeRegExp).join("|");
const pattern = new RegExp(`^\\s*(?:${keyPattern})\\s*=.*(?:\\n|$)`, "gm");
+85 -50
View File
@@ -4,8 +4,9 @@ import { pathToFileURL } from "node:url";
import { deflateSync } from "node:zlib";
import { loadAppConfig } from "./config";
import { APP_NAME } from "./constants";
import { getProviderAccountSnapshots } from "./provider-account-service";
import { getTodayUsageTotals, onUsageRecorded } from "./usage-store";
import type { AppConfig, TrayIconPreference } from "../shared/app";
import type { AppConfig, ProviderAccountMeter, TrayBalanceProgressConfig, TrayIconPreference } from "../shared/app";
const popoverMenuWidth = 420;
const popoverPreferredHeight = 740;
@@ -37,8 +38,8 @@ class TrayController {
private resolvedRandomTrayIcon?: TrayMascotIconId;
private refreshTimer?: NodeJS.Timeout;
private tray?: Tray;
private trayBalanceProgress?: TrayBalanceProgressConfig;
private trayIconPreference: TrayIconPreference = "random";
private trayProgressTargetTokens = 100000;
private trayTotalTokens = 0;
private unsubscribeUsageUpdates?: () => void;
@@ -113,9 +114,9 @@ class TrayController {
this.resolvedRandomTrayIcon = undefined;
}
this.trayIconPreference = nextPreference;
this.trayProgressTargetTokens = normalizeTrayProgressTarget(nextConfig.trayProgressTargetTokens);
if (nextPreference === "progress") {
this.applyProgressTrayIcon(this.trayTotalTokens);
this.trayBalanceProgress = normalizeTrayBalanceProgressConfig(nextConfig.trayBalanceProgress);
if (nextPreference === "progress" && this.trayBalanceProgress) {
await this.refreshBalanceProgressTrayIcon();
return;
}
this.applyTrayIcon(this.resolveTrayIconId(nextPreference));
@@ -342,8 +343,8 @@ class TrayController {
try {
const totals = await getTodayUsageTotals(undefined, { includeProxy: true });
this.trayTotalTokens = Math.max(0, totals.totalTokens);
if (this.trayIconPreference === "progress") {
this.applyProgressTrayIcon(this.trayTotalTokens);
if (this.trayIconPreference === "progress" && this.trayBalanceProgress) {
await this.refreshBalanceProgressTrayIcon();
}
this.tray.setTitle(formatTokenTitle(totals.totalTokens));
} catch {
@@ -359,15 +360,28 @@ class TrayController {
this.tray.setImage(icon.isEmpty() ? nativeImage.createEmpty() : icon);
}
private applyProgressTrayIcon(totalTokens: number): void {
private applyProgressTrayIcon(progress: number): void {
if (!this.tray) {
return;
}
const progress = calculateTrayProgress(totalTokens, this.trayProgressTargetTokens);
const icon = createTrayProgressIcon(progress);
this.tray.setImage(icon.isEmpty() ? nativeImage.createEmpty() : icon);
}
private async refreshBalanceProgressTrayIcon(): Promise<void> {
if (!this.trayBalanceProgress) {
return;
}
try {
const snapshots = await getProviderAccountSnapshots(this.trayBalanceProgress.provider);
const snapshot = snapshots.find((account) => account.provider === this.trayBalanceProgress?.provider) ?? snapshots[0];
const meter = snapshot?.meters.find((candidate) => candidate.id === this.trayBalanceProgress?.meterId);
this.applyProgressTrayIcon(meter ? calculateTrayBalanceProgress(meter) : 0);
} catch {
this.applyProgressTrayIcon(0);
}
}
private refreshRandomTrayIconForCurrentDay(): void {
if (this.trayIconPreference !== "random") {
return;
@@ -471,7 +485,7 @@ function createTrayIcon(iconId: TrayMascotIconId): Electron.NativeImage {
function createTrayProgressIcon(progress: number): Electron.NativeImage {
const clamped = Math.max(0, Math.min(1, progress));
const image = nativeImage.createFromBuffer(createProgressRingPng(clamped));
const image = nativeImage.createFromBuffer(createBalanceProgressBarPng(clamped));
if (image.isEmpty()) {
return nativeImage.createEmpty();
}
@@ -486,71 +500,92 @@ function normalizeTrayIconPreference(value: unknown): TrayIconPreference {
: "random";
}
function normalizeTrayProgressTarget(value: unknown): number {
const target = Number(value);
if (!Number.isFinite(target) || target <= 0) {
return 100000;
function normalizeTrayBalanceProgressConfig(value: unknown): TrayBalanceProgressConfig | undefined {
if (!isRecord(value)) {
return undefined;
}
return Math.min(1_000_000_000, Math.max(1000, Math.trunc(target)));
const provider = readRecordString(value, "provider");
const meterId = readRecordString(value, "meterId");
return provider && meterId ? { meterId, provider } : undefined;
}
function calculateTrayProgress(totalTokens: number, targetTokens: number): number {
if (targetTokens <= 0) {
return 0;
function calculateTrayBalanceProgress(meter: ProviderAccountMeter): number {
if (meter.limit && meter.limit > 0) {
if (meter.remaining !== undefined) {
return Math.max(0, Math.min(1, meter.remaining / meter.limit));
}
if (meter.used !== undefined) {
return Math.max(0, Math.min(1, 1 - meter.used / meter.limit));
}
}
return Math.max(0, Math.min(1, totalTokens / targetTokens));
if (meter.unit === "%") {
if (meter.remaining !== undefined) {
return Math.max(0, Math.min(1, meter.remaining / 100));
}
if (meter.used !== undefined) {
return Math.max(0, Math.min(1, 1 - meter.used / 100));
}
}
const rawValue = meter.remaining ?? meter.limit ?? meter.used ?? 0;
return rawValue > 0 ? 1 : 0;
}
function createProgressRingPng(progress: number): Buffer {
function createBalanceProgressBarPng(progress: number): Buffer {
const size = 36;
const center = (size - 1) / 2;
const outerRadius = 15.2;
const ringRadius = 12.2;
const ringWidth = 4.2;
const rgba = Buffer.alloc(size * size * 4);
const track = { a: 0.55, b: 184, g: 163, r: 148 };
const fill = progress >= 0.99
? { a: 1, b: 153, g: 211, r: 52 }
: { a: 1, b: 250, g: 250, r: 248 };
const clamped = Math.max(0, Math.min(1, progress));
const track = { a: 0.48, b: 184, g: 163, r: 148 };
const fill = clamped <= 0.05
? { a: 1, b: 68, g: 68, r: 248 }
: clamped <= 0.2
? { a: 1, b: 36, g: 191, r: 245 }
: { a: 1, b: 252, g: 250, r: 248 };
const accent = { a: 0.95, b: 191, g: 212, r: 45 };
const background = { a: 0.92, b: 42, g: 23, r: 15 };
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const dx = px - center;
const dy = py - center;
const distance = Math.hypot(dx, dy);
const index = (y * size + x) * 4;
blendPngPixel(rgba, index, background, edgeAlpha(outerRadius - distance));
blendPngPixel(rgba, index, track, strokeAlpha(distance, ringRadius, ringWidth));
if (progress > 0 && isProgressArcPoint(dx, dy, progress)) {
blendPngPixel(rgba, index, fill, strokeAlpha(distance, ringRadius, ringWidth));
}
blendPngPixel(rgba, index, background, edgeAlpha(4.4 - distance));
blendPngPixel(rgba, index, background, roundedRectAlpha(px, py, 3, 3, 30, 30, 8));
blendPngPixel(rgba, index, { a: 0.74, b: 250, g: 250, r: 248 }, roundedRectAlpha(px, py, 7, 9, 12, 2.5, 1.25));
blendPngPixel(rgba, index, accent, roundedRectAlpha(px, py, 7, 15, 18, 2.5, 1.25));
blendPngPixel(rgba, index, track, roundedRectAlpha(px, py, 7, 22, 22, 5, 2.5));
blendPngPixel(rgba, index, fill, roundedRectAlpha(px, py, 7, 22, Math.max(2, 22 * clamped), 5, 2.5));
}
}
return encodePngRgba(rgba, size, size);
}
function isProgressArcPoint(dx: number, dy: number, progress: number): boolean {
if (progress >= 1) {
return true;
}
let angle = Math.atan2(dy, dx) + Math.PI / 2;
if (angle < 0) {
angle += Math.PI * 2;
}
return angle <= progress * Math.PI * 2;
function roundedRectAlpha(
px: number,
py: number,
x: number,
y: number,
width: number,
height: number,
radius: number
): number {
const halfWidth = Math.max(0, width / 2 - radius);
const halfHeight = Math.max(0, height / 2 - radius);
const centerX = x + width / 2;
const centerY = y + height / 2;
const dx = Math.abs(px - centerX) - halfWidth;
const dy = Math.abs(py - centerY) - halfHeight;
const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
const inside = Math.min(Math.max(dx, dy), 0);
return Math.max(0, Math.min(1, 0.5 - (outside + inside - radius)));
}
function edgeAlpha(distance: number): number {
return Math.max(0, Math.min(1, distance + 0.5));
function readRecordString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function strokeAlpha(distance: number, radius: number, width: number): number {
return Math.max(0, Math.min(1, width / 2 + 0.5 - Math.abs(distance - radius)));
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function blendPngPixel(
+1 -2
View File
@@ -42,8 +42,7 @@ class WindowsManager {
nodeIntegration: false,
preload: path.join(__dirname, "preload.js"),
sandbox: true,
webSecurity: true,
webviewTag: true
webSecurity: true
},
width
});
+20 -11
View File
@@ -16,8 +16,8 @@ import {
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference,
normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference,
normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, ProfileConfig, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
@@ -25,7 +25,7 @@ import {
providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
ResolvedTheme, resolvePluginInstallPlan, RouterRule, ServerActionBusy,
shouldAutoProbeProviderBaseUrl, splitLines, translateProxyCertificateMessage, translateText, TrayWidgetConfig,
shouldAutoProbeProviderBaseUrl, splitLines, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig,
uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect,
useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId,
VirtualModelDraft, virtualModelProfileFromDraft
@@ -969,11 +969,14 @@ function App() {
probe?.capabilities ?? [],
protocol && baseUrl ? [{ baseUrl, source: probe?.detectedProtocol ? "detected" : "preset", type: protocol }] : []
);
const existingProvider = providerEditIndex === undefined ? undefined : draftConfig.Providers[providerEditIndex];
const provider: GatewayProviderConfig = {
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
api_key: providerDraft.apiKey.trim(),
capabilities: capabilities.length > 0 ? capabilities : undefined,
account: accountConfig,
credentials: existingProvider?.credentials,
failover: existingProvider?.failover,
icon: providerDraft.icon.trim() || undefined,
models,
name: providerName,
@@ -1575,17 +1578,21 @@ function App() {
function changeTrayIconPreference(value: string) {
const trayIcon = normalizeTrayIconPreference(value);
if (trayIcon === "progress" && !normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress)) {
return;
}
updateConfig((config) => ({
...config,
trayIcon
}));
}
function changeTrayProgressTargetTokens(value: string) {
const trayProgressTargetTokens = normalizeTrayProgressTargetTokens(value);
updateConfig((config) => ({
...config,
trayProgressTargetTokens
function changeTrayBalanceProgress(config: TrayBalanceProgressConfig) {
const trayBalanceProgress = normalizeTrayBalanceProgressConfig(config);
updateConfig((current) => ({
...current,
trayBalanceProgress,
trayIcon: trayBalanceProgress ? "progress" : current.trayIcon === "progress" ? "random" : current.trayIcon
}));
}
@@ -1874,7 +1881,7 @@ function App() {
function openProfileDialog(index: number) {
const profile = draftConfig.profile.profiles[index];
if (!profile?.enabled || normalizeProfileScope(profile.scope) !== "ccr") {
if (!profile?.enabled) {
return;
}
setProfileActionError("");
@@ -2413,17 +2420,19 @@ function App() {
onCheckUpdate: checkForAppUpdate,
onChangeLanguage: changeLanguagePreference,
onChangeTheme: changeThemePreference,
onChangeTrayBalanceProgress: changeTrayBalanceProgress,
onChangeTrayIcon: changeTrayIconPreference,
onChangeTrayProgressTarget: changeTrayProgressTargetTokens,
onChangeTrayWidgets: changeTrayWidgets,
onClose: () => setSettingsOpen(false),
onDownloadUpdate: downloadAppUpdate,
onInstallUpdate: installAppUpdate,
profiles: draftConfig.profile.profiles,
systemLanguage,
systemTheme,
themePreference: draftConfig.theme || "system",
providerAccountSnapshots,
trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress),
trayIconPreference: draftConfig.trayIcon || "random",
trayProgressTargetTokens: draftConfig.trayProgressTargetTokens || 100000,
trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants),
updateActionBusy,
updateActionError,
+255 -14
View File
@@ -1,14 +1,14 @@
import {
AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, Button,
AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, BotHandoffScanTarget, Button,
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy,
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, motion,
DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion,
normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent,
profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery,
profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
Play, Search, SelectControl, Toggle, translateOptions, Trash2, useAppText, type ProfileOpenSurface, type VirtualModelProfileConfig,
Play, RefreshCw, Search, Select, SelectControl, Toggle, translateOptions, Trash2, useAppText, type ProfileOpenSurface, type VirtualModelProfileConfig,
copyTextToClipboard,
useEffect, useLayoutEffect, useMemo, useRef, useState, X
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X
} from "../shared";
export function ProfileView({
addProfile,
@@ -95,7 +95,7 @@ export function ProfileView({
</div>
<div className="flex shrink-0 items-center gap-2">
<Toggle checked={profile.enabled} onChange={(enabled) => updateProfileItem(index, { enabled })} />
{profile.enabled && scope === "ccr" ? (
{profile.enabled ? (
<Button aria-label={`${t("Open")} ${profile.name || t("Profile")}`} onClick={() => openProfile(index)} size="iconSm" title={t("Open")} type="button" variant="ghost">
<Play className="h-3.5 w-3.5" />
</Button>
@@ -723,7 +723,17 @@ export function AddProfileForm({
</Field>
<Field label={t("Entry mode")}>
<SelectControl
onChange={(surface) => onChange({ surface: normalizeProfileSurface(surface) })}
onChange={(surface) => {
const nextSurface = normalizeProfileSurface(surface);
onChange(nextSurface !== "cli"
? { surface: nextSurface }
: {
botConfigId: "",
botConfigured: true,
botEnabled: false,
surface: nextSurface
});
}}
options={translateOptions(profileSurfaceOptions, t)}
value={draft.surface}
/>
@@ -772,9 +782,11 @@ export function AddProfileForm({
</Field>
</>
)}
<div className="sm:col-span-2">
<BotGatewaySelectForm botConfigs={botConfigs} draft={draft} onChange={onChange} onCreateBot={onCreateBot} />
</div>
{draft.surface !== "cli" ? (
<div className="sm:col-span-2">
<BotGatewaySelectForm botConfigs={botConfigs} draft={draft} onChange={onChange} onCreateBot={onCreateBot} />
</div>
) : null}
<Field className="sm:col-span-2" label={t("Environment variables")}>
<KeyValueRowsControl
addLabel={t("Add env variable")}
@@ -793,6 +805,19 @@ export function AddProfileForm({
}
const ADD_BOT_SELECT_VALUE = "__add_bot__";
const HANDOFF_TARGET_NONE_VALUE = "__ccr_handoff_target_none__";
type BotHandoffScanState = {
error: string;
loading: boolean;
results: BotHandoffScanTarget[];
};
const emptyHandoffScanState: BotHandoffScanState = {
error: "",
loading: false,
results: []
};
function BotGatewaySelectForm({
botConfigs,
@@ -812,16 +837,68 @@ function BotGatewaySelectForm({
{ label: t("Add new bot"), value: ADD_BOT_SELECT_VALUE }
];
const selectedValue = draft.botEnabled && draft.botConfigId ? draft.botConfigId : "none";
const selectedBot = draft.botEnabled
? botConfigs.find((config) => config.id === selectedValue)
: undefined;
const [wifiScan, setWifiScan] = useState<BotHandoffScanState>(emptyHandoffScanState);
const [bluetoothScan, setBluetoothScan] = useState<BotHandoffScanState>(emptyHandoffScanState);
const autoHandoffScanRef = useRef(false);
const scanHandoffTargets = useCallback(async (kind: "bluetooth" | "wifi") => {
const setScan = kind === "wifi" ? setWifiScan : setBluetoothScan;
const scanner = kind === "wifi"
? window.ccr?.scanBotHandoffWifiTargets
: window.ccr?.scanBotHandoffBluetoothTargets;
if (!scanner) {
setScan({
error: t("Handoff target scan is available in the Electron app."),
loading: false,
results: []
});
return;
}
setScan({ ...emptyHandoffScanState, loading: true });
try {
const results = await scanner();
setScan({
error: "",
loading: false,
results
});
} catch (error) {
setScan({
error: error instanceof Error ? error.message : String(error),
loading: false,
results: []
});
}
}, [t]);
useEffect(() => {
if (!draft.botEnabled || !draft.botHandoffEnabled || !selectedBot) {
autoHandoffScanRef.current = false;
return;
}
if (autoHandoffScanRef.current) {
return;
}
autoHandoffScanRef.current = true;
void scanHandoffTargets("wifi");
void scanHandoffTargets("bluetooth");
}, [draft.botEnabled, draft.botHandoffEnabled, scanHandoffTargets, selectedBot]);
function updateEnabled(botEnabled: boolean) {
if (!botEnabled) {
onChange({ botConfigId: "", botConfigured: true, botEnabled: false });
return;
}
const nextBotConfigId = draft.botConfigId || botConfigs[0]?.id || "";
const nextBot = botConfigs.find((config) => config.id === nextBotConfigId);
onChange({
botConfigId: draft.botConfigId || botConfigs[0]?.id || "",
botConfigId: nextBotConfigId,
botConfigured: true,
botEnabled: true
botEnabled: true,
botForwardAllAgentMessages: nextBot ? nextBot.botGateway.forwardAllAgentMessages !== false : draft.botForwardAllAgentMessages
});
}
@@ -834,26 +911,190 @@ function BotGatewaySelectForm({
onChange({ botConfigId: "", botConfigured: true, botEnabled: false });
return;
}
onChange({ botConfigId: value, botConfigured: true, botEnabled: true });
const nextBot = botConfigs.find((config) => config.id === value);
onChange({
botConfigId: value,
botConfigured: true,
botEnabled: true,
botForwardAllAgentMessages: nextBot ? nextBot.botGateway.forwardAllAgentMessages !== false : draft.botForwardAllAgentMessages
});
}
const botScopeHint = t("Messages are forwarded only when using the corresponding app.");
return (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-[12px] font-medium">{t("Bot")}</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="text-[12px] font-medium">{t("Bot")}</span>
<span aria-label={botScopeHint} title={botScopeHint}>
<Info
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
</span>
</span>
<Toggle checked={draft.botEnabled} onChange={updateEnabled} />
</div>
{draft.botEnabled ? (
<div className="mt-3 border-t border-border/70 pt-3">
<div className="mt-3 space-y-3 border-t border-border/70 pt-3">
<Field label={t("Select bot")}>
<SelectControl onChange={updateBot} options={options} value={selectedValue} />
</Field>
{selectedBot ? (
<>
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<span className="text-[12px] font-medium">{t("Forward agent messages")}</span>
<Toggle checked={draft.botForwardAllAgentMessages} onChange={(botForwardAllAgentMessages) => onChange({ botForwardAllAgentMessages })} />
</div>
<div className="rounded-md border border-border bg-background p-3">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-[12px] font-medium">{t("Handoff")}</span>
<Toggle checked={draft.botHandoffEnabled} onChange={(botHandoffEnabled) => onChange({ botHandoffEnabled })} />
</div>
{draft.botHandoffEnabled ? (
<div className="mt-3 grid grid-cols-1 gap-3 border-t border-border/70 pt-3 sm:grid-cols-2">
<Field label={t("Idle seconds")}>
<Input
min={30}
max={86400}
type="number"
value={draft.botHandoffIdleSeconds}
onChange={(event) => onChange({ botHandoffIdleSeconds: event.target.value })}
/>
</Field>
<HandoffTargetPicker
label={t("Phone Wi-Fi target")}
scan={wifiScan}
selectedTarget={firstHandoffTarget(draft.botHandoffPhoneWifiTargets)}
onRefresh={() => void scanHandoffTargets("wifi")}
onSelect={(botHandoffPhoneWifiTargets) => onChange({ botHandoffPhoneWifiTargets })}
/>
<HandoffTargetPicker
className="sm:col-span-2"
label={t("Phone Bluetooth target")}
scan={bluetoothScan}
selectedTarget={firstHandoffTarget(draft.botHandoffPhoneBluetoothTargets)}
onRefresh={() => void scanHandoffTargets("bluetooth")}
onSelect={(botHandoffPhoneBluetoothTargets) => onChange({ botHandoffPhoneBluetoothTargets })}
/>
</div>
) : null}
</div>
</>
) : null}
</div>
) : null}
</div>
);
}
function HandoffTargetPicker({
className,
label,
scan,
selectedTarget,
onRefresh,
onSelect
}: {
className?: string;
label: string;
scan: BotHandoffScanState;
selectedTarget: string;
onRefresh: () => void;
onSelect: (targetValue: string) => void;
}) {
const t = useAppText();
const options = selectedTarget && !scan.results.some((target) => handoffTargetMatchesSavedValue(target, selectedTarget))
? [
{
detail: "",
id: `selected:${selectedTarget}`,
label: selectedTarget,
source: "selected",
target: selectedTarget
},
...scan.results
]
: scan.results;
const placeholderText = scan.loading
? t("Scanning targets")
: options.length > 0
? t("Select a scanned target")
: t("No targets found");
const selectedOption = options.find((target) => handoffTargetMatchesSavedValue(target, selectedTarget));
const selectValue = selectedTarget || HANDOFF_TARGET_NONE_VALUE;
const selectOptions = [
...(selectedTarget ? [{ label: t("None"), value: HANDOFF_TARGET_NONE_VALUE }] : []),
...(!selectedTarget ? [{ disabled: true, label: placeholderText, value: HANDOFF_TARGET_NONE_VALUE }] : []),
...options.map((target) => ({
label: handoffTargetSelectionText(target),
value: handoffTargetSavedValue(target)
}))
];
return (
<div className={cn("min-w-0 space-y-1", className)}>
<span className="block truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2">
<Select
className="min-w-0 flex-1"
disabled={scan.loading || (!selectedTarget && options.length === 0)}
onValueChange={(value) => onSelect(value === HANDOFF_TARGET_NONE_VALUE ? "" : value)}
options={selectOptions}
value={selectValue}
/>
<Button
className="h-8 w-8 border-0 bg-transparent p-0 shadow-none hover:bg-transparent"
aria-label={t("Refresh targets")}
disabled={scan.loading}
onClick={onRefresh}
title={t("Refresh targets")}
type="button"
unstyled
>
<RefreshCw className={cn("h-5 w-5 text-muted-foreground hover:text-foreground", scan.loading && "animate-spin")} />
</Button>
</div>
{selectedOption?.detail ? (
<div className="truncate text-[11px] text-muted-foreground" title={selectedOption.detail}>
{selectedOption.detail}
</div>
) : null}
{scan.error ? (
<div className="break-words text-[11px] text-destructive">{scan.error}</div>
) : null}
</div>
);
}
function firstHandoffTarget(value: string): string {
return value.split(/\r?\n/).map((item) => item.trim()).find(Boolean) ?? "";
}
function handoffTargetSelectionText(target: BotHandoffScanTarget): string {
if (target.source !== "bluetooth") {
return target.label;
}
const label = target.label.trim();
const value = target.target.trim();
if (!label || !value || label === value || label.includes(value)) {
return label || value;
}
return `${label}(${value})`;
}
function handoffTargetSavedValue(target: BotHandoffScanTarget): string {
if (target.source === "bluetooth") {
return handoffTargetSelectionText(target);
}
return target.target;
}
function handoffTargetMatchesSavedValue(target: BotHandoffScanTarget, savedValue: string): boolean {
return target.target === savedValue || handoffTargetSavedValue(target) === savedValue;
}
export function AddProfileDialog({
botConfigs,
canSubmit,
File diff suppressed because it is too large Load Diff
+116 -40
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import {
closestCenter,
DndContext,
@@ -38,6 +38,7 @@ import {
FolderOpen,
Gauge,
Globe,
Info,
KeyRound,
Layers3,
LoaderCircle,
@@ -97,6 +98,7 @@ import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import appLogoUrl from "../../../../assets/logo.png";
import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png";
import codexLogoUrl from "@/assets/agent-logos/codex.png";
import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg";
@@ -146,8 +148,10 @@ import type {
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
BotGatewayQrWindowOpenResult,
BotGatewayRuntimeConfig,
BotGatewaySavedConfig,
BotHandoffScanTarget,
GatewayProviderConfig,
GatewayProviderCapability,
GatewayPluginAppConfig,
@@ -197,6 +201,7 @@ import type {
RouterFallbackMode,
RouterRule,
RouterRuleType,
TrayBalanceProgressConfig,
TrayComponentVariants,
TrayWidgetConfig,
TrayWidgetType,
@@ -235,13 +240,13 @@ import {
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "../../../shared/provider-url";
export {
createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,
createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,
closestCenter, DndContext, DragOverlay, getFirstCollision, KeyboardSensor, MeasuringStrategy, pointerWithin,
PointerSensor, rectIntersection, useSensor, useSensors, arrayMove, rectSortingStrategy, SortableContext,
sortableKeyboardCoordinates, useSortable, CSS, AnimatePresence, LayoutGroup, motion, useReducedMotion,
Activity, ArrowDown, ArrowUp, Box, Boxes, Braces, Check, CheckCircle2,
ChevronDown, ChevronLeft, ChevronRight, CircleAlert, Copy, Database, FolderOpen,
ExternalLink, Gauge, Globe, KeyRound, Layers3, LoaderCircle, MoveRight, Network,
ExternalLink, Gauge, Globe, Info, KeyRound, Layers3, LoaderCircle, MoveRight, Network,
Palette, PanelLeftClose, PanelLeftOpen, Pause, Pencil, Play, Plus,
Power, QrCode, RefreshCw, Route, Search, Server, Settings, ShieldCheck,
Trash2, UserRound, X, Area, Bar, BarChart, CartesianGrid,
@@ -249,7 +254,7 @@ export {
XAxis, YAxis, Badge, Button, Card, CardContent, CardHeader,
CardTitle, Checkbox, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
DialogTitle, Input, Label, PopoverContent, Select, Switch, Textarea,
cn, claudeCodeLogoUrl, codexLogoUrl, onboardingMascotSpriteUrl, anthropicProviderIconUrl, bailianProviderIconUrl, deepseekProviderIconUrl,
cn, appLogoUrl, claudeCodeLogoUrl, codexLogoUrl, onboardingMascotSpriteUrl, anthropicProviderIconUrl, bailianProviderIconUrl, deepseekProviderIconUrl,
geminiProviderIconUrl, mistralProviderIconUrl, moonshotProviderIconUrl, openaiProviderIconUrl, openrouterProviderIconUrl, siliconflowProviderIconUrl, zaiGlobalCodingProviderIconUrl,
zaiGlobalGeneralProviderIconUrl, zhipuCnCodingProviderIconUrl, zhipuCnGeneralProviderIconUrl, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, BUILTIN_FUSION_TOOL_SERVER_NAME,
BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, enforceSingleEnabledGlobalProfilePerAgent, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS,
@@ -259,14 +264,14 @@ export {
export type {
HTMLAttributes, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent,
LucideIcon, AgentAnalysisFilter, AgentAnalysisSnapshot, AgentKind, AppConfig, AppInfo, AppUpdateStatus, ApiKeyConfig,
ApiKeyLimitConfig, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, BotGatewayQrLoginStartRequest, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig,
ApiKeyLimitConfig, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, BotGatewayQrLoginStartRequest, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, BotHandoffScanTarget, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig,
GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType,
OverviewWidgetVariant, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProviderAccountConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig,
ProviderAccountMeter, ProviderAccountStandardConnectorConfig, ProviderAccountSnapshot, ProviderAccountTestPath, ProviderAccountTestResult, ProviderDeepLinkPayload, ProviderDeepLinkRequest,
ProfileConfig, ProfileOpenSurface, CodexProfileConfigFormat, ProfileScope, ProfileSurface, ProxyCertificateInstallResult, ProxyCertificateStatus, ProxyNetworkBody,
ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, RequestLogEntry, RequestLogListFilter, RequestLogPage,
RequestLogStatusFilter, RouterConfig, RouterFallbackConfig, RouterFallbackMode, RouterRule, RouterRuleType, TrayComponentVariants,
TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow, UsageSeriesPoint, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, UsageTotals,
TrayBalanceProgressConfig, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow, UsageSeriesPoint, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, UsageTotals,
VirtualModelBaseModelMode, VirtualModelExecutionMode, VirtualModelFusionCustomToolConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider, VirtualModelProfileConfig, VirtualModelToolVisibility, ProviderIdentitySafetyIssue, ProviderPreset, ProviderPresetEndpoint
};
@@ -309,6 +314,10 @@ export type AppCopy = {
themeSystem: string;
tray: string;
update: string;
trayBalanceProgressAccount: string;
trayBalanceProgressData: string;
trayBalanceProgressNoData: string;
trayBalanceProgressRequired: string;
trayIcon: string;
trayIconCyan: string;
trayIconOrange: string;
@@ -399,12 +408,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
themeSystem: "System",
tray: "Tray",
update: "Updates",
trayBalanceProgressAccount: "Account",
trayBalanceProgressData: "Data",
trayBalanceProgressNoData: "No account data is available. Enable account monitoring on a provider first.",
trayBalanceProgressRequired: "Choose an account and data to enable balance progress.",
trayIcon: "Tray mascot",
trayIconCyan: "Cyan",
trayIconOrange: "Orange",
trayIconProgress: "Progress ring",
trayIconCyan: "Auralis",
trayIconOrange: "Solara",
trayIconProgress: "Balance progress",
trayIconRandom: "Random",
trayIconViolet: "Violet",
trayIconViolet: "Vesper",
trayComponentAccount: "Account meter",
trayComponentArc: "Arc",
trayComponentArea: "Area",
@@ -647,12 +660,16 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
themeSystem: "跟随系统",
tray: "Tray",
update: "更新",
trayBalanceProgressAccount: "账户",
trayBalanceProgressData: "数据",
trayBalanceProgressNoData: "暂无可用账户数据,请先为供应商启用账户监控。",
trayBalanceProgressRequired: "请选择账户和数据后启用余额进度条。",
trayIcon: "托盘小精灵",
trayIconCyan: "青色小精灵",
trayIconOrange: "橙色小精灵",
trayIconProgress: "圆形进度条",
trayIconCyan: "晴岚",
trayIconOrange: "暖阳",
trayIconProgress: "余额进度条",
trayIconRandom: "随机",
trayIconViolet: "紫色小精灵",
trayIconViolet: "星澜",
trayComponentAccount: "账户指标",
trayComponentArc: "弧形",
trayComponentArea: "面积图",
@@ -753,13 +770,23 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Feishu": "飞书",
"DingTalk": "钉钉",
"QR Login": "扫码登录",
"Weixin QR login": "微信扫码登录",
"Weixin Login": "微信登录",
"Weixin QR code": "微信二维码",
"QR login is available in the Electron app.": "扫码登录仅在 Electron App 中可用。",
"QR window is available in the Electron app.": "二维码窗口仅在 Electron App 中可用。",
"QR scan timed out.": "扫码超时。",
"QR scan observation failed.": "扫码状态观测失败。",
"QR scan observation ended unexpectedly.": "扫码状态观测异常结束。",
"Preparing Weixin login.": "正在准备微信登录。",
"QR login canceled.": "扫码登录已取消。",
"Weixin login requires a web login URL.": "微信登录需要网页形式的登录地址。",
"Generate QR code": "生成二维码",
"Generating QR code": "正在生成二维码",
"Open QR window": "打开二维码窗口",
"Scan the QR code in Weixin.": "请使用微信扫描二维码。",
"Scan with Weixin to connect this bot.": "使用微信扫码连接这个 Bot。",
"Scan with Weixin in the opened window.": "请在打开的窗口中使用微信扫码。",
"Weixin login window closed, confirming login status.": "微信登录窗口已关闭,正在确认登录状态。",
"Waiting for QR code": "等待生成二维码",
"Waiting for scan": "等待扫码",
"Scanned, confirm on phone": "已扫码,请在手机上确认",
@@ -849,9 +876,11 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Cursor Proxy routes": "Cursor Proxy 路由",
"Custom": "自定义",
"Delete": "删除",
"Delete bot": "删除 Bot",
"Delete Extension": "删除扩展",
"Delete Provider": "删除供应商",
"Delete Routing Rule": "删除路由规则",
"Delete this bot?": "删除这个 Bot",
"Delete this extension from the configuration?": "从配置中删除这个扩展?",
"Delete this provider from the configuration?": "从配置中删除这个供应商?",
"Delete this routing rule from the configuration?": "从配置中删除这条路由规则?",
@@ -888,17 +917,19 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Fallback chain": "回退链",
"Fallback model": "回退模型",
"Failure handling": "故障处理",
"First enabled": "首个启用规则",
"Forward agent messages": "转发 Agent 消息",
"Messages are forwarded only when using the corresponding app.": "仅在使用对应 App 时才会转发消息。",
"First enabled": "首个启用规则",
"Gateway conversation ID": "网关会话 ID",
"Generated config": "生成配置",
"Generated path": "生成路径",
"Group": "群组",
"Handoff": "接力",
"Handoff target scan is available in the Electron app.": "接力目标扫描仅在 Electron App 中可用。",
"Headers": "请求头",
"Header rows require keys.": "请求头行必须填写 Key。",
"Fetch usage": "获取用量",
"Fetch manifest": "拉取 manifest",
"Handoff": "Handoff",
"Hide advanced settings": "收起高级设置",
"HTTP JSON request": "HTTP JSON 请求",
"Host": "主机",
@@ -960,6 +991,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"No requests captured yet": "暂无请求记录",
"No bots configured": "尚未配置 Bot",
"No route activity": "暂无路由活动",
"No targets found": "未发现目标",
"None": "无",
"Not configured": "未配置",
"Not running": "未运行",
@@ -1053,7 +1085,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Search providers or models": "搜索供应商或模型",
"Search request logs": "搜索请求日志",
"Search routing rules": "搜索路由规则",
"Select account": "选择账户",
"Select bot": "选择 Bot",
"Select data": "选择数据",
"Server": "服务",
"Startup timeout ms": "启动超时 ms",
"State directory": "状态目录",
@@ -1487,10 +1521,13 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Resize request list and detail panels": "调整请求列表和详情面板",
"Resize request/response": "调整请求/响应",
"Response": "响应",
"Refresh targets": "刷新目标",
"Restart Proxy": "重启代理",
"Select provider": "选择供应商",
"Select a scanned target": "选择扫描到的目标",
"Selected": "已选择",
"Service": "服务",
"Scanning targets": "正在扫描目标",
"Service status": "服务状态",
"Step": "步骤",
"Start service": "启动服务",
@@ -1519,7 +1556,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Update failed": "更新失败",
"Update ready to install": "更新已准备安装",
"Updates are only available in packaged builds.": "在线更新仅在打包后的应用中可用。",
"This action is applied immediately to the draft config and will auto-save with other changes.": "此操作会立即应用到草稿配置,并随其他变更自动保存。",
"After deletion, this bot data cannot be recovered.": "删除后数据不可恢复。",
"This bot is being used by the following agents and cannot be deleted.": "当前 Bot 正在被以下 Agent 使用,不能删除。",
"{count} agent profiles use this bot": "{count} 个 Agent 使用中",
"This provider link came from an external website. Review details before importing.": "这个供应商链接来自外部网站。导入前请确认下面的内容。",
"Welcome to CCR": "欢迎使用CCR",
"Trusted": "已信任",
@@ -1943,7 +1982,7 @@ export const fallbackConfig: AppConfig = {
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "/Users/jinhuilee/products/bot-gateway",
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
@@ -3259,24 +3298,26 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
const botConfigId = profile.botConfigId || matchingBotConfigId(profile.botGateway, botConfigs);
const selectedBot = botConfigId ? botConfigs.find((config) => config.id === botConfigId) : undefined;
if (profile.agent === "claude-code") {
const surface = normalizeProfileSurface(profile.surface);
return {
...createProfileDraft("claude-code", profile.name),
...botDraft,
botConfigId,
botEnabled: Boolean(selectedBot || profile.botGateway?.enabled),
botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled),
envRows: keyValueRowsFromRecord(profile.env ?? {}),
model: profile.model,
scope: normalizeProfileFormScope(profile.scope),
settingsFile: profile.settingsFile ?? "~/.claude/settings.json",
smallFastModel: profile.smallFastModel ?? "",
surface: normalizeProfileSurface(profile.surface)
surface
};
}
const surface = normalizeProfileSurface(profile.surface);
return {
...createProfileDraft("codex", profile.name),
...botDraft,
botConfigId,
botEnabled: Boolean(selectedBot || profile.botGateway?.enabled),
botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled),
configFile: profile.configFile ?? "~/.codex/config.toml",
envRows: keyValueRowsFromRecord(profile.env ?? {}),
model: profile.model,
@@ -3284,7 +3325,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs
providerName: profile.providerName ?? "Claude Code Router",
scope: normalizeProfileFormScope(profile.scope),
showAllSessions: Boolean(profile.showAllSessions),
surface: normalizeProfileSurface(profile.surface)
surface
};
}
@@ -3295,7 +3336,11 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean {
if (!validateProfileEnvRows(draft.envRows)) {
return false;
}
if (draft.botEnabled && !draft.botConfigId.trim()) {
const botAllowed = draft.surface !== "cli";
if (botAllowed && draft.botEnabled && !draft.botConfigId.trim()) {
return false;
}
if (botAllowed && draft.botEnabled && draft.botHandoffEnabled && !isNumberDraftValid(draft.botHandoffIdleSeconds, 30, 86_400)) {
return false;
}
if (draft.agent === "claude-code") {
@@ -3326,11 +3371,19 @@ export function profileConfigFromDraft(
botConfigs: BotGatewaySavedConfig[] = []
): ProfileConfig {
const id = existingProfile?.id ?? uniqueProfileId(existingProfiles, draft.name || draft.agent);
const selectedBot = draft.botEnabled
const botAllowed = draft.surface !== "cli";
const selectedBot = botAllowed && draft.botEnabled
? botConfigs.find((config) => config.id === draft.botConfigId.trim())
: undefined;
const botGateway = selectedBot
? { botConfigId: selectedBot.id, botGateway: selectedBot.botGateway }
? {
botConfigId: selectedBot.id,
botGateway: {
...selectedBot.botGateway,
forwardAllAgentMessages: draft.botForwardAllAgentMessages,
handoff: botGatewayHandoffFromProfileDraft(draft, selectedBot.botGateway.handoff)
}
}
: {};
return normalizeProfileItem({
agent: draft.agent,
@@ -3351,6 +3404,22 @@ export function profileConfigFromDraft(
}, existingProfiles.length);
}
function botGatewayHandoffFromProfileDraft(
draft: AddProfileDraft,
fallback: BotGatewayRuntimeConfig["handoff"] = fallbackConfig.botGateway.handoff
): BotGatewayRuntimeConfig["handoff"] {
return {
...fallbackConfig.botGateway.handoff,
...fallback,
enabled: draft.botHandoffEnabled,
idleSeconds: numberDraftValue(draft.botHandoffIdleSeconds, fallback.idleSeconds ?? fallbackConfig.botGateway.handoff.idleSeconds, 30, 86_400),
phoneBluetoothTargets: splitDraftLines(draft.botHandoffPhoneBluetoothTargets).slice(0, 1),
phoneWifiTargets: splitDraftLines(draft.botHandoffPhoneWifiTargets).slice(0, 1),
screenLock: fallback.screenLock ?? fallbackConfig.botGateway.handoff.screenLock,
userIdle: fallback.userIdle ?? fallbackConfig.botGateway.handoff.userIdle
};
}
export function createBotGatewayConfigDraft(config?: BotGatewaySavedConfig): BotGatewayConfigDraft {
const botDraft = createBotGatewayDraft(config?.botGateway);
return {
@@ -3376,8 +3445,7 @@ export function isBotGatewayConfigDraftSubmittable(draft: BotGatewayConfigDraft)
return false;
}
return (
botGatewayMissingRequiredAuthFields(draft.botAuthFields, platform, authType).length === 0 &&
isNumberDraftValid(draft.botHandoffIdleSeconds, 30, 86_400)
botGatewayMissingRequiredAuthFields(draft.botAuthFields, platform, authType).length === 0
);
}
@@ -3420,18 +3488,13 @@ function botGatewayConfigFromDraft(
authType,
autoStartIntegration: true,
command: "",
createIntegration: draft.botEnabled !== false && platform !== "none",
createIntegration: draft.botEnabled !== false && platform !== "none" && authType !== "qr_login",
credentials: authPayload.credentials,
cwd: "",
enabled: draft.botEnabled !== false,
forwardAllAgentMessages: draft.botForwardAllAgentMessages,
handoff: {
enabled: draft.botHandoffEnabled,
idleSeconds: numberDraftValue(draft.botHandoffIdleSeconds, fallbackConfig.botGateway.handoff.idleSeconds, 30, 86_400),
phoneBluetoothTargets: splitDraftLines(draft.botHandoffPhoneBluetoothTargets).slice(0, 1),
phoneWifiTargets: splitDraftLines(draft.botHandoffPhoneWifiTargets).slice(0, 1),
screenLock: true,
userIdle: true
...fallbackConfig.botGateway.handoff
},
integrationConfig: authPayload.integrationConfig,
integrationId: existingBotGateway?.integrationId?.trim() || createBotGatewayIntegrationId(configId),
@@ -3795,6 +3858,7 @@ export function profileSummaryItems(
config: AppConfig,
t: (value: string) => string
): Array<{ label: string; value: string }> {
const surface = normalizeProfileSurface(profile.surface);
const envCount = Object.keys(profile.env ?? {}).length;
const envSummaryItems = envCount > 0
? [{ label: t("Environment variables"), value: String(envCount) }]
@@ -3803,9 +3867,9 @@ export function profileSummaryItems(
? config.botConfigs.find((item) => item.id === profile.botConfigId)
: undefined;
const resolvedBotGateway = savedBot?.botGateway ?? profile.botGateway ?? config.botGateway;
const botSummaryItems = resolvedBotGateway?.enabled && resolvedBotGateway.platform !== "none"
const botSummaryItems = surface !== "cli" && resolvedBotGateway?.enabled && resolvedBotGateway.platform !== "none"
? [{ label: t("Bot"), value: `${t("Enabled")} (${savedBot ? botGatewaySavedConfigLabel(savedBot, t) : t(botGatewayPlatformLabel(resolvedBotGateway.platform))})` }]
: profile.botGateway
: surface !== "cli" && profile.botGateway
? [{ label: t("Bot"), value: t("Disabled") }]
: [];
const smallFastModel = profile.smallFastModel?.trim() || "";
@@ -3856,8 +3920,8 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro
const scope = normalizeProfileScope(profile.scope);
const surface = normalizeProfileSurface(profile.surface);
const env = isPlainRecord(profile.env) ? stringRecordValue(profile.env) : {};
const botGateway = normalizeBotGatewayRuntimeConfig(profile.botGateway);
const botConfigId = stringValue(profile.botConfigId);
const botGateway = surface !== "cli" ? normalizeBotGatewayRuntimeConfig(profile.botGateway) : undefined;
const botConfigId = surface !== "cli" ? stringValue(profile.botConfigId) : "";
if (profile.agent === "claude-code") {
return {
agent: "claude-code",
@@ -4969,6 +5033,15 @@ export function normalizeTrayIconPreference(value: unknown): AppConfig["trayIcon
: "random";
}
export function normalizeTrayBalanceProgressConfig(value: unknown): TrayBalanceProgressConfig | undefined {
if (!isPlainRecord(value)) {
return undefined;
}
const provider = typeof value.provider === "string" ? value.provider.trim() : "";
const meterId = typeof value.meterId === "string" ? value.meterId.trim() : "";
return provider && meterId ? { meterId, provider } : undefined;
}
export function normalizeTrayProgressTargetTokens(value: unknown): number {
return Math.min(1_000_000_000, Math.max(1000, positiveInteger(value) ?? 100000));
}
@@ -5334,6 +5407,8 @@ export function normalizeConfig(config: AppConfig): AppConfig {
const profiles = Array.isArray(profileConfig.profiles)
? normalizeProfileItems(profileConfig.profiles)
: legacyProfileItemsFromProfileConfig(profileConfig);
const trayBalanceProgress = normalizeTrayBalanceProgressConfig(config.trayBalanceProgress);
const trayIcon = normalizeTrayIconPreference(config.trayIcon);
return {
...fallbackConfig,
@@ -5378,8 +5453,9 @@ export function normalizeConfig(config: AppConfig): AppConfig {
plugins: Array.isArray(config.plugins) ? config.plugins : [],
providerPlugins: Array.isArray(config.providerPlugins) ? config.providerPlugins : [],
theme: normalizeThemePreference(config.theme),
trayBalanceProgress,
trayComponentVariants: normalizeTrayComponentVariants(config.trayComponentVariants),
trayIcon: normalizeTrayIconPreference(config.trayIcon),
trayIcon: trayIcon === "progress" && !trayBalanceProgress ? "random" : trayIcon,
trayProgressTargetTokens: normalizeTrayProgressTargetTokens(config.trayProgressTargetTokens),
trayWidgets: normalizeTrayWidgets(config.trayWidgets, config.trayWindowModules, config.trayComponentVariants),
trayWindowModules: normalizeTrayWindowModules(config.trayWindowModules),
+2 -4
View File
@@ -1,6 +1,6 @@
import {
AppConfig, createSourceTabs, DEFAULT_TRAY_WIDGETS, defaultTrayWidgetVariant, emptySnapshots, formatCompactNumber, formatProviderName,
formatPercent, formatUpdated, formatUsdCost, normalizeTrayIconPreference, normalizeTrayWidgets, ProviderAccountSnapshot, rangeLabel,
formatPercent, formatUpdated, formatUsdCost, normalizeTrayWidgets, ProviderAccountSnapshot, rangeLabel,
SnapshotMap, SourceTab, TrayComponentVariants, TrayWidgetConfig, UsageComparisonRow, UsageStatsFilter, UsageStatsRange, UsageTotals, useCallback, useEffect,
useMemo, useState, useTrayText
} from "./shared";
@@ -20,7 +20,6 @@ export function TrayApp() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string>();
const [trayIconPreference, setTrayIconPreference] = useState<AppConfig["trayIcon"]>("random");
const [snapshots, setSnapshots] = useState<SnapshotMap>(emptySnapshots);
const [accountSnapshots, setAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
const [trayWidgets, setTrayWidgets] = useState<TrayWidgetConfig[]>(DEFAULT_TRAY_WIDGETS);
@@ -52,7 +51,6 @@ export function TrayApp() {
setAllSnapshots((current) => ({ ...current, "30d": allMonth ?? month }));
setAccountSnapshots(accounts);
setConfiguredProviders(config.Providers.map((provider) => provider.name.trim()).filter(Boolean));
setTrayIconPreference(normalizeTrayIconPreference(config.trayIcon));
setTrayWidgets(normalizeTrayWidgets(config.trayWidgets, config.trayWindowModules, config.trayComponentVariants));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
@@ -112,7 +110,7 @@ export function TrayApp() {
return (
<main className="h-screen w-screen overflow-hidden bg-transparent text-slate-100">
<aside className="flex h-full min-h-0 flex-col overflow-y-auto rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-50 shadow-[0_18px_42px_rgba(15,23,42,.28)]">
<TrayStatusStrip totalTokens={activeTotals.totalTokens} trayIconPreference={trayIconPreference} />
<TrayStatusStrip totalTokens={activeTotals.totalTokens} />
<section className="space-y-2">
{trayWidgets.map((widget, index) => (
+2 -4
View File
@@ -1,5 +1,5 @@
import {
AppConfig, DEFAULT_TRAY_WIDGETS, emptySnapshots, normalizeTrayIconPreference,
DEFAULT_TRAY_WIDGETS, emptySnapshots,
normalizeTrayWidgets, ProviderAccountSnapshot, SnapshotMap, TrayWidgetConfig, UsageStatsFilter,
UsageStatsRange, useCallback, useEffect, useState, useTrayText
} from "./shared";
@@ -14,7 +14,6 @@ export function TrayDetailApp({ provider }: { provider?: string }) {
const [range, setRange] = useState<UsageStatsRange>("30d");
const [snapshots, setSnapshots] = useState<SnapshotMap>(emptySnapshots);
const [accountSnapshots, setAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
const [trayIconPreference, setTrayIconPreference] = useState<AppConfig["trayIcon"]>("random");
const [trayWidgets, setTrayWidgets] = useState<TrayWidgetConfig[]>(DEFAULT_TRAY_WIDGETS);
const refresh = useCallback(async () => {
@@ -38,7 +37,6 @@ export function TrayDetailApp({ provider }: { provider?: string }) {
]);
setSnapshots({ today, "24h": day, "7d": week, "30d": month });
setAccountSnapshots(accounts);
setTrayIconPreference(normalizeTrayIconPreference(config.trayIcon));
setTrayWidgets(normalizeTrayWidgets(config.trayWidgets, config.trayWindowModules, config.trayComponentVariants));
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
@@ -75,7 +73,7 @@ export function TrayDetailApp({ provider }: { provider?: string }) {
<main
className="h-screen w-screen overflow-y-auto rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-100 shadow-[0_18px_42px_rgba(15,23,42,.28)]"
>
<TrayStatusStrip totalTokens={snapshots[range].totals.totalTokens} trayIconPreference={trayIconPreference} />
<TrayStatusStrip totalTokens={snapshots[range].totals.totalTokens} />
<UsageDetailPanel activeStats={snapshots[range]} accountSnapshots={accountSnapshots} provider={provider} range={range} widgets={trayWidgets} onRangeChange={setRange} />
{loading ? <div className="mt-2 text-[11px] font-medium text-slate-200/60">{t("Syncing usage...")}</div> : null}
{error ? <div className="mt-3 rounded-lg border border-rose-400/24 bg-rose-500/18 px-3 py-2 text-[12px] font-medium text-rose-100">{error}</div> : null}
@@ -1,19 +1,14 @@
import {
AppConfig, formatCompactNumber, isTrayMascotIconPreference, Power, trayMascotIconUrls, useTrayText
appLogoUrl, formatCompactNumber, Power, useTrayText
} from "../shared";
export function TrayStatusStrip({
totalTokens,
trayIconPreference
}: {
totalTokens: number;
trayIconPreference: AppConfig["trayIcon"];
}) {
export function TrayStatusStrip({ totalTokens }: { totalTokens: number }) {
const t = useTrayText();
return (
<div className="mb-3 flex min-w-0 items-center justify-between gap-3 border-b border-white/10 pb-2">
<div className="flex min-w-0 items-center gap-2">
<TrayIconPreview className="h-7 w-7 border-white/15 bg-white/10" preference={trayIconPreference} />
<TrayWindowHeaderIcon />
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold text-slate-50">{formatCompactNumber(totalTokens)} {t("tokens")}</div>
<div className="truncate text-[10px] font-medium text-slate-400">CCR</div>
@@ -32,67 +27,13 @@ export function TrayStatusStrip({
);
}
function TrayIconPreview({
className,
preference
}: {
className?: string;
preference: AppConfig["trayIcon"];
}) {
const randomIcons: Array<"violet" | "orange" | "cyan"> = ["violet", "orange", "cyan"];
function TrayWindowHeaderIcon() {
return (
<span
aria-hidden="true"
className={[
"relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/10 bg-white/[.04] shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)]",
className ?? ""
].join(" ")}
className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/15 bg-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)]"
>
{preference === "random" ? (
randomIcons.map((iconId, index) => (
<img
alt=""
className={[
"absolute h-[66%] w-[66%] object-contain drop-shadow-sm",
index === 0 ? "left-[9%] top-[22%]" : "",
index === 1 ? "left-[22%] top-[11%]" : "",
index === 2 ? "left-[34%] top-[27%]" : ""
].join(" ")}
key={iconId}
src={trayMascotIconUrls[iconId]}
/>
))
) : null}
{isTrayMascotIconPreference(preference) ? (
<img alt="" className="h-[88%] w-[88%] object-contain drop-shadow-sm" src={trayMascotIconUrls[preference]} />
) : null}
{preference === "progress" ? <TrayProgressPreview /> : null}
<img alt="" className="h-[72%] w-[72%] object-contain" src={appLogoUrl} />
</span>
);
}
function TrayProgressPreview() {
const radius = 12.2;
const circumference = 2 * Math.PI * radius;
const progress = 0.68;
return (
<svg aria-hidden="true" className="h-[80%] w-[80%]" viewBox="0 0 36 36">
<circle cx="18" cy="18" fill="rgba(15,23,42,.92)" r="15.2" />
<circle cx="18" cy="18" fill="none" r={radius} stroke="rgba(148,163,184,.55)" strokeWidth="4.2" />
<circle
cx="18"
cy="18"
fill="none"
r={radius}
stroke="rgb(248,250,252)"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
strokeLinecap="round"
strokeWidth="4.2"
transform="rotate(-90 18 18)"
/>
</svg>
);
}
+4 -2
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { Power } from "lucide-react";
import appLogoUrl from "../../../../assets/logo.png";
import trayCyanIconUrl from "../../../../assets/tray-cyan.png";
import trayOrangeIconUrl from "../../../../assets/tray-orange.png";
import trayVioletIconUrl from "../../../../assets/tray-violet.png";
@@ -9,6 +10,7 @@ import type {
AppConfig,
ProviderAccountMeter,
ProviderAccountSnapshot,
TrayBalanceProgressConfig,
TrayComponentVariants,
TrayWidgetConfig,
TrayWidgetType,
@@ -23,10 +25,10 @@ import type {
export {
createContext, useCallback, useContext, useEffect, useMemo, useState, createRoot,
Power, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS
Power, appLogoUrl, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS
};
export type {
ReactNode, AppConfig, ProviderAccountMeter, ProviderAccountSnapshot, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow,
ReactNode, AppConfig, ProviderAccountMeter, ProviderAccountSnapshot, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow,
UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, UsageTotals
};
+9 -14
View File
@@ -1,6 +1,5 @@
export {};
import type * as React from "react";
import type {
AgentAnalysisFilter,
AgentAnalysisSnapshot,
@@ -14,6 +13,11 @@ import type {
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
BotGatewayQrWindowCloseRequest,
BotGatewayQrWindowCloseResult,
BotGatewayQrWindowOpenRequest,
BotGatewayQrWindowOpenResult,
BotHandoffScanTarget,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
GatewayMcpToolInfo,
@@ -46,24 +50,12 @@ import type {
} from "../../shared/app";
declare global {
namespace JSX {
interface IntrinsicElements {
webview: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
allowpopups?: boolean | string;
partition?: string;
preload?: string;
src?: string;
title?: string;
webpreferences?: string;
};
}
}
interface Window {
ccr?: {
applyClaudeAppGateway: (config?: AppConfig) => Promise<ClaudeAppGatewayApplyResult>;
applyProfile: () => Promise<ProfileApplyResult>;
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => Promise<BotGatewayQrLoginCancelResult>;
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise<BotGatewayQrWindowCloseResult>;
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
closeTray: () => Promise<void>;
detectProviderIcon: (request: ProviderIconDetectionRequest) => Promise<ProviderIconDetectionResult>;
@@ -86,6 +78,7 @@ declare global {
installProxyCertificate: () => Promise<ProxyCertificateInstallResult>;
listMcpServerTools: (server: GatewayMcpServerConfig) => Promise<GatewayMcpToolInfo[]>;
openBuiltInBrowser: () => Promise<void>;
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => Promise<BotGatewayQrWindowOpenResult>;
openExternal: (url: string) => Promise<void>;
openProfile: (request: ProfileOpenRequest) => Promise<ProfileOpenResult>;
probeProvider: (request: GatewayProviderProbeRequest) => Promise<GatewayProviderProbeResult>;
@@ -103,6 +96,8 @@ declare global {
startGateway: () => Promise<GatewayStatus>;
startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => Promise<BotGatewayQrLoginStartResult>;
stopGateway: () => Promise<GatewayStatus>;
scanBotHandoffBluetoothTargets: () => Promise<BotHandoffScanTarget[]>;
scanBotHandoffWifiTargets: () => Promise<BotHandoffScanTarget[]>;
testProviderAccountConnector: (request: ProviderAccountTestRequest) => Promise<ProviderAccountTestResult>;
updateCheck: () => Promise<AppUpdateStatus>;
updateDownload: () => Promise<AppUpdateStatus>;
+64
View File
@@ -66,8 +66,10 @@ export type GatewayProviderConfig = {
baseurl?: string;
billing?: unknown;
capabilities?: GatewayProviderCapability[];
credentials?: ProviderCredentialConfig[];
extraBody?: unknown;
extraHeaders?: unknown;
failover?: ProviderFailoverConfig;
icon?: string;
models: string[];
name: string;
@@ -76,6 +78,31 @@ export type GatewayProviderConfig = {
type?: GatewayProviderProtocol | string;
};
export type ProviderCredentialConfig = {
account?: ProviderAccountConfig;
api_key?: string;
apiKey?: string;
apikey?: string;
enabled?: boolean;
id: string;
label?: string;
limits?: ApiKeyLimitConfig;
priority?: number;
weight?: number;
};
export type ProviderFailoverStrategy =
| "failover-only"
| "least-utilized"
| "priority-spillover"
| "weighted-round-robin";
export type ProviderFailoverConfig = {
cooldownMs?: number;
spilloverThreshold?: number;
strategy?: ProviderFailoverStrategy;
};
export type ProviderAccountAuthMode = "provider-api-key" | "provider-api-key-raw" | "none";
export type ProviderAccountConnectorSource = "standard" | "http-json" | "plugin" | "local-estimate" | "merged" | "unsupported";
export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "unsupported";
@@ -558,6 +585,11 @@ export type ProxyRuntimeConfig = {
export type TrayIconPreference = "random" | "violet" | "orange" | "cyan" | "progress";
export type TrayBalanceProgressConfig = {
meterId: string;
provider: string;
};
export type TrayAccountComponentVariant = "bar" | "compact" | "ring" | "arc" | "stacked";
export type TrayFlowComponentVariant = "line" | "area" | "bar" | "sparkline";
export type TrayStatsComponentVariant = "cards" | "compact" | "pills";
@@ -888,6 +920,14 @@ export type BotGatewayHandoffConfig = {
userIdle: boolean;
};
export type BotHandoffScanTarget = {
detail: string;
id: string;
label: string;
source: "bluetooth" | "selected" | "wifi" | string;
target: string;
};
export type BotGatewayConversationConfig = {
gatewayConversationId?: string;
platformConversationId?: string;
@@ -967,6 +1007,29 @@ export type BotGatewayQrLoginCancelResult = {
canceled: boolean;
};
export type BotGatewayQrWindowOpenRequest = {
scanTimeoutMs?: number;
sessionId: string;
title?: string;
url: string;
waitForScan?: boolean;
};
export type BotGatewayQrWindowOpenResult = {
message?: string;
observed?: boolean;
opened: boolean;
reason?: "closed" | "error" | "scan_detected" | "timeout";
};
export type BotGatewayQrWindowCloseRequest = {
sessionId: string;
};
export type BotGatewayQrWindowCloseResult = {
closed: boolean;
};
export type AppConfig = {
APIKEY: string;
APIKEYS: ApiKeyConfig[];
@@ -989,6 +1052,7 @@ export type AppConfig = {
overviewWidgets: OverviewWidgetConfig[];
routerEndpoint: string;
theme: "system" | "light" | "dark";
trayBalanceProgress?: TrayBalanceProgressConfig;
trayProgressTargetTokens: number;
trayComponentVariants: TrayComponentVariants;
trayIcon: TrayIconPreference;
+4
View File
@@ -27,6 +27,10 @@ export const IPC_CHANNELS = {
appBotGatewayQrLoginCancel: "ccr:app:bot-gateway-qr-login-cancel",
appBotGatewayQrLoginStart: "ccr:app:bot-gateway-qr-login-start",
appBotGatewayQrLoginWait: "ccr:app:bot-gateway-qr-login-wait",
appBotGatewayQrWindowClose: "ccr:app:bot-gateway-qr-window-close",
appBotGatewayQrWindowOpen: "ccr:app:bot-gateway-qr-window-open",
appBotHandoffBluetoothTargetsScan: "ccr:app:bot-handoff-bluetooth-targets-scan",
appBotHandoffWifiTargetsScan: "ccr:app:bot-handoff-wifi-targets-scan",
appProbeProvider: "ccr:app:probe-provider",
appProviderDeepLink: "ccr:app:provider-deep-link",
appGetPluginMarketplace: "ccr:app:get-plugin-marketplace",