Add configurable model routing and provider docs

This commit is contained in:
musistudio
2026-06-19 18:30:33 +08:00
parent e3ec3cabde
commit d7fc9726fb
23 changed files with 3550 additions and 93 deletions
+13
View File
@@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"electron-updater": "^6.8.9",
"node-forge": "^1.4.0",
"sql.js": "^1.14.1",
@@ -2074,6 +2075,18 @@
"node": ">=20"
}
},
"node_modules/@the-next-ai/bot-gateway-sdk": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@the-next-ai/bot-gateway-sdk/-/bot-gateway-sdk-0.1.0.tgz",
"integrity": "sha512-AedLi7oqf3ChqPllvy3h+h6plGZf/Rxbpp5TdmfAKeXjJ7ezaWVxRtsOA6EFAVXYLTtYQ+Sy0fZThW5FucKYKQ==",
"license": "MIT",
"bin": {
"bot-gateway-stdio": "bin/bot-gateway-stdio.mjs"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+1
View File
@@ -22,6 +22,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"electron-updater": "^6.8.9",
"node-forge": "^1.4.0",
"sql.js": "^1.14.1",
+230
View File
@@ -0,0 +1,230 @@
import os from "node:os";
import { createRequire } from "node:module";
import path from "node:path";
import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig } 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));
if (!bot?.enabled || !bot.platform || bot.platform === "none") {
return disabledBotGatewayEnv();
}
const handoff = bot.handoff ?? {
enabled: false,
idleSeconds: 30,
phoneBluetoothTargets: [],
phoneWifiTargets: [],
screenLock: true,
userIdle: true
};
const stateDir = resolveBotGatewayStateDir(bot, profile);
const env: Record<string, string> = {
BOT_GATEWAY_STATE_DIR: stateDir,
CCR_BOT_GATEWAY_ACK_EVENTS: boolEnv(bot.acknowledgeEvents),
CCR_BOT_GATEWAY_ARGS_JSON: JSON.stringify(bot.args ?? []),
CCR_BOT_GATEWAY_AUTH_TYPE: bot.authType ?? "",
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_CREDENTIALS_JSON: JSON.stringify(bot.credentials ?? {}),
CCR_BOT_GATEWAY_CWD: bot.cwd ?? "",
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES: boolEnv(bot.forwardAllAgentMessages),
CCR_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "",
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 ?? "",
...botGatewaySdkEnv(),
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000),
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr",
CCR_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled),
CCR_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30),
CCR_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS: (handoff.phoneBluetoothTargets ?? []).join("\n"),
CCR_BOT_HANDOFF_PHONE_WIFI_TARGETS: (handoff.phoneWifiTargets ?? []).join("\n"),
CCR_BOT_HANDOFF_SCREEN_LOCK: boolEnv(handoff.screenLock),
CCR_BOT_HANDOFF_USER_IDLE: boolEnv(handoff.userIdle),
CCR_BOT_PROFILE_ID: profile.id,
CCR_BOT_PROFILE_NAME: profile.name,
CODEXL_BOT_GATEWAY_ENABLED: "true",
CODEXL_BOT_GATEWAY_FORWARD_ALL_CODEX_MESSAGES: boolEnv(bot.forwardAllAgentMessages),
CODEXL_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "",
CODEXL_BOT_GATEWAY_PLATFORM: bot.platform,
CODEXL_BOT_GATEWAY_STATE_DIR: stateDir,
CODEXL_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr",
CODEXL_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled),
CODEXL_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30),
CODEXL_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS: (handoff.phoneBluetoothTargets ?? []).join("\n"),
CODEXL_BOT_HANDOFF_PHONE_WIFI_TARGETS: (handoff.phoneWifiTargets ?? []).join("\n"),
CODEXL_BOT_HANDOFF_SCREEN_LOCK: boolEnv(handoff.screenLock),
CODEXL_BOT_HANDOFF_USER_IDLE: boolEnv(handoff.userIdle)
};
if (bot.conversationRef) {
env.CCR_BOT_GATEWAY_CONVERSATION_REF_JSON = JSON.stringify(bot.conversationRef);
}
return env;
}
function resolveBotGatewayConfig(config: AppConfig, profile: ProfileConfig): BotGatewayRuntimeConfig {
const savedBot = profile.botConfigId
? (config.botConfigs ?? []).find((item) => item.id === profile.botConfigId)
: undefined;
return savedBot?.botGateway ?? profile.botGateway ?? config.botGateway;
}
function botGatewaySdkEnv(): Record<string, string> {
const sdkModule = resolveBotGatewaySdkModule();
return sdkModule ? { CCR_BOT_GATEWAY_SDK_MODULE: sdkModule } : {};
}
function resolveBotGatewaySdkModule(): string {
try {
return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js");
} catch {
return "";
}
}
function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig {
const platform = normalizeBotGatewayPlatform(bot.platform);
return {
...bot,
authType: normalizeBotGatewayAuthType(platform, bot.authType),
credentials: sanitizeBotGatewayRecord(bot.credentials),
integrationConfig: websocketBotGatewayIntegrationConfig(platform, bot.integrationConfig),
platform
};
}
function normalizeBotGatewayPlatform(value: string): string {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === "off" || normalized === "disabled") {
return "none";
}
if (normalized === "lark") {
return "feishu";
}
if (normalized === "dingding") {
return "dingtalk";
}
if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) {
return "weixin-ilink";
}
if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) {
return "wecom";
}
return normalized || "none";
}
function normalizeBotGatewayAuthType(platform: string, value: string): string {
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
if (!platform || platform === "none") {
return "";
}
if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") {
return defaultBotGatewayAuthType(platform);
}
if (normalized === "appsecret") {
return "app_secret";
}
if (normalized === "bottoken" || normalized === "token") {
return "bot_token";
}
if (normalized === "oauth" || normalized === "oauth_2") {
return "oauth2";
}
if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) {
return "qr_login";
}
return normalized;
}
function defaultBotGatewayAuthType(platform: string): string {
if (platform === "weixin-ilink") {
return "qr_login";
}
if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") {
return "app_secret";
}
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
return "";
}
function websocketBotGatewayIntegrationConfig(platform: string, value: Record<string, unknown>): Record<string, unknown> {
const config = sanitizeBotGatewayRecord(value);
delete config.transport;
delete config.sendMode;
const transport = botGatewayWebSocketTransport(platform);
return transport ? { ...config, transport } : config;
}
function botGatewayWebSocketTransport(platform: string): string {
if (!platform || platform === "none") {
return "";
}
return platform === "slack" ? "socket" : "websocket";
}
function sanitizeBotGatewayRecord(value: Record<string, unknown> | undefined): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (!value || typeof value !== "object" || Array.isArray(value)) {
return result;
}
for (const [key, rawValue] of Object.entries(value)) {
if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) {
continue;
}
result[key] = rawValue;
}
return result;
}
function isWebhookRelatedBotGatewayKey(key: string): boolean {
const normalized = key.trim().toLowerCase().replace(/[_-]+/g, "");
return normalized.includes("webhook") || normalized === "sendmode";
}
function disabledBotGatewayEnv(): Record<string, string> {
return {
CCR_BOT_GATEWAY_ENABLED: "false",
CODEXL_BOT_GATEWAY_ENABLED: "false"
};
}
function resolveBotGatewayStateDir(bot: BotGatewayRuntimeConfig, profile: ProfileConfig): string {
const configured = (bot.stateDir ?? "").trim();
if (configured) {
return resolveUserPath(configured);
}
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "default";
return path.join(CONFIGDIR, "bot-gateway", slug);
}
function resolveUserPath(value: string): string {
const trimmed = value.trim();
if (trimmed === "~") {
return os.homedir();
}
if (trimmed.startsWith("~/")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return path.resolve(trimmed || ".");
}
function sanitizePathSegment(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
function boolEnv(value: boolean): string {
return value ? "true" : "false";
}
+428
View File
@@ -0,0 +1,428 @@
import { mkdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { CONFIGDIR } from "./constants";
import type {
BotGatewayQrLoginCancelRequest,
BotGatewayQrLoginCancelResult,
BotGatewayQrLoginStartRequest,
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
BotGatewayRuntimeConfig
} from "../shared/app";
type BotGatewayClientWithRequest = {
close?: () => Promise<void> | void;
health: () => Promise<unknown>;
request: <T = unknown>(method: string, params?: unknown) => Promise<T>;
};
type BotGatewaySdkModule = {
createBotGatewayClient: (options?: unknown) => unknown;
};
type QrSession = {
botConfigId: string;
client: BotGatewayClientWithRequest;
credentials: Record<string, unknown>;
integrationConfig: Record<string, unknown>;
integrationId: string;
platform: string;
stateDir: string;
tenantId: string;
timeoutMs: number;
};
const qrSessions = new Map<string, QrSession>();
let sdkPromise: Promise<BotGatewaySdkModule> | undefined;
export async function startBotGatewayQrLogin(
request: BotGatewayQrLoginStartRequest
): Promise<BotGatewayQrLoginStartResult> {
const savedConfig = request.config;
const bot = normalizeBotGatewayForQr(savedConfig.botGateway);
if (!bot.enabled || bot.platform !== "weixin-ilink" || bot.authType !== "qr_login") {
throw new Error("微信扫码登录只支持微信平台的扫码认证方式。");
}
const stateDir = resolveBotGatewayStateDir(bot, savedConfig.id);
mkdirSync(stateDir, { recursive: true });
const client = await createQrClient(bot, stateDir);
const timeoutMs = Math.max(1000, bot.requestTimeoutMs || 600000);
let registered = false;
try {
await withTimeout(client.health(), Math.max(1000, bot.startupTimeoutMs || 10000), "Bot Gateway health check timed out.");
const integrationId = await resolveWeixinQrIntegrationId(client, bot, timeoutMs);
const rawStart = await botGatewayClientRequest(client, "auth.qr.start", {
config: bot.integrationConfig,
credentials: bot.credentials,
force: request.force !== false,
integrationId,
platform: bot.platform,
tenantId: bot.tenantId
}, timeoutMs);
const auth = unwrapGatewayResult(rawStart);
const sessionId = stringValue(auth.sessionId);
if (!sessionId) {
throw new Error("Bot Gateway QR start response missing sessionId.");
}
const previous = qrSessions.get(sessionId);
if (previous) {
closeQrClient(previous.client);
}
qrSessions.set(sessionId, {
botConfigId: savedConfig.id,
client,
credentials: bot.credentials,
integrationConfig: bot.integrationConfig,
integrationId,
platform: bot.platform,
stateDir,
tenantId: bot.tenantId,
timeoutMs
});
registered = true;
return {
botConfigId: savedConfig.id,
expiresAt: stringValue(auth.expiresAt),
integrationId,
message: stringValue(auth.message),
platform: bot.platform,
qrCodeUrl: stringValue(auth.qrCodeUrl),
sessionId,
stateDir,
tenantId: bot.tenantId
};
} catch (error) {
if (!registered) {
closeQrClient(client);
}
throw error;
}
}
export async function waitBotGatewayQrLogin(
request: BotGatewayQrLoginWaitRequest
): Promise<BotGatewayQrLoginWaitResult> {
const sessionId = request.sessionId.trim();
const session = qrSessions.get(sessionId);
if (!session) {
throw new Error("微信扫码登录会话不存在,请重新生成二维码。");
}
const rawWait = await botGatewayClientRequest(session.client, "auth.qr.wait", {
autoStart: true,
config: session.integrationConfig,
configOverride: {
...session.integrationConfig,
transport: botGatewayWebSocketTransport(session.platform)
},
credentials: session.credentials,
integrationId: session.integrationId,
platform: session.platform,
sessionId,
tenantId: session.tenantId,
timeoutMs: Math.max(1000, request.timeoutMs || 5000),
verifyCode: request.verifyCode?.trim() || undefined
}, session.timeoutMs);
const auth = unwrapGatewayResult(rawWait);
const status = stringValue(auth.status) || "pending";
const confirmed = status === "confirmed";
const result = {
confirmed,
integrationId: session.integrationId,
message: stringValue(auth.message),
sessionId,
stateDir: session.stateDir,
status,
tenantId: session.tenantId
};
if (isTerminalQrLoginStatus(status)) {
qrSessions.delete(sessionId);
closeQrClient(session.client);
}
return result;
}
export function cancelBotGatewayQrLogin(
request: BotGatewayQrLoginCancelRequest
): BotGatewayQrLoginCancelResult {
const sessionId = request.sessionId.trim();
const session = qrSessions.get(sessionId);
if (session) {
qrSessions.delete(sessionId);
closeQrClient(session.client);
}
return { canceled: Boolean(session) };
}
async function createQrClient(bot: BotGatewayRuntimeConfig, stateDir: string): Promise<BotGatewayClientWithRequest> {
const sdk = await loadBotGatewaySdk();
const client = sdk.createBotGatewayClient({
transport: "stdio",
env: {
...process.env,
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()
}
: {})
}) as BotGatewayClientWithRequest;
if (!client || typeof client.request !== "function" || typeof client.health !== "function") {
throw new Error("Bot Gateway SDK client does not expose request().");
}
return client;
}
async function loadBotGatewaySdk(): Promise<BotGatewaySdkModule> {
if (!sdkPromise) {
sdkPromise = importBotGatewaySdk();
}
return sdkPromise;
}
async function importBotGatewaySdk(): Promise<BotGatewaySdkModule> {
const candidates = [
process.env.CCR_BOT_GATEWAY_SDK_MODULE,
"@the-next-ai/bot-gateway-sdk"
].filter((value): value is string => Boolean(value?.trim()));
const errors: string[] = [];
for (const candidate of candidates) {
try {
const sdk = await import(botGatewaySdkImportSpecifier(candidate));
if (sdk && typeof sdk.createBotGatewayClient === "function") {
return sdk as BotGatewaySdkModule;
}
errors.push(`${candidate}: missing createBotGatewayClient export`);
} catch (error) {
errors.push(`${candidate}: ${formatError(error)}`);
}
}
throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`);
}
function botGatewaySdkImportSpecifier(value: string): string {
const trimmed = value.trim();
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
return trimmed;
}
if (path.isAbsolute(trimmed)) {
return pathToFileURL(trimmed).href;
}
return trimmed;
}
async function resolveWeixinQrIntegrationId(
client: BotGatewayClientWithRequest,
bot: BotGatewayRuntimeConfig,
timeoutMs: number
): Promise<string> {
const requested = bot.integrationId.trim();
const raw = await botGatewayClientRequest(client, "integrations.list", {}, timeoutMs).catch(() => ({}));
const result = unwrapGatewayResult(raw);
const integrations = Array.isArray(result.integrations) ? result.integrations.filter(isRecord) : [];
const requestedIntegration = integrations.find((integration) => stringValue(integration.id) === requested);
if (requestedIntegration) {
if (stringValue(requestedIntegration.platform) === "weixin-ilink") {
return requested;
}
} else if (requested) {
return requested;
}
const tenant = bot.tenantId.trim();
const tenantIntegration = integrations.find((integration) =>
stringValue(integration.platform) === "weixin-ilink" &&
stringValue(integration.tenantId).toLowerCase() === tenant.toLowerCase()
);
if (tenantIntegration) {
const id = stringValue(tenantIntegration.id);
if (id) return id;
}
const platformIntegration = integrations.find((integration) => stringValue(integration.platform) === "weixin-ilink");
if (platformIntegration) {
const id = stringValue(platformIntegration.id);
if (id) return id;
}
return requested || safePathSegment(`weixin-ilink-${tenant || "ccr"}`);
}
function normalizeBotGatewayForQr(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig {
const platform = normalizeBotGatewayPlatform(bot.platform);
const authType = normalizeBotGatewayAuthType(platform, bot.authType);
return {
...bot,
authType,
credentials: sanitizeBotGatewayRecord(bot.credentials),
integrationConfig: websocketBotGatewayIntegrationConfig(platform, bot.integrationConfig),
platform,
tenantId: bot.tenantId.trim() || "ccr"
};
}
function websocketBotGatewayIntegrationConfig(platform: string, value: Record<string, unknown>): Record<string, unknown> {
const config = sanitizeBotGatewayRecord(value);
delete config.transport;
delete config.sendMode;
const transport = botGatewayWebSocketTransport(platform);
return transport ? { ...config, transport } : config;
}
function botGatewayWebSocketTransport(platform: string): string {
if (!platform || platform === "none") {
return "";
}
return platform === "slack" ? "socket" : "websocket";
}
function normalizeBotGatewayPlatform(value: string): string {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === "off" || normalized === "disabled") {
return "none";
}
if (normalized === "lark") {
return "feishu";
}
if (normalized === "dingding") {
return "dingtalk";
}
if (normalized === "wechat" || normalized === "weixin-work" || normalized === "wework") {
return "wecom";
}
return normalized;
}
function normalizeBotGatewayAuthType(platform: string, value: string): string {
const normalized = value.trim().toLowerCase().replace(/[-\s]+/g, "_");
const aliases: Record<string, string> = {
qr: "qr_login",
qr_code: "qr_login",
qr_login: "qr_login",
qrcode: "qr_login",
token: "bot_token"
};
const authType = aliases[normalized] ?? normalized;
if (platform === "weixin-ilink") {
return authType || "qr_login";
}
return authType;
}
function sanitizeBotGatewayRecord(value: Record<string, unknown> | undefined): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (!value || typeof value !== "object" || Array.isArray(value)) {
return result;
}
for (const [key, rawValue] of Object.entries(value)) {
if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) {
continue;
}
result[key] = rawValue;
}
return result;
}
function isWebhookRelatedBotGatewayKey(key: string): boolean {
const normalized = key.trim().toLowerCase().replace(/[_-]+/g, "");
return normalized.includes("webhook") || normalized === "sendmode";
}
function unwrapGatewayResult(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
return {};
}
const result = value.result;
return isRecord(result) ? result : value;
}
function botGatewayClientRequest(
client: BotGatewayClientWithRequest,
method: string,
params: unknown,
timeoutMs: number
): Promise<unknown> {
return withTimeout(client.request(method, params), timeoutMs, `Bot Gateway request timed out: ${method}`);
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
const timeout = Math.max(1000, timeoutMs || 30000);
let timer: NodeJS.Timeout | undefined;
return new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeout);
promise.then(
(value) => {
if (timer) clearTimeout(timer);
resolve(value);
},
(error) => {
if (timer) clearTimeout(timer);
reject(error);
}
);
});
}
function closeQrClient(client: BotGatewayClientWithRequest): void {
try {
const result = client.close?.();
if (result && typeof (result as Promise<void>).catch === "function") {
(result as Promise<void>).catch(() => undefined);
}
} catch {
// Best-effort cleanup for a short-lived QR login helper process.
}
}
function resolveBotGatewayStateDir(bot: BotGatewayRuntimeConfig, configId: string): string {
const configured = bot.stateDir.trim();
if (configured) {
return resolveUserPath(configured);
}
const slug = safePathSegment(configId || bot.integrationId || bot.tenantId) || "default";
return path.join(CONFIGDIR, "bot-gateway", slug);
}
function resolveUserPath(value: string): string {
const trimmed = value.trim();
if (trimmed === "~") {
return os.homedir();
}
if (trimmed.startsWith("~/")) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
}
function safePathSegment(value: string): string {
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
}
function isTerminalQrLoginStatus(status: string): boolean {
return ["already_bound", "confirmed", "expired", "failed"].includes(status);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileOpenSurface } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { launchCodexAppProfile } from "./codex-app-launch";
import { buildProfileLaunchPlan, findProfileForOpen, resolveProfileOpenSurface } from "./profile-launch-core";
@@ -41,7 +42,8 @@ async function main(): Promise<void> {
const childEnv = {
...process.env,
...plan.env
...plan.env,
...botGatewayProfileEnv(config, profile)
};
delete childEnv.ELECTRON_RUN_AS_NODE;
+2
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileConfig } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { codexModelCatalogBase64 } from "./codex-model-catalog";
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "./profile-launch-core";
@@ -43,6 +44,7 @@ export function launchCodexAppProfile(configDir: string, profile: ProfileConfig,
const env: NodeJS.ProcessEnv = {
...process.env,
...plan.env,
...(config ? botGatewayProfileEnv(config, profile) : {}),
...codexProfileEnv(profile),
CODEX_CLI_PATH: plan.command,
CODEX_ELECTRON_USER_DATA_PATH: userDataDir,
+695
View File
@@ -8,6 +8,7 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const readline = require("node:readline");
const { pathToFileURL } = require("node:url");
const VERSION = "3.0.0";
const DEFAULT_MODEL = "claude-sonnet-4-5";
@@ -16,9 +17,14 @@ 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();
async function main() {
const args = process.argv.slice(2);
if (process.env.CCR_CLAUDE_CODE_WRAPPER === "1") {
await runClaudeCodeCliWrapper(args);
return;
}
if (shouldRunClaudeCodeAppServer(args)) {
await runClaudeCodeAppServer(args);
return;
@@ -26,6 +32,34 @@ async function main() {
await runCodexCliMiddleware(args.length === 0 ? defaultCodexArgs() : args);
}
async function runClaudeCodeCliWrapper(args) {
const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude");
log("claude_code_wrapper_start", { realCli, args });
const child = childProcess.spawn(realCli, args, {
env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]),
stdio: ["inherit", "pipe", "inherit"]
});
child.on("error", (error) => {
log("claude_code_wrapper_spawn_error", { error: formatError(error) });
});
let pending = "";
child.stdout.on("data", (chunk) => {
process.stdout.write(chunk);
pending += chunk.toString("utf8");
const lines = pending.split(/\r?\n/g);
pending = lines.pop() || "";
for (const line of lines) {
BOT_BRIDGE.handleClaudeCliLine(line);
}
});
const code = await waitForChild(child);
if (pending.trim()) {
BOT_BRIDGE.handleClaudeCliLine(pending);
}
log("claude_code_wrapper_exit", { code });
process.exitCode = code;
}
function defaultCodexArgs() {
return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli"
? []
@@ -70,6 +104,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);
if (!shouldSuppressBotBridgeLine(rewritten)) {
process.stdout.write(rewritten + "\n");
}
@@ -1380,6 +1415,7 @@ function writeNotification(method, params) {
}
function writeRaw(value) {
BOT_BRIDGE.handleJsonRpcValue(value);
writeLine(process.stdout, value);
}
@@ -1387,6 +1423,665 @@ function writeLine(stream, value) {
stream.write(JSON.stringify(value) + "\n");
}
function createBotGatewayBridge() {
const config = readBotGatewayBridgeConfig();
if (!config.enabled) {
return {
handleClaudeCliLine() {},
handleJsonRpcLine() {},
handleJsonRpcValue() {}
};
}
const bridge = new BotGatewayBridge(config);
process.once("exit", () => bridge.stop());
return bridge;
}
function readBotGatewayBridgeConfig() {
const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED");
const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none");
const handoffEnabled = boolEnv("CCR_BOT_HANDOFF_ENABLED") || boolEnv("CODEXL_BOT_HANDOFF_ENABLED");
return {
acknowledgeEvents: boolEnv("CCR_BOT_GATEWAY_ACK_EVENTS"),
args: jsonArrayEnv("CCR_BOT_GATEWAY_ARGS_JSON"),
authType: normalizeBotGatewayAuthType(platform, nonEmptyEnv("CCR_BOT_GATEWAY_AUTH_TYPE") || ""),
autoStartIntegration: boolEnv("CCR_BOT_GATEWAY_AUTO_START_INTEGRATION"),
command: nonEmptyEnv("CCR_BOT_GATEWAY_COMMAND") || "",
conversationRef: jsonObjectEnv("CCR_BOT_GATEWAY_CONVERSATION_REF_JSON"),
createIntegration: boolEnv("CCR_BOT_GATEWAY_CREATE_INTEGRATION"),
credentials: sanitizeBotGatewayRecord(jsonObjectEnv("CCR_BOT_GATEWAY_CREDENTIALS_JSON") || {}),
cwd: nonEmptyEnv("CCR_BOT_GATEWAY_CWD") || "",
enabled: enabled && platform !== "none",
forwardAllAgentMessages: boolEnv("CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES") || boolEnv("CODEXL_BOT_GATEWAY_FORWARD_ALL_CODEX_MESSAGES"),
handoff: {
enabled: handoffEnabled,
idleSeconds: numberEnv("CCR_BOT_HANDOFF_IDLE_SECONDS", numberEnv("CODEXL_BOT_HANDOFF_IDLE_SECONDS", 30)),
phoneBluetoothTargets: listEnv("CCR_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS") || listEnv("CODEXL_BOT_HANDOFF_PHONE_BLUETOOTH_TARGETS"),
phoneWifiTargets: listEnv("CCR_BOT_HANDOFF_PHONE_WIFI_TARGETS") || listEnv("CODEXL_BOT_HANDOFF_PHONE_WIFI_TARGETS"),
screenLock: boolEnv("CCR_BOT_HANDOFF_SCREEN_LOCK") || boolEnv("CODEXL_BOT_HANDOFF_SCREEN_LOCK"),
userIdle: boolEnv("CCR_BOT_HANDOFF_USER_IDLE") || boolEnv("CODEXL_BOT_HANDOFF_USER_IDLE")
},
integrationConfig: websocketBotGatewayIntegrationConfig(platform, jsonObjectEnv("CCR_BOT_GATEWAY_CONFIG_JSON") || {}),
integrationId: nonEmptyEnv("CCR_BOT_GATEWAY_INTEGRATION_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_INTEGRATION_ID") || "",
platform,
pollIntervalMs: numberEnv("CCR_BOT_GATEWAY_POLL_INTERVAL_MS", 2000),
profileId: nonEmptyEnv("CCR_BOT_PROFILE_ID") || nonEmptyEnv("CCR_CODEX_PROFILE") || nonEmptyEnv("CODEXL_CODEX_PROFILE") || "default",
profileName: nonEmptyEnv("CCR_BOT_PROFILE_NAME") || nonEmptyEnv("CODEXL_CODEX_WORKSPACE_NAME") || "CCR",
requestTimeoutMs: numberEnv("CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS", 600000),
sourceDir: nonEmptyEnv("CCR_BOT_GATEWAY_SOURCE_DIR") || "",
startupTimeoutMs: numberEnv("CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS", 10000),
stateDir: nonEmptyEnv("CCR_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("CODEXL_BOT_GATEWAY_STATE_DIR") || nonEmptyEnv("BOT_GATEWAY_STATE_DIR") || "",
tenantId: nonEmptyEnv("CCR_BOT_GATEWAY_TENANT_ID") || nonEmptyEnv("CODEXL_BOT_GATEWAY_TENANT_ID") || "ccr"
};
}
function normalizeBotGatewayPlatform(value) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized || normalized === "off" || normalized === "disabled") return "none";
if (normalized === "lark") return "feishu";
if (normalized === "dingding") return "dingtalk";
if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) return "weixin-ilink";
if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) return "wecom";
return normalized;
}
function normalizeBotGatewayAuthType(platform, value) {
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
if (!platform || platform === "none") return "";
if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") {
return defaultBotGatewayAuthType(platform);
}
if (normalized === "appsecret") return "app_secret";
if (normalized === "bottoken" || normalized === "token") return "bot_token";
if (normalized === "oauth" || normalized === "oauth_2") return "oauth2";
if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) return "qr_login";
return normalized;
}
function defaultBotGatewayAuthType(platform) {
if (platform === "weixin-ilink") return "qr_login";
if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") return "app_secret";
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") return "bot_token";
return "";
}
function websocketBotGatewayIntegrationConfig(platform, value) {
const config = sanitizeBotGatewayRecord(value);
delete config.transport;
delete config.sendMode;
const transport = botGatewayWebSocketTransport(platform);
return transport ? { ...config, transport } : config;
}
function botGatewayWebSocketTransport(platform) {
if (!platform || platform === "none") return "";
return platform === "slack" ? "socket" : "websocket";
}
function sanitizeBotGatewayRecord(value) {
const result = {};
if (!value || typeof value !== "object" || Array.isArray(value)) return result;
for (const [key, rawValue] of Object.entries(value)) {
if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) continue;
result[key] = rawValue;
}
return result;
}
function isWebhookRelatedBotGatewayKey(key) {
const normalized = key.trim().toLowerCase().replace(/[_-]+/g, "");
return normalized.includes("webhook") || normalized === "sendmode";
}
class BotGatewayBridge {
constructor(config) {
this.config = config;
this.child = null;
this.client = null;
this.forwarded = new Set();
this.latestEvent = null;
this.messageCounter = 0;
this.pollTimer = null;
this.startPromise = null;
this.claudeCliCapture = { finalText: "", resultCount: 0, text: "" };
this.turnCaptures = new Map();
}
handleClaudeCliLine(line) {
if (!line || !this.config.enabled) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (!message || typeof message !== "object") return;
if (message.type === "stream_event" && message.event) {
this.captureClaudeStreamEvent(message.event);
return;
}
if (message.type === "assistant" && message.message && message.message.content) {
const text = textFromContent(message.message.content);
if (text) this.claudeCliCapture.finalText = text;
return;
}
if (message.type === "result") {
const errorText = message.is_error ? stringValue(message.result) || "Claude Code returned an error" : "";
const text = errorText
? "Agent turn failed: " + errorText
: this.claudeCliCapture.finalText || stringValue(message.result) || this.claudeCliCapture.text;
this.completeClaudeCliCapture(text, Boolean(errorText));
return;
}
const result = stringValue(message.result);
if (result && !message.method && !message.params) {
this.completeClaudeCliCapture(result, false);
}
}
captureClaudeStreamEvent(event) {
if (!event || typeof event !== "object") return;
if (event.type === "content_block_delta" && event.delta && event.delta.type === "text_delta" && typeof event.delta.text === "string") {
this.claudeCliCapture.text += event.delta.text;
return;
}
if (event.type === "content_block_start" && event.content_block) {
const text = textFromContent([event.content_block]);
if (text) this.claudeCliCapture.finalText = text;
}
}
completeClaudeCliCapture(text, isError) {
const trimmed = typeof text === "string" ? text.trim() : "";
if (!trimmed) return;
this.claudeCliCapture.resultCount += 1;
const key = [
isError ? "claude-cli-error" : "claude-cli",
process.pid,
this.claudeCliCapture.resultCount,
trimmed.length
].join(":");
this.forwardAgentText(key, trimmed, {});
this.claudeCliCapture.finalText = "";
this.claudeCliCapture.text = "";
}
handleJsonRpcLine(line) {
if (!line || !this.config.enabled) return;
let value;
try {
value = JSON.parse(line);
} catch {
return;
}
this.handleJsonRpcValue(value);
}
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") {
this.handleCompletedItem(params);
} else if (method === "item/agentMessage/delta") {
this.handleAgentMessageDelta(params);
} else if (method === "turn/completed") {
this.handleTurnCompleted(params);
}
}
handleCompletedItem(params) {
const item = params.item && typeof params.item === "object" ? params.item : null;
if (!isAgentMessageItem(item)) return;
const text = agentMessageItemText(item).trim();
if (!text) return;
const capture = this.turnCapture(params);
if (capture) capture.finalText = text;
const key = ["item", params.threadId, params.turnId, item.id, text.length].map((part) => String(part || "")).join(":");
this.forwardAgentText(key, text, params);
}
handleAgentMessageDelta(params) {
const delta = typeof params.delta === "string" ? params.delta : typeof params.text === "string" ? params.text : "";
if (!delta) return;
const capture = this.turnCapture(params);
if (capture) capture.text += delta;
}
handleTurnCompleted(params) {
const turn = params.turn && typeof params.turn === "object" ? params.turn : null;
const captureKey = turnCaptureKey(params);
const errorText = turnErrorText(turn);
if (errorText) {
const key = ["turn-error", params.threadId || (turn && turn.threadId), turn && turn.id, errorText.length].map((part) => String(part || "")).join(":");
this.forwardAgentText(key, "Agent turn failed: " + errorText, params);
if (captureKey) this.turnCaptures.delete(captureKey);
return;
}
const capture = captureKey ? this.turnCaptures.get(captureKey) : null;
const text = capture ? (capture.finalText || capture.text || "").trim() : "";
if (text) {
const key = ["turn", params.threadId || (turn && turn.threadId), turn && turn.id, text.length].map((part) => String(part || "")).join(":");
this.forwardAgentText(key, text, params);
}
if (captureKey) this.turnCaptures.delete(captureKey);
}
turnCapture(params) {
const key = turnCaptureKey(params);
if (!key) return null;
let capture = this.turnCaptures.get(key);
if (!capture) {
capture = { finalText: "", text: "" };
this.turnCaptures.set(key, capture);
}
return capture;
}
forwardAgentText(key, text, params) {
if (this.forwarded.has(key)) return;
const decision = this.forwardDecision();
if (!decision.shouldForward) {
log("bot_gateway_forward_skip", { key, reason: decision.reason });
return;
}
this.forwarded.add(key);
this.ensureStarted()
.then(() => this.sendText(key, text, params, decision))
.catch((error) => {
this.forwarded.delete(key);
this.logError("forward_failed", error);
});
}
forwardDecision() {
if (!this.config.forwardAllAgentMessages) {
return { shouldForward: false, reason: "forward_all_disabled" };
}
if (!this.config.handoff.enabled) {
return { shouldForward: false, reason: "handoff_disabled" };
}
const presence = evaluateHandoffPresence(this.config.handoff);
return {
shouldForward: presence.away,
reason: presence.away ? presence.reasons.join(", ") : presence.evidence.join(", ")
};
}
async sendText(key, text, params, decision) {
const conversationRef = this.resolveConversationRef();
if (!conversationRef) {
throw new Error("No Bot Gateway conversationRef is configured and no inbound bot event context is available.");
}
this.messageCounter += 1;
const outbound = {
tenantId: this.resolveTenantId(),
integrationId: this.resolveIntegrationId(),
conversationRef,
intent: {
type: "text",
text
},
idempotencyKey: "ccr:handoff:" + this.config.profileId + ":" + key + ":" + this.messageCounter
};
await withTimeout(this.client.send(outbound), this.config.requestTimeoutMs, "Bot Gateway request timed out: outbound.send");
log("bot_gateway_forward_sent", {
key,
reason: decision.reason,
textLen: text.length,
threadId: params.threadId || "",
turnId: params.turnId || ""
});
}
resolveTenantId() {
return eventString(this.latestEvent, "tenantId") || this.config.tenantId || "ccr";
}
resolveIntegrationId() {
return eventString(this.latestEvent, "integrationId") || this.config.integrationId;
}
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;
}
async ensureStarted() {
if (this.client) return;
if (this.startPromise) return this.startPromise;
this.startPromise = this.start().finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async start() {
const sdk = await loadBotGatewaySdk();
const env = Object.assign({}, process.env, {
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);
this.client = sdk.createBotGatewayClient(clientOptions);
await withTimeout(this.client.health(), this.config.startupTimeoutMs, "Bot Gateway health check timed out.");
await this.ensureIntegration();
await this.pollEvents();
this.pollTimer = setInterval(() => {
this.pollEvents().catch((error) => this.logError("poll_failed", error));
}, Math.max(500, this.config.pollIntervalMs));
log("bot_gateway_started", { platform: this.config.platform, sdkTransport: clientOptions.transport, command: clientOptions.command || "sdk-bundled" });
}
async ensureIntegration() {
if (!this.config.integrationId) return;
if (this.config.createIntegration) {
await botGatewayClientRequest(this.client, "integrations.create", {
id: this.config.integrationId,
tenantId: this.config.tenantId,
platform: this.config.platform,
authType: this.config.authType,
credentials: this.config.credentials,
config: this.config.integrationConfig
}, this.config.requestTimeoutMs);
}
if (this.config.autoStartIntegration) {
await botGatewayClientRequest(this.client, "integrations.start", {
integrationId: this.config.integrationId
}, this.config.requestTimeoutMs).catch((error) => {
log("bot_gateway_integration_start_skip", { error: formatError(error) });
});
}
}
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) });
});
}
}
}
}
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;
if (this.config.tenantId && event.tenantId !== this.config.tenantId) return false;
return true;
}
stop() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.client && typeof this.client.close === "function") {
this.client.close();
}
this.client = null;
}
logError(event, error) {
log("bot_gateway_" + event, { error: formatError(error) });
}
}
let BOT_GATEWAY_SDK_PROMISE = null;
async function loadBotGatewaySdk() {
if (!BOT_GATEWAY_SDK_PROMISE) {
BOT_GATEWAY_SDK_PROMISE = importBotGatewaySdk();
}
return BOT_GATEWAY_SDK_PROMISE;
}
async function importBotGatewaySdk() {
const candidates = [];
const configured = nonEmptyEnv("CCR_BOT_GATEWAY_SDK_MODULE");
if (configured) {
candidates.push(configured);
}
candidates.push("@the-next-ai/bot-gateway-sdk");
const errors = [];
for (const candidate of candidates) {
try {
const sdk = await import(botGatewaySdkImportSpecifier(candidate));
if (sdk && typeof sdk.createBotGatewayClient === "function") {
return sdk;
}
errors.push(candidate + ": missing createBotGatewayClient export");
} catch (error) {
errors.push(candidate + ": " + formatError(error));
}
}
throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; "));
}
function botGatewaySdkImportSpecifier(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "@the-next-ai/bot-gateway-sdk";
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
if (path.isAbsolute(trimmed)) return pathToFileURL(trimmed).href;
return trimmed;
}
function botGatewaySdkClientOptions(config, env) {
const command = resolveBotGatewayCommand(config);
return {
transport: "stdio",
...(command || {}),
env
};
}
function resolveBotGatewayCommand(config) {
if (config.command) {
return {
command: expandHome(config.command),
args: config.args,
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 botGatewayClientRequest(client, method, params, timeoutMs) {
if (!client || typeof client.request !== "function") {
return Promise.reject(new Error("Bot Gateway SDK client does not expose request()."));
}
return withTimeout(client.request(method, params), timeoutMs, "Bot Gateway request timed out: " + method);
}
function withTimeout(promise, timeoutMs, message) {
const timeout = Math.max(1000, timeoutMs || 30000);
let timer = null;
return new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeout);
Promise.resolve(promise).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
function evaluateHandoffPresence(config) {
if (!config.enabled) {
return { away: false, reasons: [], evidence: ["handoff disabled"] };
}
const reasons = [];
const evidence = [];
if (config.screenLock) {
const locked = detectScreenLocked();
if (locked !== true) {
return { away: false, reasons, evidence: [locked === false ? "screen unlocked" : "screen lock unknown"] };
}
reasons.push("screen locked");
}
if (config.userIdle) {
const seconds = detectUserIdleSeconds();
if (!Number.isFinite(seconds)) {
evidence.push("idle time unknown");
} else if (seconds >= config.idleSeconds) {
reasons.push("idle for " + seconds + "s");
} else {
return { away: false, reasons, evidence: ["idle for " + seconds + "s"] };
}
}
if (config.phoneWifiTargets.length || config.phoneBluetoothTargets.length) {
evidence.push("phone target checks are configured but not available in CCR middleware");
}
return { away: reasons.length > 0, reasons, evidence };
}
function detectScreenLocked() {
if (process.platform !== "darwin") return null;
const output = commandOutput("/usr/sbin/ioreg", ["-r", "-k", "CGSSessionScreenIsLocked"]) || commandOutput("/usr/sbin/ioreg", ["-n", "Root", "-d1"]);
if (!output) return null;
for (const line of output.split(/\r?\n/g)) {
if (!line.includes("CGSSessionScreenIsLocked") && !line.includes("IOConsoleLocked")) continue;
const lower = line.toLowerCase();
if (lower.includes("yes") || lower.includes("true") || lower.includes("= 1")) return true;
if (lower.includes("no") || lower.includes("false") || lower.includes("= 0")) return false;
}
return false;
}
function detectUserIdleSeconds() {
if (process.platform !== "darwin") return null;
const output = commandOutput("/usr/sbin/ioreg", ["-c", "IOHIDSystem"]);
if (!output) return null;
for (const line of output.split(/\r?\n/g)) {
if (!line.includes("HIDIdleTime")) continue;
const raw = String(line.split("=")[1] || "").trim();
const digits = raw.match(/^\d+/);
if (!digits) return null;
return Math.floor(Number(digits[0]) / 1000000000);
}
return null;
}
function commandOutput(command, args) {
try {
const result = childProcess.spawnSync(command, args, { encoding: "utf8", timeout: 2000 });
return result.status === 0 ? result.stdout : "";
} catch {
return "";
}
}
function isAgentMessageItem(item) {
if (!item || typeof item !== "object") return false;
return item.type === "agentMessage" || item.type === "agent_message" || item.type === "assistantMessage" || item.type === "assistant_message";
}
function agentMessageItemText(item) {
if (!item || typeof item !== "object") return "";
if (typeof item.text === "string") return item.text;
if (typeof item.content === "string") return item.content;
if (typeof item.message === "string") return item.message;
return "";
}
function turnCaptureKey(params) {
const threadId = params.threadId || params.thread_id || (params.thread && params.thread.id);
const turnId = params.turnId || params.turn_id || (params.turn && params.turn.id);
if (!threadId || !turnId) return "";
return String(threadId) + ":" + String(turnId);
}
function turnErrorText(turn) {
if (!turn) return "";
if (typeof turn.error === "string" && turn.error.trim()) return turn.error.trim();
if (turn.error && typeof turn.error === "object") {
if (typeof turn.error.message === "string") return turn.error.message.trim();
if (typeof turn.error.details === "string") return turn.error.details.trim();
}
return "";
}
function eventString(value, key) {
return value && typeof value[key] === "string" ? value[key].trim() : "";
}
function jsonObjectEnv(name) {
const text = nonEmptyEnv(name);
if (!text) return null;
try {
const value = JSON.parse(text);
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
function jsonArrayEnv(name) {
const text = nonEmptyEnv(name);
if (!text) return [];
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value.map((item) => String(item)) : [];
} catch {
return [];
}
}
function listEnv(name) {
const value = process.env[name];
if (!value) return [];
return value.split(/\r?\n|,/g).map((item) => item.trim()).filter(Boolean);
}
function boolEnv(name) {
const value = process.env[name];
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function safePathSegment(value) {
const segment = String(value || "").trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return segment || "default";
}
function waitForChild(child) {
return new Promise((resolve) => {
child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1)));
+370 -1
View File
@@ -7,6 +7,8 @@ import type {
AppConfig,
ApiKeyConfig,
ApiKeyLimitConfig,
BotGatewayRuntimeConfig,
BotGatewaySavedConfig,
ClaudeCodeProfileConfig,
CodexProfileConfig,
GatewayAgentConfig,
@@ -48,9 +50,15 @@ type LoadedProfileConfig = Partial<Omit<ProfileRuntimeConfig, "claudeCode" | "co
profiles?: ProfileConfig[];
};
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "gateway" | "profile" | "proxy">> & {
type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">> & {
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
};
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "gateway" | "profile" | "proxy">> & {
Router?: Partial<RouterConfig>;
agent?: Partial<GatewayAgentConfig>;
botConfigs?: BotGatewaySavedConfig[];
botGateway?: LoadedBotGatewayConfig;
gateway?: Partial<AppConfig["gateway"]>;
profile?: LoadedProfileConfig;
proxy?: Partial<ProxyRuntimeConfig>;
@@ -94,6 +102,36 @@ const DEFAULT_CONFIG: AppConfig = {
mcpServers: []
},
autoStart: false,
botConfigs: [],
botGateway: {
acknowledgeEvents: false,
args: [],
authType: "",
autoStartIntegration: true,
command: "",
createIntegration: false,
credentials: {},
cwd: "",
enabled: false,
forwardAllAgentMessages: true,
handoff: {
enabled: false,
idleSeconds: 30,
phoneBluetoothTargets: [],
phoneWifiTargets: [],
screenLock: true,
userIdle: true
},
integrationConfig: {},
integrationId: "",
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sourceDir: "/Users/jinhuilee/products/bot-gateway",
startupTimeoutMs: 10000,
stateDir: "",
tenantId: "ccr"
},
gateway: {
coreHost: "127.0.0.1",
corePort: 3457,
@@ -177,6 +215,112 @@ const DEFAULT_CONFIG: AppConfig = {
trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES
};
function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig {
const platform = normalizeBotGatewayPlatform(config?.platform ?? DEFAULT_CONFIG.botGateway.platform);
return {
...DEFAULT_CONFIG.botGateway,
...(config ?? {}),
authType: normalizeBotGatewayAuthType(platform, config?.authType ?? DEFAULT_CONFIG.botGateway.authType),
credentials: sanitizeBotGatewayRecord(config?.credentials ?? DEFAULT_CONFIG.botGateway.credentials),
handoff: {
...DEFAULT_CONFIG.botGateway.handoff,
...(config?.handoff ?? {})
},
integrationConfig: websocketBotGatewayIntegrationConfig(platform, config?.integrationConfig ?? DEFAULT_CONFIG.botGateway.integrationConfig),
platform
};
}
function normalizeBotGatewayPlatform(value: unknown): string {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!normalized || normalized === "off" || normalized === "disabled") {
return "none";
}
if (normalized === "lark") {
return "feishu";
}
if (normalized === "dingding") {
return "dingtalk";
}
if (["wechat", "weixin", "wx", "weixin-ilink", "weixin_ilink", "ilink"].includes(normalized)) {
return "weixin-ilink";
}
if (["wecom", "wework", "wechat-work", "work-weixin", "enterprise-wechat"].includes(normalized)) {
return "wecom";
}
return normalized;
}
function normalizeBotGatewayAuthType(platform: string, value: unknown): string {
const normalized = typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
if (!platform || platform === "none") {
return "";
}
if (!normalized || normalized === "default" || normalized === "auto" || normalized === "webhook" || normalized === "webhook_secret" || normalized === "outgoing_webhook") {
return defaultBotGatewayAuthType(platform);
}
if (normalized === "appsecret") {
return "app_secret";
}
if (normalized === "bottoken" || normalized === "token") {
return "bot_token";
}
if (normalized === "oauth" || normalized === "oauth_2") {
return "oauth2";
}
if (["qr", "qr_login", "qrcode", "qr_code"].includes(normalized)) {
return "qr_login";
}
return normalized;
}
function defaultBotGatewayAuthType(platform: string): string {
if (platform === "weixin-ilink") {
return "qr_login";
}
if (platform === "feishu" || platform === "dingtalk" || platform === "wecom") {
return "app_secret";
}
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
return "";
}
function websocketBotGatewayIntegrationConfig(platform: string, value: Record<string, unknown>): Record<string, unknown> {
const config = sanitizeBotGatewayRecord(value);
delete config.transport;
delete config.sendMode;
const transport = botGatewayWebSocketTransport(platform);
return transport ? { ...config, transport } : config;
}
function botGatewayWebSocketTransport(platform: string): string {
if (!platform || platform === "none") {
return "";
}
return platform === "slack" ? "socket" : "websocket";
}
function sanitizeBotGatewayRecord(value: Record<string, unknown> | undefined): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (!isObject(value)) {
return result;
}
for (const [key, rawValue] of Object.entries(value)) {
if (!key.trim() || isWebhookRelatedBotGatewayKey(key)) {
continue;
}
result[key] = rawValue;
}
return result;
}
function isWebhookRelatedBotGatewayKey(key: string): boolean {
const normalized = key.trim().toLowerCase().replace(/[_-]+/g, "");
return normalized.includes("webhook") || normalized === "sendmode";
}
export async function loadAppConfig(): Promise<AppConfig> {
ensureConfigFile();
@@ -210,6 +354,8 @@ export async function loadAppConfig(): Promise<AppConfig> {
...(picked.agent ?? {}),
mcpServers: picked.agent?.mcpServers ?? DEFAULT_CONFIG.agent.mcpServers
},
botConfigs: picked.botConfigs ?? DEFAULT_CONFIG.botConfigs,
botGateway: completeBotGatewayConfig(picked.botGateway),
gateway: {
...DEFAULT_CONFIG.gateway,
...gatewayConfig,
@@ -443,6 +589,14 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
if (agent) {
config.agent = agent;
}
const botGateway = parseBotGateway((value as Record<string, unknown>).botGateway ?? (value as Record<string, unknown>).bot_gateway ?? (value as Record<string, unknown>).bot);
if (botGateway) {
config.botGateway = botGateway;
}
const botConfigs = parseBotGatewaySavedConfigs((value as Record<string, unknown>).botConfigs ?? (value as Record<string, unknown>).bot_configs);
if (botConfigs) {
config.botConfigs = botConfigs;
}
if (typeof value.autoStart === "boolean") {
config.autoStart = value.autoStart;
}
@@ -977,6 +1131,14 @@ function parseStringList(value: unknown): string[] {
return [];
}
function parseStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value) && typeof value !== "string") {
return undefined;
}
const list = parseStringList(value);
return list.length ? list : undefined;
}
function parseRouterRules(value: unknown): RouterRule[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -1054,6 +1216,199 @@ function parseAgent(value: unknown, legacyMcpServers?: unknown): Partial<Gateway
return { mcpServers };
}
function parseBotGateway(value: unknown): LoadedBotGatewayConfig | undefined {
if (!isObject(value)) {
return undefined;
}
const config: LoadedBotGatewayConfig = {};
if (typeof value.enabled === "boolean") {
config.enabled = value.enabled;
}
const sourceDir = readString(value.sourceDir) || readString(value.source_dir) || readString(value.projectDir) || readString(value.project_dir);
if (sourceDir) {
config.sourceDir = sourceDir;
}
const command = readString(value.command);
if (command) {
config.command = command;
}
const args = parseStringArray(value.args);
if (args) {
config.args = args;
}
const cwd = readString(value.cwd) || readString(value.rootDir) || readString(value.root_dir);
if (cwd) {
config.cwd = cwd;
}
const stateDir = readString(value.stateDir) || readString(value.state_dir);
if (stateDir) {
config.stateDir = stateDir;
}
const tenantId = readString(value.tenantId) || readString(value.tenant_id);
if (tenantId) {
config.tenantId = tenantId;
}
const integrationId = readString(value.integrationId) || readString(value.integration_id);
if (integrationId) {
config.integrationId = integrationId;
}
const platform = readString(value.platform);
if (platform) {
config.platform = platform;
}
const authType = readString(value.authType) || readString(value.auth_type);
if (authType) {
config.authType = authType;
}
const credentials = parseUnknownRecord(value.credentials) || parseUnknownRecord(value.authFields) || parseUnknownRecord(value.auth_fields);
if (credentials) {
config.credentials = credentials;
}
const integrationConfig = parseUnknownRecord(value.integrationConfig) || parseUnknownRecord(value.config);
if (integrationConfig) {
config.integrationConfig = integrationConfig;
}
const conversationRef = parseBotGatewayConversation(value.conversationRef ?? value.conversation_ref ?? value.conversation);
if (conversationRef) {
config.conversationRef = conversationRef;
}
if (typeof value.createIntegration === "boolean") {
config.createIntegration = value.createIntegration;
} else if (typeof value.create_integration === "boolean") {
config.createIntegration = value.create_integration;
}
if (typeof value.autoStartIntegration === "boolean") {
config.autoStartIntegration = value.autoStartIntegration;
} else if (typeof value.auto_start_integration === "boolean") {
config.autoStartIntegration = value.auto_start_integration;
}
if (typeof value.acknowledgeEvents === "boolean") {
config.acknowledgeEvents = value.acknowledgeEvents;
} else if (typeof value.acknowledge_events === "boolean") {
config.acknowledgeEvents = value.acknowledge_events;
}
if (typeof value.forwardAllAgentMessages === "boolean") {
config.forwardAllAgentMessages = value.forwardAllAgentMessages;
} else if (typeof value.forward_all_agent_messages === "boolean" || typeof value.forward_all_codex_messages === "boolean") {
config.forwardAllAgentMessages = Boolean(value.forward_all_agent_messages ?? value.forward_all_codex_messages);
}
const requestTimeoutMs = readNumber(value.requestTimeoutMs ?? value.request_timeout_ms);
if (requestTimeoutMs !== undefined) {
config.requestTimeoutMs = clampNumber(requestTimeoutMs, 1000, 3_600_000);
}
const startupTimeoutMs = readNumber(value.startupTimeoutMs ?? value.startup_timeout_ms);
if (startupTimeoutMs !== undefined) {
config.startupTimeoutMs = clampNumber(startupTimeoutMs, 1000, 120_000);
}
const pollIntervalMs = readNumber(value.pollIntervalMs ?? value.poll_interval_ms);
if (pollIntervalMs !== undefined) {
config.pollIntervalMs = clampNumber(pollIntervalMs, 500, 60_000);
}
const handoff = parseBotGatewayHandoff(value.handoff);
if (handoff) {
config.handoff = handoff;
}
return config;
}
function parseBotGatewaySavedConfigs(value: unknown): BotGatewaySavedConfig[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const result: BotGatewaySavedConfig[] = [];
const seen = new Set<string>();
value.forEach((item, index) => {
if (!isObject(item)) {
return;
}
const rawBot = item.botGateway ?? item.bot_gateway ?? item.bot ?? item.config;
const parsedBot = parseBotGateway(rawBot);
if (!parsedBot) {
return;
}
const botGateway = completeBotGatewayConfig(parsedBot);
if (!botGateway.enabled || !botGateway.platform || botGateway.platform === "none") {
return;
}
const fallbackId = botGateway.integrationId || `bot-${index + 1}`;
const id = readString(item.id) || readString(item.savedConfigId) || readString(item.saved_config_id) || fallbackId;
if (!id || seen.has(id)) {
return;
}
seen.add(id);
result.push({
botGateway,
id,
name: readString(item.name) || botGateway.platform || id,
...(readString(item.updatedAt) || readString(item.updated_at)
? { updatedAt: readString(item.updatedAt) || readString(item.updated_at) }
: {})
});
});
return result;
}
function parseBotGatewayHandoff(value: unknown): Partial<BotGatewayRuntimeConfig["handoff"]> | undefined {
if (!isObject(value)) {
return undefined;
}
const handoff: Partial<BotGatewayRuntimeConfig["handoff"]> = {};
if (typeof value.enabled === "boolean") {
handoff.enabled = value.enabled;
}
const idleSeconds = readNumber(value.idleSeconds ?? value.idle_seconds);
if (idleSeconds !== undefined) {
handoff.idleSeconds = clampNumber(idleSeconds, 30, 86_400);
}
if (typeof value.screenLock === "boolean") {
handoff.screenLock = value.screenLock;
} else if (typeof value.screen_lock === "boolean") {
handoff.screenLock = value.screen_lock;
}
if (typeof value.userIdle === "boolean") {
handoff.userIdle = value.userIdle;
} else if (typeof value.user_idle === "boolean") {
handoff.userIdle = value.user_idle;
}
const phoneWifiTargets = parseStringArray(value.phoneWifiTargets ?? value.phone_wifi_targets);
if (phoneWifiTargets) {
handoff.phoneWifiTargets = uniqueStrings(phoneWifiTargets).slice(0, 1);
}
const phoneBluetoothTargets = parseStringArray(value.phoneBluetoothTargets ?? value.phone_bluetooth_targets);
if (phoneBluetoothTargets) {
handoff.phoneBluetoothTargets = uniqueStrings(phoneBluetoothTargets).slice(0, 1);
}
return Object.keys(handoff).length ? handoff : undefined;
}
function parseBotGatewayConversation(value: unknown): BotGatewayRuntimeConfig["conversationRef"] | undefined {
if (!isObject(value)) {
return undefined;
}
const platformConversationId =
readString(value.platformConversationId) ||
readString(value.platform_conversation_id) ||
readString(value.conversationId) ||
readString(value.chatId) ||
readString(value.channelId);
const gatewayConversationId = readString(value.gatewayConversationId) || readString(value.gateway_conversation_id);
if (!platformConversationId && !gatewayConversationId) {
return undefined;
}
const type = parseEnumValue(value.type, ["dm", "group", "channel", "thread"], "dm");
const threadId = readString(value.threadId) || readString(value.thread_id);
return {
...(gatewayConversationId ? { gatewayConversationId } : {}),
...(platformConversationId ? { platformConversationId } : {}),
...(threadId ? { threadId } : {}),
type
};
}
function parseMcpServers(value: unknown): GatewayMcpServerConfig[] | undefined {
if (!Array.isArray(value)) {
return undefined;
@@ -1454,10 +1809,15 @@ 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 parsedBotGateway = parseBotGateway(item.botGateway ?? item.bot_gateway ?? item.bot);
const botGateway = parsedBotGateway ? completeBotGatewayConfig(parsedBotGateway) : undefined;
if (agent === "claude-code") {
return {
agent,
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
enabled,
env,
id,
@@ -1472,6 +1832,8 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
return {
agent,
...(botConfigId ? { botConfigId } : {}),
...(botGateway ? { botGateway } : {}),
cliMiddleware: true,
codexCliPath: readString(item.codexCliPath) || readString(item.cliPath) || readString(item.codexPath) || "",
codexHome: readString(item.codexHome) || readString(item.home) || "",
@@ -1628,6 +1990,13 @@ function parseStringRecord(value: unknown): Record<string, string> | undefined {
return Object.keys(result).length ? result : undefined;
}
function parseUnknownRecord(value: unknown): Record<string, unknown> | undefined {
if (!isObject(value)) {
return undefined;
}
return { ...value };
}
function parseApiKeys(value: unknown): ApiKeyConfig[] | undefined {
if (!Array.isArray(value)) {
return undefined;
+11 -1
View File
@@ -3,6 +3,7 @@ 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 { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-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";
@@ -22,7 +23,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, 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, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
const pluginMarketplace: PluginMarketplaceEntry[] = [
{
@@ -155,6 +156,15 @@ ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: Ap
message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}`
};
});
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginStart, (_event, request: BotGatewayQrLoginStartRequest) => {
return startBotGatewayQrLogin(request);
});
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginWait, (_event, request: BotGatewayQrLoginWaitRequest) => {
return waitBotGatewayQrLogin(request);
});
ipcMain.handle(IPC_CHANNELS.appBotGatewayQrLoginCancel, (_event, request: BotGatewayQrLoginCancelRequest) => {
return cancelBotGatewayQrLogin(request);
});
ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => {
const config = await loadAppConfig();
return applyProfileConfig(config);
+9
View File
@@ -7,6 +7,12 @@ import type {
AppInfo,
AppUpdateStatus,
ApiKeyConfig,
BotGatewayQrLoginCancelRequest,
BotGatewayQrLoginCancelResult,
BotGatewayQrLoginStartRequest,
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
GatewayMcpToolInfo,
@@ -41,6 +47,7 @@ import type {
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>,
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>,
@@ -78,11 +85,13 @@ contextBridge.exposeInMainWorld("ccr", {
setTrayDetailOpen: (open: boolean, provider?: string) => ipcRenderer.invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise<void>,
showMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.appShowMainWindow) as Promise<void>,
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>,
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>,
updateInstall: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateInstall) as Promise<void>,
waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrLoginWait, request) as Promise<BotGatewayQrLoginWaitResult>,
onBeforeQuit: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on(IPC_CHANNELS.appBeforeQuit, handler);
+9 -2
View File
@@ -133,11 +133,11 @@ function buildClaudeCodeLaunchPlan(
if (surface === "app") {
throw new Error("Claude App opening is available from the CCR desktop app.");
}
const command = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude";
const settingsFile = resolveClaudeCodeSettingsFile(configDir, profile);
const launcher = path.join(configDir, "bin", claudeCodeWrapperFilename(profile));
return {
args: extraArgs,
command,
command: launcher,
env: {
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
CCR_PROFILE_SURFACE: surface
@@ -147,6 +147,13 @@ function buildClaudeCodeLaunchPlan(
};
}
function claudeCodeWrapperFilename(profile: ProfileConfig): string {
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent) || "claude-code";
return process.platform === "win32"
? `ccr-claude-code-wrapper-${slug}.cmd`
: `ccr-claude-code-wrapper-${slug}`;
}
function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string {
const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex";
return process.platform === "win32"
+3 -1
View File
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
import os from "node:os";
import path from "node:path";
import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult } from "../shared/app";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
import { launchCodexAppProfile } from "./codex-app-launch";
@@ -44,7 +45,8 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen
detached: true,
env: {
...process.env,
...plan.env
...plan.env,
...botGatewayProfileEnv(config, profile)
},
stdio: "ignore"
});
+97 -9
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileConfig } from "../shared/app";
import { replacePersistedApiKeys } from "./api-key-store";
import { botGatewayProfileEnv } from "./bot-gateway-env";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { codexModelCatalogBase64 } from "./codex-model-catalog";
import { CONFIGDIR } from "./constants";
@@ -47,7 +48,8 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
const settings = readJsonObject(settingsFile);
const env = {
...Object.fromEntries(stringRecord(settings.env)),
...profileEnv(profile)
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
};
env.ANTHROPIC_BASE_URL = endpoint;
env.ANTHROPIC_API_BASE_URL = endpoint;
@@ -66,19 +68,21 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token
}
const helperResult = writeClaudeCodeApiKeyHelper(profile, token);
const wrapperResult = writeClaudeCodeWrapper(config, profile);
const nextSettings = {
...settings,
apiKeyHelper: helperResult.file,
env
};
const writeResult = writeFileWithBackup(settingsFile, `${JSON.stringify(nextSettings, null, 2)}\n`);
const changed = writeResult.changed || helperResult.changed || wrapperResult.changed;
return {
appliedAt,
backupFile: writeResult.backupFile ?? helperResult.backupFile,
backupFile: writeResult.backupFile ?? helperResult.backupFile ?? wrapperResult.backupFile,
client: "claude-code",
enabled: true,
message: writeResult.changed || helperResult.changed
? "Claude Code settings are managed by CCR."
message: changed
? `Claude Code settings are managed by CCR (wrapper ${wrapperResult.file}).`
: "Claude Code settings already match CCR.",
ok: true,
path: settingsFile
@@ -124,7 +128,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
showAllSessions: Boolean(profile.showAllSessions)
});
const middlewareResult = profile.cliMiddleware
? writeCodexCliMiddleware(profile, {
? writeCodexCliMiddleware(config, profile, {
configFormat,
configFile,
modelCatalogBase64: codexModelCatalogBase64(config, model),
@@ -385,7 +389,83 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string {
].join("\r\n");
}
function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { backupFile?: string; changed: boolean; file: string } {
const binDir = path.join(CONFIGDIR, "bin");
mkdirSync(binDir, { recursive: true });
const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename());
const runtimeResult = writeFileWithBackup(runtimeFile, codexCliMiddlewareRuntimeScript());
if (process.platform !== "win32") {
chmodSync(runtimeFile, 0o755);
}
const file = path.join(binDir, claudeCodeWrapperFilename(profile));
const content = process.platform === "win32"
? claudeCodeWrapperCmdScript(config, profile, runtimeFile)
: claudeCodeWrapperShellScript(config, profile, runtimeFile);
const writeResult = writeFileWithBackup(file, content);
if (process.platform !== "win32") {
chmodSync(file, 0o755);
}
return {
backupFile: writeResult.backupFile ?? runtimeResult.backupFile,
changed: writeResult.changed || runtimeResult.changed,
file
};
}
function claudeCodeWrapperFilename(profile: ProfileConfig): string {
const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent).toLowerCase() || "claude-code";
return process.platform === "win32"
? `ccr-claude-code-wrapper-${slug}.cmd`
: `ccr-claude-code-wrapper-${slug}`;
}
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)
})
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `export ${key}=${shellQuote(value)}`);
return [
"#!/bin/sh",
...envExports,
`export CCR_CLAUDE_CODE_WRAPPER=1`,
`export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
`export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`,
"NODE_BIN=${CCR_NODE_BIN:-node}",
`exec "$NODE_BIN" ${shellQuote(runtimeFile)} "$@"`,
""
].join("\n");
}
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)
})
.filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN")
.map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
return [
"@echo off",
...envExports,
`set "CCR_CLAUDE_CODE_WRAPPER=1"`,
`set "CCR_REAL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`,
`set "CODEXL_CLAUDE_CODE_BIN=${realClaude.replace(/"/g, '\\"')}"`,
"if not defined CCR_NODE_BIN set \"CCR_NODE_BIN=node\"",
"if \"%~1\"==\"\" (",
` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}"`,
") else (",
` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}" %*`,
")",
"exit /b %ERRORLEVEL%",
""
].join("\r\n");
}
function writeCodexCliMiddleware(
config: AppConfig,
profile: ProfileConfig,
values: {
configFormat: "legacy" | "separate_profile_files";
@@ -404,8 +484,8 @@ function writeCodexCliMiddleware(
}
const file = path.join(binDir, codexMiddlewareFilename(profile, values.providerId));
const content = process.platform === "win32"
? codexMiddlewareCmdScript(profile, values, runtimeFile)
: codexMiddlewareShellScript(profile, values, runtimeFile);
? codexMiddlewareCmdScript(config, profile, values, runtimeFile)
: codexMiddlewareShellScript(config, profile, values, runtimeFile);
const writeResult = writeFileWithBackup(file, content);
if (process.platform !== "win32") {
chmodSync(file, 0o755);
@@ -428,6 +508,7 @@ function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): st
}
function codexMiddlewareShellScript(
config: AppConfig,
profile: ProfileConfig,
values: {
configFormat: "legacy" | "separate_profile_files";
@@ -442,7 +523,10 @@ 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)).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
}).map(([key, value]) => `export ${key}=${shellQuote(value)}`);
return [
"#!/bin/sh",
...envExports,
@@ -472,6 +556,7 @@ function codexMiddlewareShellScript(
}
function codexMiddlewareCmdScript(
config: AppConfig,
profile: ProfileConfig,
values: {
configFormat: "legacy" | "separate_profile_files";
@@ -488,7 +573,10 @@ 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)).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
const envExports = Object.entries({
...profileEnv(profile),
...botGatewayProfileEnv(config, profile)
}).map(([key, value]) => `set "${key}=${value.replace(/"/g, '\\"')}"`);
return [
"@echo off",
...envExports,
+2 -1
View File
@@ -42,7 +42,8 @@ class WindowsManager {
nodeIntegration: false,
preload: path.join(__dirname, "preload.js"),
sandbox: true,
webSecurity: true
webSecurity: true,
webviewTag: true
},
width
});
+55 -20
View File
@@ -1,7 +1,7 @@
import {
AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSnapshot, AgentFilterValue,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppUpdateStatus,
AppLanguagePreference, applyProviderProbeResult, AppToast, buildExtensionList, claudeDesignRoutingConfigFromDraft,
AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft,
ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft,
createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis,
createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft,
@@ -15,7 +15,7 @@ import {
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists,
navigation, NavigationId, normalizeApiKeys, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets,
navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets,
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference,
normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder,
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
@@ -109,6 +109,8 @@ function App() {
const [savedConfig, setSavedConfig] = useState<AppConfig>(fallbackConfig);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsInitialPage, setSettingsInitialPage] = useState<"appearance" | "bots" | "tray" | "update">("appearance");
const [settingsBotAddRequestKey, setSettingsBotAddRequestKey] = useState(0);
const [compactLayout, setCompactLayout] = useState(() => window.matchMedia("(max-width: 720px)").matches);
const [toast, setToast] = useState<AppToast>();
const [languagePreference, setLanguagePreference] = useState<AppLanguagePreference>(() => readLanguagePreference());
@@ -408,7 +410,7 @@ function App() {
return buildExtensionList(draftConfig).find((extension) =>
extension.source === extensionDeleteTarget.source && extension.index === extensionDeleteTarget.index
);
}, [draftConfig.plugins, draftConfig.providerPlugins, draftConfig.virtualModelProfiles, extensionDeleteTarget]);
}, [draftConfig.plugins, draftConfig.providerPlugins, extensionDeleteTarget]);
const extensionConfigItem = useMemo(() => {
if (!extensionConfigTarget) {
return undefined;
@@ -443,8 +445,8 @@ function App() {
const canSubmitProvider =
Boolean(providerDraft.name.trim() && providerDraft.baseUrl.trim()) &&
providerDialogModels.length > 0;
const canSubmitProfile = isProfileDraftSubmittable(profileDraft);
const canSubmitProfileEdit = profileEditIndex !== undefined && isProfileDraftSubmittable(profileEditDraft);
const canSubmitProfile = isProfileDraftSubmittable(profileDraft) && isProfileBotSelectionValid(profileDraft, draftConfig.botConfigs);
const canSubmitProfileEdit = profileEditIndex !== undefined && isProfileDraftSubmittable(profileEditDraft) && isProfileBotSelectionValid(profileEditDraft, draftConfig.botConfigs);
const canSubmitApiKey = Boolean(apiKeyDraft.name.trim()) && (apiKeyDraft.expirationPreset !== "custom" || Boolean(apiKeyDraft.expiresAt.trim()));
const canSubmitApiKeyEdit = apiKeyEditDraft.expirationPreset !== "custom" || Boolean(apiKeyEditDraft.expiresAt.trim());
const canSubmitRoutingRule =
@@ -1348,10 +1350,8 @@ function App() {
updateConfig((config) => {
if (source === "plugins") {
config.plugins = (config.plugins ?? []).filter((_, itemIndex) => itemIndex !== index);
} else if (source === "providerPlugins") {
config.providerPlugins = (config.providerPlugins ?? []).filter((_, itemIndex) => itemIndex !== index);
} else {
config.virtualModelProfiles = (config.virtualModelProfiles ?? []).filter((_, itemIndex) => itemIndex !== index);
config.providerPlugins = (config.providerPlugins ?? []).filter((_, itemIndex) => itemIndex !== index);
}
return config;
});
@@ -1561,14 +1561,6 @@ function App() {
config.providerPlugins = values;
return config;
}
const values = [...(config.virtualModelProfiles ?? [])];
const item = values[index];
if (!item) {
return config;
}
values[index] = { ...item, enabled };
config.virtualModelProfiles = values;
return config;
});
}
@@ -1606,6 +1598,29 @@ function App() {
}));
}
function changeBotConfigs(botConfigs: BotGatewaySavedConfig[]) {
const normalizedBotConfigs = normalizeBotGatewaySavedConfigs(botConfigs);
const validIds = new Set(normalizedBotConfigs.map((config) => config.id));
updateConfig((config) => ({
...config,
botConfigs: normalizedBotConfigs,
profile: {
...config.profile,
profiles: config.profile.profiles.map((profile) =>
profile.botConfigId && !validIds.has(profile.botConfigId)
? removeProfileBotReference(profile)
: profile
)
}
}));
}
function openBotSettingsWithAddDialog() {
setSettingsInitialPage("bots");
setSettingsBotAddRequestKey((current) => current + 1);
setSettingsOpen(true);
}
function changeOverviewWidgets(widgets: OverviewWidgetConfig[]) {
updateConfig((config) => ({
...config,
@@ -1853,7 +1868,7 @@ function App() {
return;
}
setProfileEditIndex(index);
setProfileEditDraft(createProfileDraftFromProfile(profile));
setProfileEditDraft(createProfileDraftFromProfile(profile, draftConfig.botConfigs));
setProfileActionError("");
}
@@ -1966,7 +1981,7 @@ function App() {
return false;
}
setProfileSubmitBusy("add");
const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles);
const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles, undefined, draftConfig.botConfigs);
setProfileAgentTab(profile.agent);
const next = buildConfigUpdate((config) => ({
...config,
@@ -2014,7 +2029,7 @@ function App() {
setProfileActionError("Profile no longer exists.");
return false;
}
const nextProfile = profileConfigFromDraft(profileEditDraft, draftConfig.profile.profiles, currentProfile);
const nextProfile = profileConfigFromDraft(profileEditDraft, draftConfig.profile.profiles, currentProfile, draftConfig.botConfigs);
setProfileAgentTab(nextProfile.agent);
const next = buildConfigUpdate((config) => {
const profiles = [...config.profile.profiles];
@@ -2110,7 +2125,10 @@ function App() {
needsTrafficLightSafeArea={needsTrafficLightSafeArea}
networkCaptureEnabled={networkCaptureEnabled}
onOpenServerView={() => setActiveView("server")}
onOpenSettings={() => setSettingsOpen(true)}
onOpenSettings={() => {
setSettingsInitialPage("appearance");
setSettingsOpen(true);
}}
onSelectNavigationItem={selectNavigationItem}
onToggleSidebar={() => setSidebarOpen((current) => !current)}
proxyStatus={proxyStatus}
@@ -2296,11 +2314,13 @@ function App() {
onSubmit: submitPluginSettingsDraft
} : undefined}
profileAdd={profileAddOpen ? {
botConfigs: draftConfig.botConfigs,
canSubmit: canSubmitProfile,
draft: profileDraft,
error: profileActionError,
mode: "add",
onChange: updateProfileDraft,
onCreateBot: openBotSettingsWithAddDialog,
onClose: () => setProfileAddOpen(false),
providers: draftConfig.Providers,
submitting: profileSubmitBusy === "add",
@@ -2308,11 +2328,13 @@ function App() {
onSubmit: submitProfileDraft
} : undefined}
profileEdit={profileEditIndex !== undefined ? {
botConfigs: draftConfig.botConfigs,
canSubmit: canSubmitProfileEdit,
draft: profileEditDraft,
error: profileActionError,
mode: "edit",
onChange: updateProfileEditDraft,
onCreateBot: openBotSettingsWithAddDialog,
onClose: () => {
setProfileEditIndex(undefined);
setProfileActionError("");
@@ -2381,9 +2403,13 @@ function App() {
providers: draftConfig.Providers
} : undefined}
settings={settingsOpen ? {
botAddRequestKey: settingsBotAddRequestKey,
botConfigs: draftConfig.botConfigs,
copy,
initialPage: settingsInitialPage,
isMac,
languagePreference,
onChangeBotConfigs: changeBotConfigs,
onCheckUpdate: checkForAppUpdate,
onChangeLanguage: changeLanguagePreference,
onChangeTheme: changeThemePreference,
@@ -2425,6 +2451,15 @@ function App() {
);
}
function removeProfileBotReference(profile: ProfileConfig): ProfileConfig {
const { botConfigId: _botConfigId, botGateway: _botGateway, ...rest } = profile;
return rest;
}
function isProfileBotSelectionValid(draft: AddProfileDraft, botConfigs: BotGatewaySavedConfig[]): boolean {
return !draft.botEnabled || botConfigs.some((config) => config.id === draft.botConfigId.trim());
}
function formatUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -24,7 +24,7 @@ export function ExtensionsView({
const t = useAppText();
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const extensions = useMemo(() => buildExtensionList(config), [config.plugins, config.providerPlugins, config.virtualModelProfiles]);
const extensions = useMemo(() => buildExtensionList(config), [config.plugins, config.providerPlugins]);
const visibleExtensions = useMemo(
() => extensions.filter((extension) => extensionMatchesQuery(extension, normalizedQuery)),
[extensions, normalizedQuery]
@@ -215,9 +215,11 @@ export function OnboardingView({
>
<div className="mx-auto w-full max-w-[720px]">
<AddProfileForm
botConfigs={[]}
draft={profileDraft}
error={profileError}
onChange={onChangeProfile}
onCreateBot={() => undefined}
providers={config.Providers}
virtualModelProfiles={config.virtualModelProfiles ?? []}
/>
@@ -1,5 +1,5 @@
import {
AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, Button,
AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, BotGatewaySavedConfig, botGatewaySavedConfigLabel, Button,
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy,
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, motion,
@@ -684,15 +684,19 @@ function ProfileModelSelector({
}
export function AddProfileForm({
botConfigs,
draft,
error,
onChange,
onCreateBot,
providers,
virtualModelProfiles = []
}: {
botConfigs: BotGatewaySavedConfig[];
draft: AddProfileDraft;
error: string;
onChange: (patch: Partial<AddProfileDraft>) => void;
onCreateBot: () => void;
providers: GatewayProviderConfig[];
virtualModelProfiles?: VirtualModelProfileConfig[];
}) {
@@ -768,6 +772,9 @@ export function AddProfileForm({
</Field>
</>
)}
<div className="sm:col-span-2">
<BotGatewaySelectForm botConfigs={botConfigs} draft={draft} onChange={onChange} onCreateBot={onCreateBot} />
</div>
<Field className="sm:col-span-2" label={t("Environment variables")}>
<KeyValueRowsControl
addLabel={t("Add env variable")}
@@ -785,23 +792,89 @@ export function AddProfileForm({
);
}
const ADD_BOT_SELECT_VALUE = "__add_bot__";
function BotGatewaySelectForm({
botConfigs,
draft,
onChange,
onCreateBot
}: {
botConfigs: BotGatewaySavedConfig[];
draft: AddProfileDraft;
onChange: (patch: Partial<AddProfileDraft>) => void;
onCreateBot: () => void;
}) {
const t = useAppText();
const options = [
{ label: t("None"), value: "none" },
...botConfigs.map((config) => ({ label: botGatewaySavedConfigLabel(config, t), value: config.id })),
{ label: t("Add new bot"), value: ADD_BOT_SELECT_VALUE }
];
const selectedValue = draft.botEnabled && draft.botConfigId ? draft.botConfigId : "none";
function updateEnabled(botEnabled: boolean) {
if (!botEnabled) {
onChange({ botConfigId: "", botConfigured: true, botEnabled: false });
return;
}
onChange({
botConfigId: draft.botConfigId || botConfigs[0]?.id || "",
botConfigured: true,
botEnabled: true
});
}
function updateBot(value: string) {
if (value === ADD_BOT_SELECT_VALUE) {
onCreateBot();
return;
}
if (value === "none") {
onChange({ botConfigId: "", botConfigured: true, botEnabled: false });
return;
}
onChange({ botConfigId: value, botConfigured: true, botEnabled: true });
}
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>
<Toggle checked={draft.botEnabled} onChange={updateEnabled} />
</div>
{draft.botEnabled ? (
<div className="mt-3 border-t border-border/70 pt-3">
<Field label={t("Select bot")}>
<SelectControl onChange={updateBot} options={options} value={selectedValue} />
</Field>
</div>
) : null}
</div>
);
}
export function AddProfileDialog({
botConfigs,
canSubmit,
draft,
error,
mode = "add",
onChange,
onCreateBot,
onClose,
providers,
submitting = false,
virtualModelProfiles = [],
onSubmit
}: {
botConfigs: BotGatewaySavedConfig[];
canSubmit: boolean;
draft: AddProfileDraft;
error: string;
mode?: "add" | "edit";
onChange: (patch: Partial<AddProfileDraft>) => void;
onCreateBot: () => void;
onClose: () => void;
providers: GatewayProviderConfig[];
submitting?: boolean;
@@ -819,7 +892,7 @@ export function AddProfileDialog({
</div>
</DialogHeader>
<DialogBody>
<AddProfileForm draft={draft} error={error} onChange={onChange} providers={providers} virtualModelProfiles={virtualModelProfiles} />
<AddProfileForm botConfigs={botConfigs} draft={draft} error={error} onChange={onChange} onCreateBot={onCreateBot} providers={providers} virtualModelProfiles={virtualModelProfiles} />
</DialogBody>
<DialogFooter>
<div className="flex justify-end gap-2">
+571 -6
View File
@@ -1,10 +1,13 @@
import {
Activity, AppConfig, AppCopy, AppLanguagePreference, AppUpdateStatus, Boxes, Button,
Check, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
DialogFooter, DialogHeader, DialogTitle, Field, formatSystemOption, Gauge,
DndContext, DragEndEvent, Input, KeyboardSensor, languageDisplayName, Layers3, LoaderCircle, Palette,
Activity, AppConfig, AppCopy, AppLanguagePreference, AppUpdateStatus, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform,
botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions,
botGatewaySavedConfigFromDraft, botGatewaySavedConfigLabel, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewaySavedConfig, Button,
Check, CheckCircle2, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
DialogFooter, DialogHeader, DialogTitle, ExternalLink, Field, formatSystemOption, Gauge,
createBotGatewayConfigDraft, DndContext, DragEndEvent, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, languageDisplayName, Layers3, LoaderCircle,
normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, Palette,
PanelLeftOpen, Power, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
PointerSensor, rectSortingStrategy, RefreshCw, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
PointerSensor, QrCode, rectSortingStrategy, RefreshCw, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState,
X
@@ -13,9 +16,13 @@ import {
type UpdateActionBusy = "" | "check" | "download" | "install";
export function AppSettingsDialog({
botAddRequestKey,
botConfigs,
copy,
initialPage = "appearance",
isMac,
languagePreference,
onChangeBotConfigs,
onCheckUpdate,
onChangeLanguage,
onChangeTheme,
@@ -35,9 +42,13 @@ export function AppSettingsDialog({
updateActionError,
updateStatus
}: {
botAddRequestKey?: number;
botConfigs: BotGatewaySavedConfig[];
copy: AppCopy;
initialPage?: SettingsPageId;
isMac: boolean;
languagePreference: AppLanguagePreference;
onChangeBotConfigs: (configs: BotGatewaySavedConfig[]) => void;
onCheckUpdate: () => Promise<void>;
onChangeLanguage: (value: string) => void;
onChangeTheme: (value: string) => void;
@@ -60,6 +71,7 @@ export function AppSettingsDialog({
return (
<SettingsLayout
copy={copy}
initialPage={initialPage}
isMac={isMac}
onClose={onClose}
renderPage={(activePage) => {
@@ -89,6 +101,16 @@ export function AppSettingsDialog({
/>
);
}
if (activePage === "bots") {
return (
<BotSettingsPage
addRequestKey={botAddRequestKey}
botConfigs={botConfigs}
copy={copy}
onChange={onChangeBotConfigs}
/>
);
}
return (
<UpdateSettingsPage
actionBusy={updateActionBusy}
@@ -107,18 +129,24 @@ export function AppSettingsDialog({
function SettingsLayout({
copy,
initialPage,
isMac,
onClose,
renderPage
}: {
copy: AppCopy;
initialPage: SettingsPageId;
isMac: boolean;
onClose: () => void;
renderPage: (activePage: SettingsPageId) => ReactNode;
}) {
const [activePage, setActivePage] = useState<SettingsPageId>("appearance");
const [activePage, setActivePage] = useState<SettingsPageId>(initialPage);
const visiblePage = activePage === "tray" && !isMac ? "appearance" : activePage;
useEffect(() => {
setActivePage(initialPage);
}, [initialPage]);
return (
<Dialog onOpenChange={(open) => !open && onClose()}>
<DialogContent className="h-[min(700px,calc(100dvh-2rem))] max-w-[1160px]">
@@ -139,6 +167,13 @@ function SettingsLayout({
label={copy.settings.appearance}
onClick={() => setActivePage("appearance")}
/>
<SettingsPageButton
active={visiblePage === "bots"}
className="mt-1"
icon={Boxes}
label={copy.settings.bots}
onClick={() => setActivePage("bots")}
/>
{isMac ? (
<SettingsPageButton
active={visiblePage === "tray"}
@@ -252,6 +287,536 @@ function AppearanceSettingsPage({
);
}
function BotSettingsPage({
addRequestKey = 0,
botConfigs,
copy,
onChange
}: {
addRequestKey?: number;
botConfigs: BotGatewaySavedConfig[];
copy: AppCopy;
onChange: (configs: BotGatewaySavedConfig[]) => void;
}) {
const t = (value: string) => copy.text[value] ?? value;
const [editor, setEditor] = useState<{ config?: BotGatewaySavedConfig; mode: "add" | "edit" }>();
const lastAddRequestKey = useRef(0);
useEffect(() => {
if (addRequestKey === lastAddRequestKey.current) {
return;
}
lastAddRequestKey.current = addRequestKey;
setEditor({ mode: "add" });
}, [addRequestKey]);
function saveBotConfig(config: BotGatewaySavedConfig) {
const exists = botConfigs.some((item) => item.id === config.id);
onChange(exists
? botConfigs.map((item) => item.id === config.id ? config : item)
: [...botConfigs, config]);
setEditor(undefined);
}
function removeBotConfig(config: BotGatewaySavedConfig) {
onChange(botConfigs.filter((item) => item.id !== config.id));
}
return (
<div className="mx-auto grid max-w-[760px] grid-cols-1 gap-5">
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-foreground">{copy.settings.bots}</h3>
<div className="mt-1 text-[12px] text-muted-foreground">{t("Manage bots used by agent profiles.")}</div>
</div>
<Button onClick={() => setEditor({ mode: "add" })} size="sm" type="button">
{t("Add bot")}
</Button>
</div>
<div className="grid gap-2">
{botConfigs.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-8 text-center text-[12px] text-muted-foreground">
{t("No bots configured")}
</div>
) : null}
{botConfigs.map((config) => (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2.5" key={config.id}>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{config.name}</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{t(botGatewayPlatformLabel(config.botGateway.platform))}
{config.botGateway.authType ? ` / ${t(authMethodLabel(config.botGateway.platform, config.botGateway.authType))}` : ""}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => setEditor({ config, mode: "edit" })} size="sm" type="button" variant="outline">
{t("Edit")}
</Button>
<Button onClick={() => removeBotConfig(config)} size="sm" type="button" variant="outline">
{t("Delete")}
</Button>
</div>
</div>
))}
</div>
{editor ? (
<BotConfigDialog
botConfigs={botConfigs}
config={editor.config}
copy={copy}
mode={editor.mode}
onClose={() => setEditor(undefined)}
onSave={saveBotConfig}
/>
) : null}
</div>
);
}
type BotQrDisplay =
| { kind: "empty"; src: "" }
| { kind: "frame"; src: string }
| { kind: "image"; src: string };
type BotQrLoginState = {
display: BotQrDisplay;
error: string;
loading: boolean;
message: string;
savedConfig?: BotGatewaySavedConfig;
start?: BotGatewayQrLoginStartResult;
status: string;
wait?: BotGatewayQrLoginWaitResult;
};
function BotConfigDialog({
botConfigs,
config,
copy,
mode,
onClose,
onSave
}: {
botConfigs: BotGatewaySavedConfig[];
config?: BotGatewaySavedConfig;
copy: AppCopy;
mode: "add" | "edit";
onClose: () => void;
onSave: (config: BotGatewaySavedConfig) => void;
}) {
const t = (value: string) => copy.text[value] ?? value;
const [draft, setDraft] = useState<BotGatewayConfigDraft>(() => createBotGatewayConfigDraft(config));
const [error, setError] = useState("");
const [qrLogin, setQrLogin] = useState<BotQrLoginState>(() => emptyBotQrLoginState());
const qrSessionRef = useRef("");
const platform = normalizeBotGatewayPlatform(draft.botPlatform);
const authType = normalizeBotGatewayAuthType(platform, draft.botAuthType);
const authSpecs = botGatewayAuthSpecsForPlatform(platform);
const authFields = botGatewayFieldsForAuth(platform, authType);
const platformOptions = botGatewayPlatformOptions.map((option) => ({ ...option, label: t(option.label) }));
const authOptions = authSpecs.map((option) => ({ label: t(option.label), value: option.value }));
const qrLoginSupported = platform === "weixin-ilink" && authType === "qr_login";
const qrModeKey = qrLoginSupported ? `${config?.id ?? "new"}:${platform}:${authType}` : "";
function update(patch: Partial<BotGatewayConfigDraft>) {
setDraft((current) => ({ ...current, ...patch }));
setError("");
}
function updatePlatform(value: string) {
const nextPlatform = normalizeBotGatewayPlatform(value);
const nextAuthType = botGatewayDefaultAuthType(nextPlatform);
update({
botAuthFields: botGatewayPickAuthFields(draft.botAuthFields, nextPlatform, nextAuthType),
botAuthType: nextAuthType,
botPlatform: nextPlatform
});
}
function updateAuthType(value: string) {
const nextAuthType = normalizeBotGatewayAuthType(platform, value);
update({
botAuthFields: botGatewayPickAuthFields(draft.botAuthFields, platform, nextAuthType),
botAuthType: nextAuthType
});
}
function save() {
if (!isBotGatewayConfigDraftSubmittable(draft)) {
setError(t("Bot name, platform, and required authentication fields are required."));
return;
}
const saved = botGatewaySavedConfigFromDraft(draft, botConfigs, config ?? qrLogin.savedConfig);
if (qrLogin.start && saved.botGateway.platform === "weixin-ilink") {
saved.botGateway = {
...saved.botGateway,
integrationId: qrLogin.start.integrationId,
stateDir: qrLogin.start.stateDir,
tenantId: qrLogin.start.tenantId
};
}
onSave(saved);
}
function qrSavedConfigDraft(): BotGatewaySavedConfig {
return botGatewaySavedConfigFromDraft(
draft.name.trim() ? draft : { ...draft, name: t(botGatewayPlatformLabel(platform)) },
botConfigs,
config ?? qrLogin.savedConfig
);
}
async function cancelQrSession(sessionId = qrSessionRef.current) {
const normalized = sessionId.trim();
if (!normalized) {
return;
}
qrSessionRef.current = "";
await window.ccr?.cancelBotGatewayQrLogin?.({ sessionId: normalized }).catch(() => undefined);
}
async function startQrLogin(force = true) {
if (!window.ccr?.startBotGatewayQrLogin) {
setQrLogin((current) => ({
...current,
error: t("QR login is available in the Electron app."),
loading: false
}));
return;
}
const savedConfig = qrSavedConfigDraft();
setQrLogin((current) => ({
...current,
error: "",
loading: true,
message: t("Generating QR code"),
savedConfig,
status: "starting"
}));
await cancelQrSession();
try {
const start = await window.ccr.startBotGatewayQrLogin({ config: savedConfig, force });
qrSessionRef.current = start.sessionId;
setQrLogin({
display: normalizeBotQrDisplay(start.qrCodeUrl),
error: "",
loading: false,
message: start.message || t("Scan the QR code in Weixin."),
savedConfig,
start,
status: "qr_pending"
});
} catch (error) {
setQrLogin((current) => ({
...current,
error: formatBotQrError(error),
loading: false,
message: "",
status: "failed"
}));
}
}
useEffect(() => {
return () => {
const sessionId = qrSessionRef.current;
if (sessionId) {
void window.ccr?.cancelBotGatewayQrLogin?.({ sessionId }).catch(() => undefined);
}
};
}, []);
useEffect(() => {
if (!qrModeKey) {
void cancelQrSession();
setQrLogin(emptyBotQrLoginState());
return;
}
setQrLogin(emptyBotQrLoginState());
void startQrLogin(false);
}, [qrModeKey]);
useEffect(() => {
const sessionId = qrLogin.start?.sessionId;
if (!sessionId || !window.ccr?.waitBotGatewayQrLogin) {
return;
}
let cancelled = false;
let timer: number | undefined;
const poll = async () => {
if (cancelled) {
return;
}
try {
const wait = await window.ccr?.waitBotGatewayQrLogin?.({ sessionId, timeoutMs: 5000 });
if (cancelled || !wait) {
return;
}
setQrLogin((current) => current.start?.sessionId === sessionId
? {
...current,
error: "",
message: wait.message || current.message,
status: wait.status,
wait
}
: current);
if (isTerminalBotQrLoginStatus(wait.status)) {
qrSessionRef.current = "";
return;
}
timer = window.setTimeout(poll, 1200);
} catch (error) {
if (cancelled) {
return;
}
setQrLogin((current) => current.start?.sessionId === sessionId
? { ...current, error: formatBotQrError(error) }
: current);
timer = window.setTimeout(poll, 2500);
}
};
timer = window.setTimeout(poll, 800);
return () => {
cancelled = true;
if (timer !== undefined) {
window.clearTimeout(timer);
}
};
}, [qrLogin.start?.sessionId]);
return (
<Dialog onOpenChange={(open) => !open && onClose()} open>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === "add" ? t("Add bot") : t("Edit bot")}</DialogTitle>
<Button aria-label={copy.settings.close} onClick={onClose} size="iconSm" title={copy.settings.close} type="button" variant="ghost">
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<DialogBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field className="sm:col-span-2" label={t("Name")}>
<Input value={draft.name} onChange={(event) => update({ name: event.target.value })} />
</Field>
<Field label={t("Platform")}>
<SelectControl onChange={updatePlatform} options={platformOptions} value={platform} />
</Field>
{authOptions.length > 0 ? (
<Field label={t("Auth method")}>
<SelectControl onChange={updateAuthType} options={authOptions} value={authType} />
</Field>
) : null}
{authFields.map((field) => (
<Field
key={field.key}
label={field.required ? t(field.label) : `${t(field.label)} (${t("Optional")})`}
>
<Input
autoComplete="off"
placeholder={field.placeholder ?? ""}
type={field.type === "password" ? "password" : "text"}
value={draft.botAuthFields[field.key] ?? ""}
onChange={(event) => update({
botAuthFields: {
...draft.botAuthFields,
[field.key]: event.target.value
}
})}
/>
</Field>
))}
{qrLoginSupported ? (
<div className="rounded-md border border-border bg-muted/20 p-3 sm:col-span-2">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-foreground">{t("Weixin QR login")}</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{qrLogin.message || t("Scan with Weixin to connect this bot.")}
</div>
</div>
<Button
disabled={qrLogin.loading}
onClick={() => void startQrLogin(true)}
size="sm"
type="button"
variant="outline"
>
{qrLogin.loading ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{qrLogin.start ? t("Regenerate") : t("Generate QR code")}
</Button>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-[184px_minmax(0,1fr)]">
<div className="flex h-[184px] w-full items-center justify-center overflow-hidden rounded-md border border-border bg-white p-2">
<BotQrPreview display={qrLogin.display} label={t("Weixin QR code")} />
</div>
<div className="grid min-w-0 content-start gap-2">
<div className="rounded-md border border-border bg-background px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-[12px] font-medium text-foreground">
{qrLogin.loading ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : qrLogin.status === "confirmed" ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald" />
) : qrLogin.error || isFailedBotQrLoginStatus(qrLogin.status) ? (
<CircleAlert className="h-3.5 w-3.5 text-destructive" />
) : (
<QrCode className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span>{t(botQrLoginStatusLabel(qrLogin.status))}</span>
</div>
{qrLogin.error ? (
<div className="mt-1 break-words text-[11px] text-destructive">{qrLogin.error}</div>
) : null}
</div>
{qrLogin.start?.expiresAt ? (
<div className="rounded-md border border-border bg-background px-3 py-2">
<div className="text-[11px] font-medium text-muted-foreground">{t("Expires")}</div>
<div className="mt-1 break-all font-mono text-[11px] text-foreground">{qrLogin.start.expiresAt}</div>
</div>
) : null}
{qrLogin.display.kind === "frame" ? (
<Button
onClick={() => void window.ccr?.openExternal?.(qrLogin.display.src)}
size="sm"
type="button"
variant="outline"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("Open")}
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2 sm:col-span-2">
<span className="text-[12px] font-medium">{t("Forward agent messages")}</span>
<Switch checked={draft.botForwardAllAgentMessages} onCheckedChange={(checked) => update({ botForwardAllAgentMessages: checked === true })} />
</div>
<div className="rounded-md border border-border bg-muted/20 p-3 sm:col-span-2">
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-[12px] font-medium">{t("Handoff")}</span>
<Switch checked={draft.botHandoffEnabled} onCheckedChange={(checked) => update({ botHandoffEnabled: checked === true })} />
</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 type="number" value={draft.botHandoffIdleSeconds} onChange={(event) => update({ botHandoffIdleSeconds: event.target.value })} />
</Field>
<Field label={t("Phone Wi-Fi target")}>
<Input value={draft.botHandoffPhoneWifiTargets} onChange={(event) => update({ botHandoffPhoneWifiTargets: event.target.value })} />
</Field>
<Field label={t("Phone Bluetooth target")}>
<Input value={draft.botHandoffPhoneBluetoothTargets} onChange={(event) => update({ botHandoffPhoneBluetoothTargets: event.target.value })} />
</Field>
</div>
) : null}
</div>
</div>
{error ? (
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">
{error}
</div>
) : null}
</DialogBody>
<DialogFooter>
<Button onClick={onClose} type="button" variant="outline">{t("Cancel")}</Button>
<Button onClick={save} type="button">{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BotQrPreview({ display, label }: { display: BotQrDisplay; label: string }) {
if (display.kind === "image") {
return <img alt={label} className="h-full w-full object-contain" src={display.src} />;
}
if (display.kind === "frame") {
return (
<webview
className="h-full w-full bg-white"
partition="persist:ccr-weixin-bot-qr"
src={display.src}
title={label}
webpreferences="contextIsolation=yes,nodeIntegration=no,sandbox=yes"
/>
);
}
return <QrCode className="h-16 w-16 text-black/45" />;
}
function emptyBotQrLoginState(): BotQrLoginState {
return {
display: { kind: "empty", src: "" },
error: "",
loading: false,
message: "",
status: "idle"
};
}
function normalizeBotQrDisplay(raw: string): BotQrDisplay {
const value = raw.trim();
if (!value) {
return { kind: "empty", src: "" };
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return { kind: "frame", src: value };
}
if (value.startsWith("data:")) {
return { kind: "image", src: value };
}
if (value.startsWith("<svg")) {
return { kind: "image", src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}` };
}
return { kind: "image", src: `data:image/png;base64,${value}` };
}
function isTerminalBotQrLoginStatus(status: string): boolean {
return ["already_bound", "confirmed", "expired", "failed"].includes(status);
}
function isFailedBotQrLoginStatus(status: string): boolean {
return ["already_bound", "expired", "failed"].includes(status);
}
function botQrLoginStatusLabel(status: string): string {
switch (status) {
case "starting":
return "Generating QR code";
case "qr_pending":
case "pending":
return "Waiting for scan";
case "scanned":
return "Scanned, confirm on phone";
case "needs_verification":
return "Verification required";
case "confirmed":
return "Connected";
case "expired":
return "QR code expired";
case "already_bound":
return "Already connected";
case "failed":
return "QR login failed";
default:
return "Waiting for QR code";
}
}
function formatBotQrError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function authMethodLabel(platform: string, authType: string): string {
const normalized = normalizeBotGatewayAuthType(platform, authType);
return botGatewayAuthSpecsForPlatform(platform).find((option) => option.value === normalized)?.label ?? normalized;
}
function UpdateSettingsPage({
actionBusy,
actionError,
File diff suppressed because it is too large Load Diff
+23
View File
@@ -1,5 +1,6 @@
export {};
import type * as React from "react";
import type {
AgentAnalysisFilter,
AgentAnalysisSnapshot,
@@ -7,6 +8,12 @@ import type {
AppInfo,
AppUpdateStatus,
ApiKeyConfig,
BotGatewayQrLoginCancelRequest,
BotGatewayQrLoginCancelResult,
BotGatewayQrLoginStartRequest,
BotGatewayQrLoginStartResult,
BotGatewayQrLoginWaitRequest,
BotGatewayQrLoginWaitResult,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
GatewayMcpToolInfo,
@@ -39,10 +46,24 @@ 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>;
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
closeTray: () => Promise<void>;
detectProviderIcon: (request: ProviderIconDetectionRequest) => Promise<ProviderIconDetectionResult>;
@@ -80,11 +101,13 @@ declare global {
setTrayDetailOpen: (open: boolean, provider?: string) => Promise<void>;
showMainWindow: () => Promise<void>;
startGateway: () => Promise<GatewayStatus>;
startBotGatewayQrLogin: (request: BotGatewayQrLoginStartRequest) => Promise<BotGatewayQrLoginStartResult>;
stopGateway: () => Promise<GatewayStatus>;
testProviderAccountConnector: (request: ProviderAccountTestRequest) => Promise<ProviderAccountTestResult>;
updateCheck: () => Promise<AppUpdateStatus>;
updateDownload: () => Promise<AppUpdateStatus>;
updateInstall: () => Promise<void>;
waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise<BotGatewayQrLoginWaitResult>;
onBeforeQuit: (callback: () => void) => () => void;
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void;
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void;
+92
View File
@@ -740,6 +740,8 @@ export type CodexProfileConfig = {
export type ProfileConfig = {
agent: ProfileClientKind;
botConfigId?: string;
botGateway?: BotGatewayRuntimeConfig;
configFile?: string;
cliMiddleware?: boolean;
codexCliPath?: string;
@@ -877,6 +879,94 @@ export type ProxyCertificateStatus = {
trusted: boolean;
};
export type BotGatewayHandoffConfig = {
enabled: boolean;
idleSeconds: number;
phoneBluetoothTargets: string[];
phoneWifiTargets: string[];
screenLock: boolean;
userIdle: boolean;
};
export type BotGatewayConversationConfig = {
gatewayConversationId?: string;
platformConversationId?: string;
threadId?: string;
type: "dm" | "group" | "channel" | "thread";
};
export type BotGatewayRuntimeConfig = {
acknowledgeEvents: boolean;
args: string[];
authType: string;
autoStartIntegration: boolean;
command: string;
conversationRef?: BotGatewayConversationConfig;
createIntegration: boolean;
credentials: Record<string, unknown>;
cwd: string;
enabled: boolean;
forwardAllAgentMessages: boolean;
handoff: BotGatewayHandoffConfig;
integrationConfig: Record<string, unknown>;
integrationId: string;
platform: string;
pollIntervalMs: number;
requestTimeoutMs: number;
sourceDir: string;
startupTimeoutMs: number;
stateDir: string;
tenantId: string;
};
export type BotGatewaySavedConfig = {
botGateway: BotGatewayRuntimeConfig;
id: string;
name: string;
updatedAt?: string;
};
export type BotGatewayQrLoginStartRequest = {
config: BotGatewaySavedConfig;
force?: boolean;
};
export type BotGatewayQrLoginStartResult = {
botConfigId: string;
expiresAt: string;
integrationId: string;
message: string;
platform: string;
qrCodeUrl: string;
sessionId: string;
stateDir: string;
tenantId: string;
};
export type BotGatewayQrLoginWaitRequest = {
sessionId: string;
timeoutMs?: number;
verifyCode?: string;
};
export type BotGatewayQrLoginWaitResult = {
confirmed: boolean;
integrationId: string;
message: string;
sessionId: string;
stateDir: string;
status: string;
tenantId: string;
};
export type BotGatewayQrLoginCancelRequest = {
sessionId: string;
};
export type BotGatewayQrLoginCancelResult = {
canceled: boolean;
};
export type AppConfig = {
APIKEY: string;
APIKEYS: ApiKeyConfig[];
@@ -888,6 +978,8 @@ export type AppConfig = {
Router: RouterConfig;
agent: GatewayAgentConfig;
autoStart: boolean;
botConfigs: BotGatewaySavedConfig[];
botGateway: BotGatewayRuntimeConfig;
gateway: GatewayRuntimeConfig;
preferredProvider: string;
plugins: GatewayPluginConfig[];
+3
View File
@@ -24,6 +24,9 @@ export const IPC_CHANNELS = {
appOpenProfile: "ccr:app:open-profile",
appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway",
appApplyProfile: "ccr:app:apply-profile",
appBotGatewayQrLoginCancel: "ccr:app:bot-gateway-qr-login-cancel",
appBotGatewayQrLoginStart: "ccr:app:bot-gateway-qr-login-start",
appBotGatewayQrLoginWait: "ccr:app:bot-gateway-qr-login-wait",
appProbeProvider: "ccr:app:probe-provider",
appProviderDeepLink: "ccr:app:provider-deep-link",
appGetPluginMarketplace: "ccr:app:get-plugin-marketplace",