mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-30 17:11:12 +08:00
Refactor router config and model selection flow
This commit is contained in:
@@ -6,3 +6,8 @@ release
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.idea
|
||||
.claude
|
||||
.bot-gateway-state
|
||||
.agent-data
|
||||
tmp
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -42,8 +42,7 @@ class WindowsManager {
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
webviewTag: true
|
||||
webSecurity: true
|
||||
},
|
||||
width
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Vendored
+9
-14
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user