mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Add configurable model routing and provider docs
This commit is contained in:
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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"
|
||||
});
|
||||
|
||||
@@ -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
@@ -42,7 +42,8 @@ class WindowsManager {
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
webSecurity: true,
|
||||
webviewTag: true
|
||||
},
|
||||
width
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Vendored
+23
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user