mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor provider routing and update integrations
This commit is contained in:
parent
4ff0c9ebc3
commit
c480a33587
42 changed files with 6615 additions and 1716 deletions
|
|
@ -1,10 +1,9 @@
|
|||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyBuiltInMcpServers, copyMarketplacePlugins, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
|
||||
import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs";
|
||||
|
||||
const mode = process.argv.includes("--dev") ? "development" : "production";
|
||||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyBuiltInMcpServers();
|
||||
copyMarketplacePlugins();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
browserRendererHtmlInput,
|
||||
copyAppAssets,
|
||||
copyBrowserRendererHtml,
|
||||
copyBuiltInMcpServers,
|
||||
copyMarketplacePlugins,
|
||||
copyRendererHtml,
|
||||
copyTrayRendererHtml,
|
||||
|
|
@ -73,7 +72,6 @@ function restartElectron() {
|
|||
|
||||
cleanDist();
|
||||
copyAppAssets();
|
||||
copyBuiltInMcpServers();
|
||||
copyMarketplacePlugins();
|
||||
copyBrowserRendererHtml();
|
||||
copyRendererHtml();
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
import esbuild from "esbuild";
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { builtinModules, createRequire } from "node:module";
|
||||
import { builtinModules } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export const projectRoot = path.resolve(__dirname, "..");
|
||||
export const distDir = path.join(projectRoot, "dist");
|
||||
export const mainOutDir = path.join(distDir, "main");
|
||||
export const rendererOutDir = path.join(distDir, "renderer");
|
||||
export const appAssetsDir = path.join(distDir, "assets");
|
||||
export const vendorDir = path.join(distDir, "vendor");
|
||||
export const unimcpVendorDir = path.join(vendorDir, "unimcp");
|
||||
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
|
||||
export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins");
|
||||
export const appAssetsInput = path.join(projectRoot, "assets");
|
||||
|
|
@ -42,7 +39,6 @@ export function cleanDist() {
|
|||
export function ensureDist() {
|
||||
mkdirSync(mainOutDir, { recursive: true });
|
||||
mkdirSync(appAssetsDir, { recursive: true });
|
||||
mkdirSync(vendorDir, { recursive: true });
|
||||
mkdirSync(marketplacePluginsDir, { recursive: true });
|
||||
mkdirSync(rendererAssetsDir, { recursive: true });
|
||||
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
|
||||
|
|
@ -50,12 +46,6 @@ export function ensureDist() {
|
|||
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
|
||||
}
|
||||
|
||||
export function copyBuiltInMcpServers() {
|
||||
ensureDist();
|
||||
const packageJsonPath = require.resolve("@musistudio/unimcp/package.json");
|
||||
cpSync(path.dirname(packageJsonPath), unimcpVendorDir, { recursive: true });
|
||||
}
|
||||
|
||||
export function copyAppAssets() {
|
||||
ensureDist();
|
||||
if (existsSync(appAssetsInput)) {
|
||||
|
|
@ -110,6 +100,8 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = {
|
|||
entryPoints: [
|
||||
path.join(projectRoot, "src", "main", "main.ts"),
|
||||
path.join(projectRoot, "src", "main", "browser-preload.ts"),
|
||||
path.join(projectRoot, "src", "main", "cli.ts"),
|
||||
path.join(projectRoot, "src", "main", "mcp", "fusion-vision-mcp.ts"),
|
||||
path.join(projectRoot, "src", "main", "preload.ts")
|
||||
],
|
||||
external: nodeExternals,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const pathModule = require("node:path");
|
|||
const DEFAULT_HOST = "claude.ai";
|
||||
const DEFAULT_GATEWAY_URL = "http://127.0.0.1:3456";
|
||||
const DEFAULT_UPSTREAM_ORIGIN = "https://claude.ai";
|
||||
const DEFAULT_DESIGN_ORIGIN = "https://claude.ai";
|
||||
const DEFAULT_DESIGN_REFERRER = "https://claude.ai/design";
|
||||
const DEFAULT_FALLBACK_ROUTE_HOSTS = ["claude.com", "www.anthropic.com", "anthropic.com"];
|
||||
const OMELETTE_RPC_PATH_PREFIX = "/design/anthropic.omelette.api.v1alpha.OmeletteService";
|
||||
const AUTH_ESCAPE_ROUTE_PATHS = ["/login", "/auth", "/oauth"];
|
||||
|
|
@ -16,10 +18,17 @@ const BOOTSTRAP_ROUTE_PATHS = ["/_bootstrap", "/api/bootstrap"];
|
|||
const REQUIRED_ROUTE_PATHS = ["/", OMELETTE_RPC_PATH_PREFIX, "/v1/design", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_ROUTE_PATHS = ["/design", "/v1/design", "/api", "/organizations", "/cdn-cgi", ...BOOTSTRAP_ROUTE_PATHS, ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const FALLBACK_ROUTE_PATHS = ["/app-unavailable-in-region", "/cdn-cgi", "/design", ...AUTH_ESCAPE_ROUTE_PATHS];
|
||||
const DEFAULT_SCRIPT_PATH = "/design/assets/index-DWa5J5J9.js";
|
||||
const DEFAULT_STYLE_PATH = "/design/assets/index-DZOB93ZB.css";
|
||||
const LEGACY_SCRIPT_PATHS = new Set(["/design/assets/index-DYd5ifc6.js"]);
|
||||
const LEGACY_STYLE_PATHS = new Set(["/design/assets/index-j8_-aIUE.css"]);
|
||||
const DEFAULT_SCRIPT_PATH = "";
|
||||
const DEFAULT_STYLE_PATH = "";
|
||||
const LEGACY_SCRIPT_PATHS = new Set([
|
||||
"/design/assets/index-DWa5J5J9.js",
|
||||
"/design/assets/index-DYd5ifc6.js",
|
||||
"/design/assets/index-BxFzSrWf.js"
|
||||
]);
|
||||
const LEGACY_STYLE_PATHS = new Set([
|
||||
"/design/assets/index-DZOB93ZB.css",
|
||||
"/design/assets/index-j8_-aIUE.css"
|
||||
]);
|
||||
const DEFAULT_EXTERNAL_ASSET_BASE_URLS = ["https://assets-proxy.anthropic.com/claude-ai/v2/assets/v1/"];
|
||||
const DESIGN_INDEX_ASSET_DISCOVERY_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_UPSTREAM_ASSET_REDIRECTS = 5;
|
||||
|
|
@ -145,8 +154,10 @@ module.exports = {
|
|||
const upstreamOrigin = stringValue(options.upstreamOrigin) || DEFAULT_UPSTREAM_ORIGIN;
|
||||
const assetProxy = options.assetProxy !== false;
|
||||
const assetDir = stringValue(options.assetDir);
|
||||
const scriptPath = normalizePath(stringValue(options.scriptPath) || DEFAULT_SCRIPT_PATH);
|
||||
const stylePath = normalizePath(stringValue(options.stylePath) || DEFAULT_STYLE_PATH);
|
||||
const configuredScriptPath = normalizePath(stringValue(options.scriptPath) || DEFAULT_SCRIPT_PATH);
|
||||
const configuredStylePath = normalizePath(stringValue(options.stylePath) || DEFAULT_STYLE_PATH);
|
||||
const scriptPath = shouldKeepCurrentScriptPath(configuredScriptPath) ? configuredScriptPath : DEFAULT_SCRIPT_PATH;
|
||||
const stylePath = shouldKeepCurrentStylePath(configuredStylePath) ? configuredStylePath : DEFAULT_STYLE_PATH;
|
||||
const assetAutoUpdate = options.assetAutoUpdate !== false;
|
||||
const autoAnswerQuestions = options.autoAnswerQuestions !== false;
|
||||
const gatewayUrl = stringValue(options.gatewayUrl) || DEFAULT_GATEWAY_URL;
|
||||
|
|
@ -238,6 +249,7 @@ module.exports = {
|
|||
assetDir,
|
||||
designIndexAssets: {
|
||||
checkedAt: 0,
|
||||
html: "",
|
||||
scriptPath,
|
||||
source: "configured",
|
||||
stylePath
|
||||
|
|
@ -351,12 +363,12 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
|
|||
|
||||
if (method === "GET" && (path === "/" || isDesignSpaRoute(path))) {
|
||||
const assets = await resolveDesignIndexAssets(runtime, request);
|
||||
return htmlResponse(renderDesignIndex(runtime.me, assets.scriptPath, assets.stylePath));
|
||||
return htmlResponse(renderDesignIndex(runtime.me, assets.scriptPath, assets.stylePath, assets.html));
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/app-unavailable-in-region") {
|
||||
const assets = await resolveDesignIndexAssets(runtime, request);
|
||||
return htmlResponse(renderDesignIndex(runtime.me, assets.scriptPath, assets.stylePath));
|
||||
return htmlResponse(renderDesignIndex(runtime.me, assets.scriptPath, assets.stylePath, assets.html));
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/design/favicon.png") {
|
||||
|
|
@ -927,6 +939,7 @@ function normalizeFallbackRouteHosts(value, primaryHost) {
|
|||
async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
||||
const current = runtime.designIndexAssets || {
|
||||
checkedAt: 0,
|
||||
html: "",
|
||||
scriptPath: runtime.scriptPath || DEFAULT_SCRIPT_PATH,
|
||||
source: "default",
|
||||
stylePath: runtime.stylePath || DEFAULT_STYLE_PATH
|
||||
|
|
@ -937,7 +950,8 @@ async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
|||
|
||||
const now = Date.now();
|
||||
const forceRefresh = options.force === true || requestHasNoCache(request);
|
||||
if (!forceRefresh && current.checkedAt && now - current.checkedAt < DESIGN_INDEX_ASSET_DISCOVERY_TTL_MS) {
|
||||
const hasRenderableIndex = isUsableDesignShellHtml(current.html) || Boolean(current.scriptPath || current.stylePath);
|
||||
if (!forceRefresh && hasRenderableIndex && current.checkedAt && now - current.checkedAt < DESIGN_INDEX_ASSET_DISCOVERY_TTL_MS) {
|
||||
return current;
|
||||
}
|
||||
|
||||
|
|
@ -948,6 +962,7 @@ async function resolveDesignIndexAssets(runtime, request, options = {}) {
|
|||
const fromCache = discoverCachedDesignIndexAssets(runtime.store);
|
||||
const fallback = mergeDesignIndexAssetPartials(fromLocal, fromCache) || {};
|
||||
const seeded = {
|
||||
html: current.html,
|
||||
scriptPath: fallback.scriptPath || current.scriptPath,
|
||||
source: fallback.source || current.source || "current",
|
||||
stylePath: fallback.stylePath || current.stylePath
|
||||
|
|
@ -965,16 +980,25 @@ function requestHasNoCache(request) {
|
|||
function updateDesignIndexAssets(runtime, assets, source, options = {}) {
|
||||
const current = runtime.designIndexAssets || {
|
||||
checkedAt: 0,
|
||||
html: "",
|
||||
scriptPath: runtime.scriptPath || DEFAULT_SCRIPT_PATH,
|
||||
source: "default",
|
||||
stylePath: runtime.stylePath || DEFAULT_STYLE_PATH
|
||||
};
|
||||
const scriptPath = normalizePath(stringValue(assets?.scriptPath) || current.scriptPath || DEFAULT_SCRIPT_PATH);
|
||||
const stylePath = normalizePath(stringValue(assets?.stylePath) || current.stylePath || DEFAULT_STYLE_PATH);
|
||||
const candidateScriptPath = normalizePath(stringValue(assets?.scriptPath) || current.scriptPath || DEFAULT_SCRIPT_PATH);
|
||||
const candidateStylePath = normalizePath(stringValue(assets?.stylePath) || current.stylePath || DEFAULT_STYLE_PATH);
|
||||
const scriptPath = shouldKeepCurrentScriptPath(candidateScriptPath) ? candidateScriptPath : DEFAULT_SCRIPT_PATH;
|
||||
const stylePath = shouldKeepCurrentStylePath(candidateStylePath) ? candidateStylePath : DEFAULT_STYLE_PATH;
|
||||
const html = isUsableDesignShellHtml(assets?.html)
|
||||
? String(assets.html)
|
||||
: isUsableDesignShellHtml(current.html)
|
||||
? String(current.html)
|
||||
: "";
|
||||
runtime.scriptPath = scriptPath;
|
||||
runtime.stylePath = stylePath;
|
||||
runtime.designIndexAssets = {
|
||||
checkedAt: options.checkedAt ?? Date.now(),
|
||||
html,
|
||||
scriptPath,
|
||||
source,
|
||||
stylePath,
|
||||
|
|
@ -988,9 +1012,10 @@ function updateDesignIndexAssets(runtime, assets, source, options = {}) {
|
|||
|
||||
function mergeDesignIndexAssets(current, ...candidates) {
|
||||
const merged = {
|
||||
scriptPath: current.scriptPath || DEFAULT_SCRIPT_PATH,
|
||||
html: isUsableDesignShellHtml(current.html) ? String(current.html) : "",
|
||||
scriptPath: shouldKeepCurrentScriptPath(current.scriptPath) ? current.scriptPath : DEFAULT_SCRIPT_PATH,
|
||||
source: current.source || "current",
|
||||
stylePath: current.stylePath || DEFAULT_STYLE_PATH,
|
||||
stylePath: shouldKeepCurrentStylePath(current.stylePath) ? current.stylePath : DEFAULT_STYLE_PATH,
|
||||
upstreamUrls: { ...(current.upstreamUrls || {}) }
|
||||
};
|
||||
for (const candidate of candidates) {
|
||||
|
|
@ -1003,6 +1028,10 @@ function mergeDesignIndexAssets(current, ...candidates) {
|
|||
...candidate.upstreamUrls
|
||||
};
|
||||
}
|
||||
if (isUsableDesignShellHtml(candidate.html)) {
|
||||
merged.html = String(candidate.html);
|
||||
merged.source = candidate.source || merged.source;
|
||||
}
|
||||
if (candidate.scriptPath) {
|
||||
merged.scriptPath = candidate.scriptPath;
|
||||
merged.source = candidate.source || merged.source;
|
||||
|
|
@ -1040,20 +1069,27 @@ function mergeDesignIndexAssetPartials(...candidates) {
|
|||
}
|
||||
|
||||
function selectUsableDesignIndexAssets(runtime, discovered, fallback, current) {
|
||||
const currentScriptPath = shouldKeepCurrentScriptPath(current.scriptPath) ? current.scriptPath : DEFAULT_SCRIPT_PATH;
|
||||
const currentStylePath = shouldKeepCurrentStylePath(current.stylePath) ? current.stylePath : DEFAULT_STYLE_PATH;
|
||||
const html = isUsableDesignShellHtml(discovered.html)
|
||||
? discovered.html
|
||||
: isUsableDesignShellHtml(current.html)
|
||||
? current.html
|
||||
: "";
|
||||
const scriptPath = isUsableDesignIndexScript(runtime, discovered.scriptPath)
|
||||
? discovered.scriptPath
|
||||
: isUsableDesignIndexScript(runtime, fallback.scriptPath)
|
||||
? fallback.scriptPath
|
||||
: current.scriptPath || DEFAULT_SCRIPT_PATH;
|
||||
: currentScriptPath;
|
||||
const stylePath = isUsableDesignIndexStyle(runtime, discovered.stylePath)
|
||||
? discovered.stylePath
|
||||
: isUsableDesignIndexStyle(runtime, fallback.stylePath)
|
||||
? fallback.stylePath
|
||||
: current.stylePath || DEFAULT_STYLE_PATH;
|
||||
: currentStylePath;
|
||||
const source = scriptPath === discovered.scriptPath || stylePath === discovered.stylePath
|
||||
? discovered.source
|
||||
: fallback.source || current.source || "current";
|
||||
return { scriptPath, source, stylePath };
|
||||
return { html, scriptPath, source, stylePath };
|
||||
}
|
||||
|
||||
function recordDesignIndexAssetHint(runtime, path, source) {
|
||||
|
|
@ -1088,17 +1124,20 @@ async function discoverRemoteDesignIndexAssets(runtime, request) {
|
|||
if (!shell) {
|
||||
continue;
|
||||
}
|
||||
logRemoteDesignShell(runtime, shellUrl, shell);
|
||||
const assets = mergeDesignIndexAssetPartials(
|
||||
extractDesignIndexAssetsFromLinkHeaders(shell.linkHeaders, shell.url),
|
||||
extractDesignIndexAssetsFromHtml(shell.body, shell.url)
|
||||
);
|
||||
if (assets?.scriptPath || assets?.stylePath) {
|
||||
const html = isUsableDesignShellHtml(shell.body) ? shell.body : "";
|
||||
if (html || assets?.scriptPath || assets?.stylePath) {
|
||||
return {
|
||||
html,
|
||||
origin,
|
||||
scriptPath: assets.scriptPath,
|
||||
scriptPath: assets?.scriptPath,
|
||||
source: "remote",
|
||||
stylePath: assets.stylePath,
|
||||
upstreamUrls: assets.upstreamUrls
|
||||
stylePath: assets?.stylePath,
|
||||
upstreamUrls: assets?.upstreamUrls
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1106,6 +1145,57 @@ async function discoverRemoteDesignIndexAssets(runtime, request) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function logRemoteDesignShell(runtime, shellUrl, shell) {
|
||||
const summary = summarizeRemoteDesignShell(shell);
|
||||
runtime.logger?.warn?.(
|
||||
[
|
||||
"Claude Design upstream /design raw HTML:",
|
||||
`request-url=${shellUrl.toString()}`,
|
||||
`final-url=${shell.url || shellUrl.toString()}`,
|
||||
`status=${shell.status}`,
|
||||
`content-type=${shell.contentType || "unknown"}`,
|
||||
"----- BEGIN RAW HTML -----",
|
||||
shell.body || "",
|
||||
"----- END RAW HTML -----",
|
||||
"Claude Design upstream /design raw HTML summary:",
|
||||
`title=${summary.title || "unknown"}`,
|
||||
`has-assets-proxy=${summary.hasAssetsProxy}`,
|
||||
`has-design-assets=${summary.hasDesignAssets}`,
|
||||
`has-app-unavailable=${summary.hasAppUnavailable}`,
|
||||
`has-cloudflare-challenge=${summary.hasCloudflareChallenge}`,
|
||||
`module-scripts=${summary.moduleScripts.join(", ") || "none"}`,
|
||||
`stylesheets=${summary.stylesheets.join(", ") || "none"}`
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeRemoteDesignShell(shell) {
|
||||
const html = String(shell?.body || "");
|
||||
return {
|
||||
hasAppUnavailable: /App unavailable in region/i.test(html),
|
||||
hasAssetsProxy: /https:\/\/assets-proxy\.anthropic\.com\/claude-ai\/v2\/assets\/v1\//i.test(html),
|
||||
hasCloudflareChallenge: /\bJust a moment\b|cf_chl|Performing security verification/i.test(html),
|
||||
hasDesignAssets: /(?:src|href)=["'][^"']*(?:\/design)?\/assets\//i.test(html),
|
||||
moduleScripts: firstMatches(html, /<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi, 5),
|
||||
stylesheets: firstMatches(html, /<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi, 5),
|
||||
title: firstMatch(html, /<title[^>]*>([^<]*)<\/title>/i)
|
||||
};
|
||||
}
|
||||
|
||||
function firstMatch(value, pattern) {
|
||||
const match = pattern.exec(String(value || ""));
|
||||
return match?.[1]?.trim() || "";
|
||||
}
|
||||
|
||||
function firstMatches(value, pattern, limit) {
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = pattern.exec(String(value || ""))) && matches.length < limit) {
|
||||
matches.push(match[1]);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function upstreamDesignShellUrlCandidates(origin, runtime) {
|
||||
const paths = ["/design"];
|
||||
const seen = new Set();
|
||||
|
|
@ -1124,7 +1214,10 @@ async function cacheRemoteDesignIndexAssets(runtime, assets, request) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const cached = { source: assets.source || "remote" };
|
||||
const cached = {
|
||||
html: isUsableDesignShellHtml(assets.html) ? assets.html : "",
|
||||
source: assets.source || "remote"
|
||||
};
|
||||
const scriptPath = normalizePath(assets.scriptPath);
|
||||
if (scriptPath) {
|
||||
if (
|
||||
|
|
@ -1145,7 +1238,7 @@ async function cacheRemoteDesignIndexAssets(runtime, assets, request) {
|
|||
}
|
||||
}
|
||||
|
||||
if (cached.scriptPath || cached.stylePath) {
|
||||
if (cached.html || cached.scriptPath || cached.stylePath) {
|
||||
cached.upstreamUrls = assets.upstreamUrls;
|
||||
return cached;
|
||||
}
|
||||
|
|
@ -1336,6 +1429,9 @@ function isUsableDesignIndexScript(runtime, path) {
|
|||
if (!isDesignIndexScriptPath(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (LEGACY_SCRIPT_PATHS.has(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (runtime?.store && isUsableCachedEntryScript(runtime.store, normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1347,6 +1443,9 @@ function isUsableDesignIndexStyle(runtime, path) {
|
|||
if (!isDesignIndexStylePath(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (LEGACY_STYLE_PATHS.has(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (runtime?.store && isUsableCachedEntryStyle(runtime.store, normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -6146,18 +6245,12 @@ function fetchUpstreamDesignShell(upstreamUrl, request, redirectsRemaining = MAX
|
|||
}
|
||||
|
||||
function upstreamDesignShellHeaders(upstreamUrl, request) {
|
||||
const origin = `${upstreamUrl.protocol}//${upstreamUrl.host}`;
|
||||
const cookie = headerValue(request.headers.cookie);
|
||||
return {
|
||||
accept: headerValue(request.headers.accept) || "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-encoding": "identity",
|
||||
"accept-language": headerValue(request.headers["accept-language"]) || "en-US,en;q=0.9",
|
||||
"cache-control": "no-cache",
|
||||
pragma: "no-cache",
|
||||
referer: headerValue(request.headers.referer) || `${origin}/design/`,
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"upgrade-insecure-requests": "1",
|
||||
accept: "*/*",
|
||||
...(cookie ? { cookie } : {}),
|
||||
origin: DEFAULT_DESIGN_ORIGIN,
|
||||
referer: DEFAULT_DESIGN_REFERRER,
|
||||
"user-agent":
|
||||
headerValue(request.headers["user-agent"]) ||
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
|
|
@ -6165,14 +6258,15 @@ function upstreamDesignShellHeaders(upstreamUrl, request) {
|
|||
}
|
||||
|
||||
function upstreamAssetHeaders(upstreamUrl, request) {
|
||||
const origin = `${upstreamUrl.protocol}//${upstreamUrl.host}`;
|
||||
const cookie = shouldForwardCookieToAsset(upstreamUrl) ? headerValue(request.headers.cookie) : undefined;
|
||||
return {
|
||||
accept: headerValue(request.headers.accept) || defaultAssetAccept(upstreamUrl.pathname),
|
||||
"accept-encoding": "identity",
|
||||
"accept-language": headerValue(request.headers["accept-language"]) || "en-US,en;q=0.9",
|
||||
"cache-control": "no-cache",
|
||||
...(cookie ? { cookie } : {}),
|
||||
pragma: "no-cache",
|
||||
referer: headerValue(request.headers.referer) || `${origin}/design/`,
|
||||
referer: DEFAULT_DESIGN_REFERRER,
|
||||
"sec-fetch-dest": assetFetchDest(upstreamUrl.pathname),
|
||||
"sec-fetch-mode": "no-cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
|
|
@ -6182,6 +6276,10 @@ function upstreamAssetHeaders(upstreamUrl, request) {
|
|||
};
|
||||
}
|
||||
|
||||
function shouldForwardCookieToAsset(upstreamUrl) {
|
||||
return upstreamUrl.hostname === DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function defaultAssetAccept(path) {
|
||||
if (/\.(?:js|mjs)$/i.test(path)) {
|
||||
return "*/*";
|
||||
|
|
@ -6303,7 +6401,16 @@ function readRequestBody(request) {
|
|||
});
|
||||
}
|
||||
|
||||
function renderDesignIndex(me, scriptPath, stylePath) {
|
||||
function renderDesignIndex(me, scriptPath, stylePath, html) {
|
||||
if (isUsableDesignShellHtml(html)) {
|
||||
return injectDesignMeIntoHtml(String(html), me);
|
||||
}
|
||||
const scriptTag = scriptPath
|
||||
? ` <script type="module" crossorigin src="${escapeHtmlAttribute(scriptPath)}"></script>\n`
|
||||
: "";
|
||||
const styleTag = stylePath
|
||||
? ` <link rel="stylesheet" crossorigin href="${escapeHtmlAttribute(stylePath)}">\n`
|
||||
: "";
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -6311,9 +6418,7 @@ function renderDesignIndex(me, scriptPath, stylePath) {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Claude Design</title>
|
||||
<link rel="icon" type="image/png" href="/design/favicon.png"/>
|
||||
<script type="module" crossorigin src="${escapeHtmlAttribute(scriptPath)}"></script>
|
||||
<link rel="stylesheet" crossorigin href="${escapeHtmlAttribute(stylePath)}">
|
||||
</head>
|
||||
${scriptTag}${styleTag} </head>
|
||||
<body>
|
||||
<script>
|
||||
window.__OMELETTE_ME__ = ${escapeJsonForScript(me)}
|
||||
|
|
@ -6324,6 +6429,32 @@ function renderDesignIndex(me, scriptPath, stylePath) {
|
|||
`;
|
||||
}
|
||||
|
||||
function injectDesignMeIntoHtml(html, me) {
|
||||
if (html.includes("__OMELETTE_ME__")) {
|
||||
return html;
|
||||
}
|
||||
const meScript = `<script>window.__OMELETTE_ME__ = ${escapeJsonForScript(me)}</script>`;
|
||||
if (/<body\b[^>]*>/i.test(html)) {
|
||||
return html.replace(/<body\b([^>]*)>/i, `<body$1>\n ${meScript}`);
|
||||
}
|
||||
return `${meScript}\n${html}`;
|
||||
}
|
||||
|
||||
function isUsableDesignShellHtml(value) {
|
||||
const html = stringValue(value);
|
||||
if (!html) {
|
||||
return false;
|
||||
}
|
||||
if (!/<html[\s>]/i.test(html) || !/<script\b[^>]*\bsrc=/i.test(html)) {
|
||||
return false;
|
||||
}
|
||||
if (/\bJust a moment\b|cf_chl|Performing security verification|App unavailable in region/i.test(html)) {
|
||||
return false;
|
||||
}
|
||||
return /(?:src|href)=["'][^"']*(?:\/design)?\/assets\//i.test(html) ||
|
||||
/anthropic\.omelette|OmeletteService|__OMELETTE_ME__|\/v1\/design/i.test(html);
|
||||
}
|
||||
|
||||
function normalizeMe(value) {
|
||||
const overrides = isRecord(value) ? value : {};
|
||||
const me = {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,9 @@
|
|||
"assetAutoUpdate": true,
|
||||
"autoAnswerQuestions": true,
|
||||
"assetDir": "/absolute/path/to/claude-design-assets",
|
||||
"scriptPath": "/design/assets/index-DWa5J5J9.js",
|
||||
"stylePath": "/design/assets/index-DZOB93ZB.css",
|
||||
// Optional. Leave unset to auto-discover the current hashed entry assets.
|
||||
// "scriptPath": "/design/assets/index-<hash>.js",
|
||||
// "stylePath": "/design/assets/index-<hash>.css",
|
||||
"upstreamOrigin": "https://claude.ai",
|
||||
// Optional. Missing lazy-loaded assets are fetched from these origins in order.
|
||||
"upstreamOrigins": ["https://claude.ai"],
|
||||
|
|
|
|||
1023
package-lock.json
generated
1023
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,9 @@
|
|||
"version": "3.0.0",
|
||||
"description": "Desktop scaffold for Claude Code Router.",
|
||||
"main": "dist/main/main.js",
|
||||
"bin": {
|
||||
"ccr": "dist/main/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node build/dev.mjs",
|
||||
"build": "npm run build:assets && electron-builder",
|
||||
|
|
@ -18,8 +21,7 @@
|
|||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@musistudio/unimcp": "1.1.0",
|
||||
"@the-next-ai/ai-gateway": "file:../next-ai/ai-gateway",
|
||||
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
|
||||
"node-forge": "^1.4.0",
|
||||
"sql.js": "^1.14.1",
|
||||
"undici": "^7.27.2"
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ type ClaudeAppGatewayBackup = {
|
|||
version: 1;
|
||||
};
|
||||
|
||||
type ClaudeAppGatewayApplyOptions = {
|
||||
backup?: boolean;
|
||||
dataDir?: string;
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewaySyncResult = {
|
||||
config: AppConfig;
|
||||
configChanged: boolean;
|
||||
|
|
@ -76,9 +81,9 @@ export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise<Cla
|
|||
};
|
||||
}
|
||||
|
||||
export function applyClaudeAppGatewayConfig(config: AppConfig): { config: AppConfig; result: ClaudeAppGatewayApplyResult } {
|
||||
export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAppGatewayApplyOptions = {}): { config: AppConfig; result: ClaudeAppGatewayApplyResult } {
|
||||
const state = ensureClaudeAppGatewayState(config);
|
||||
const paths = getClaudeAppGatewayPaths();
|
||||
const paths = getClaudeAppGatewayPaths(options.dataDir);
|
||||
const endpoint = gatewayEndpoint(state.config);
|
||||
const model = inferClaudeAppGatewayModel(state.config);
|
||||
const gatewayConfig: ClaudeAppGatewayConfig = {
|
||||
|
|
@ -92,7 +97,9 @@ export function applyClaudeAppGatewayConfig(config: AppConfig): { config: AppCon
|
|||
unstableDisableModelVerification: true
|
||||
};
|
||||
|
||||
backupClaudeAppGatewayConfig(paths);
|
||||
if (options.backup !== false) {
|
||||
backupClaudeAppGatewayConfig(paths);
|
||||
}
|
||||
mkdirSync(paths.libraryDir, { mode: 0o700, recursive: true });
|
||||
writeJsonFile(paths.configLibraryFile, gatewayConfig);
|
||||
applyClaudeAppConfigMeta(paths.metaFile);
|
||||
|
|
@ -175,8 +182,7 @@ function findReusableApiKey(config: AppConfig): string {
|
|||
return config.APIKEY.trim();
|
||||
}
|
||||
|
||||
function getClaudeAppGatewayPaths(): ClaudeAppGatewayPaths {
|
||||
const dataDir = getClaudeApp3pDataDir();
|
||||
function getClaudeAppGatewayPaths(dataDir = getClaudeApp3pDataDir()): ClaudeAppGatewayPaths {
|
||||
const libraryDir = path.join(dataDir, CLAUDE_APP_CONFIG_LIBRARY_DIR);
|
||||
return {
|
||||
configLibraryFile: path.join(libraryDir, `${CLAUDE_APP_CONFIG_ID}.json`),
|
||||
|
|
|
|||
364
src/main/claude-app-launch.ts
Normal file
364
src/main/claude-app-launch.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProfileConfig } from "../shared/app";
|
||||
import { resolveClaudeCodeSettingsFile } from "./profile-launch-core";
|
||||
|
||||
type ClaudeAppLookupResult = {
|
||||
checked: string[];
|
||||
executable?: string;
|
||||
};
|
||||
|
||||
export type ClaudeAppLaunchResult = {
|
||||
command: string;
|
||||
pid?: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
const macClaudeAppNames = ["Claude.app", "Claude Desktop.app"];
|
||||
const windowsClaudeAppDirs = ["Claude", "Claude Desktop", "ClaudeDesktop", "AnthropicClaude"];
|
||||
const windowsClaudeExeNames = ["Claude.exe", "claude.exe", "Claude Desktop.exe"];
|
||||
|
||||
export function launchClaudeAppProfile(configDir: string, profile: ProfileConfig): ClaudeAppLaunchResult {
|
||||
const lookup = findInstalledClaudeAppExecutable();
|
||||
if (!lookup.executable) {
|
||||
throw new Error([
|
||||
"Claude App was not found. Install Claude App or set CLAUDE_APP_PATH to its executable, then try again.",
|
||||
lookup.checked.length ? `Checked: ${lookup.checked.join(", ")}` : ""
|
||||
].filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
const settingsFile = resolveClaudeCodeSettingsFile(configDir, profile);
|
||||
const settingsDir = path.dirname(settingsFile);
|
||||
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...profileEnv(profile),
|
||||
CLAUDE_CONFIG_DIR: settingsDir,
|
||||
CLAUDE_USER_DATA_DIR: userDataDir,
|
||||
CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir,
|
||||
CCR_PROFILE_SURFACE: "app",
|
||||
ELECTRON_ENABLE_LOGGING: "1"
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir), {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: "ignore"
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return {
|
||||
command: lookup.executable,
|
||||
pid: child.pid,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeAppProfileUserDataDir(configDir: string, profile: ProfileConfig): string {
|
||||
const settingsFile = resolveClaudeCodeSettingsFile(configDir, profile);
|
||||
return claudeElectronUserDataDir(path.dirname(settingsFile), profile);
|
||||
}
|
||||
|
||||
function claudeElectronArgs(userDataDir: string): string[] {
|
||||
return [
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows"
|
||||
];
|
||||
}
|
||||
|
||||
function claudeElectronUserDataDir(settingsDir: string, profile: ProfileConfig): string {
|
||||
return path.join(
|
||||
settingsDir,
|
||||
".claude-code-router",
|
||||
"claude-app-user-data",
|
||||
sanitizeProfilePathSegment(profile.id || profile.name || "default") || "default"
|
||||
);
|
||||
}
|
||||
|
||||
function findInstalledClaudeAppExecutable(): ClaudeAppLookupResult {
|
||||
const checked: string[] = [];
|
||||
const envCandidate = findFirstExecutable(envClaudeAppPathCandidates(), checked);
|
||||
if (envCandidate) {
|
||||
return { checked, executable: envCandidate };
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return { checked, executable: findFirstExecutable(macClaudeAppCandidates(), checked) };
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return { checked, executable: findFirstExecutable(windowsClaudeAppCandidates(), checked) };
|
||||
}
|
||||
return { checked, executable: findFirstExecutable(linuxClaudeAppCandidates(), checked) };
|
||||
}
|
||||
|
||||
function findFirstExecutable(candidates: string[], checked: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || checked.includes(candidate)) {
|
||||
continue;
|
||||
}
|
||||
checked.push(candidate);
|
||||
const executable = normalizeClaudeAppCandidate(candidate);
|
||||
if (executable) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envClaudeAppPathCandidates(): string[] {
|
||||
return ["CCR_CLAUDE_APP_PATH", "CLAUDE_APP_PATH"]
|
||||
.map((key) => process.env[key]?.trim() || "")
|
||||
.filter(Boolean)
|
||||
.map(resolveUserPath);
|
||||
}
|
||||
|
||||
function macClaudeAppCandidates(): string[] {
|
||||
const roots = [
|
||||
"/Applications",
|
||||
path.join(os.homedir(), "Applications")
|
||||
];
|
||||
return roots.flatMap((root) => macClaudeAppNames.map((name) => path.join(root, name)));
|
||||
}
|
||||
|
||||
function windowsClaudeAppCandidates(): string[] {
|
||||
const roots = [
|
||||
process.env.LOCALAPPDATA,
|
||||
process.env.APPDATA,
|
||||
process.env.ProgramFiles,
|
||||
process.env["ProgramFiles(x86)"],
|
||||
process.env.ProgramW6432,
|
||||
path.join(os.homedir(), "AppData", "Local"),
|
||||
path.join(os.homedir(), "AppData", "Roaming")
|
||||
].filter((value): value is string => Boolean(value?.trim()));
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
const installRoots = [
|
||||
root,
|
||||
path.join(root, "Programs"),
|
||||
path.join(root, "Programs", "Anthropic"),
|
||||
path.join(root, "Anthropic"),
|
||||
path.join(root, "Microsoft", "WindowsApps")
|
||||
];
|
||||
for (const installRoot of installRoots) {
|
||||
for (const exeName of windowsClaudeExeNames) {
|
||||
pushUnique(candidates, path.join(installRoot, exeName));
|
||||
}
|
||||
for (const dirName of windowsClaudeAppDirs) {
|
||||
const appDir = path.join(installRoot, dirName);
|
||||
pushUnique(candidates, appDir);
|
||||
for (const exeName of windowsClaudeExeNames) {
|
||||
pushUnique(candidates, path.join(appDir, exeName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const whereCandidate of windowsWhereClaudeCandidates()) {
|
||||
pushUnique(candidates, whereCandidate);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function linuxClaudeAppCandidates(): string[] {
|
||||
return [
|
||||
"/usr/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/Claude/claude",
|
||||
"/opt/Claude/Claude"
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeClaudeAppCandidate(candidate: string): string | undefined {
|
||||
if (process.platform === "darwin") {
|
||||
if (candidate.endsWith(".app")) {
|
||||
return executableFromMacAppBundle(candidate);
|
||||
}
|
||||
return isFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return normalizeWindowsClaudeAppCandidate(candidate);
|
||||
}
|
||||
return isFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
function executableFromMacAppBundle(appPath: string): string | undefined {
|
||||
if (!isDirectory(appPath)) {
|
||||
return undefined;
|
||||
}
|
||||
const infoPath = path.join(appPath, "Contents", "Info.plist");
|
||||
const macosDir = path.join(appPath, "Contents", "MacOS");
|
||||
const bundleExecutable = readBundleExecutable(infoPath);
|
||||
if (bundleExecutable) {
|
||||
const executable = path.join(macosDir, bundleExecutable);
|
||||
if (isFile(executable)) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
|
||||
const appName = path.basename(appPath, ".app");
|
||||
for (const name of [appName, "Claude", "claude"]) {
|
||||
const executable = path.join(macosDir, name);
|
||||
if (isFile(executable)) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return readdirSync(macosDir)
|
||||
.map((entry) => path.join(macosDir, entry))
|
||||
.find((entry) => isFile(entry));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readBundleExecutable(infoPath: string): string | undefined {
|
||||
if (!isFile(infoPath)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(infoPath, "utf8");
|
||||
return content.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/)?.[1]?.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsClaudeAppCandidate(candidate: string): string | undefined {
|
||||
if (isDirectory(candidate)) {
|
||||
return windowsClaudeExecutableInDir(candidate);
|
||||
}
|
||||
if (!isFile(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
const fileName = path.basename(candidate).toLowerCase();
|
||||
if (windowsClaudeExeNames.some((name) => name.toLowerCase() === fileName)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const parent = path.basename(path.dirname(candidate)).toLowerCase();
|
||||
if (parent === "resources") {
|
||||
const appDir = path.dirname(path.dirname(candidate));
|
||||
return windowsClaudeExecutableInDir(appDir);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function windowsClaudeExecutableInDir(dir: string): string | undefined {
|
||||
if (!isDirectory(dir)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const exeName of windowsClaudeExeNames) {
|
||||
const candidate = path.join(dir, exeName);
|
||||
if (isFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nested of ["app", "current", "Current"]) {
|
||||
const candidate = windowsClaudeExecutableInDir(path.join(dir, nested));
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const versionedDirs = readdirSync(dir)
|
||||
.filter((entry) => entry.toLowerCase().startsWith("app-"))
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.filter(isDirectory)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const versionedDir of versionedDirs) {
|
||||
const candidate = windowsClaudeExecutableInDir(versionedDir);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function windowsWhereClaudeCandidates(): string[] {
|
||||
if (process.platform !== "win32") {
|
||||
return [];
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
for (const name of ["Claude", "claude", "Claude Desktop"]) {
|
||||
const result = spawnSync("where.exe", [name], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
continue;
|
||||
}
|
||||
for (const line of result.stdout.split(/\r?\n/)) {
|
||||
if (line.trim()) {
|
||||
pushUnique(candidates, line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function profileEnv(profile: ProfileConfig): Record<string, string> {
|
||||
return Object.entries(profile.env ?? {}).reduce<Record<string, string>>((result, [key, value]) => {
|
||||
if (isEnvName(key) && typeof value === "string") {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function isEnvName(value: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
function sanitizeProfilePathSegment(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function resolveUserPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function isFile(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], value: string): void {
|
||||
if (!values.includes(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
138
src/main/cli.ts
Normal file
138
src/main/cli.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileOpenSurface } from "../shared/app";
|
||||
import { launchCodexAppProfile } from "./codex-app-launch";
|
||||
import { buildProfileLaunchPlan, findProfileForOpen, resolveProfileOpenSurface } from "./profile-launch-core";
|
||||
|
||||
type CliOptions = {
|
||||
agentArgs: string[];
|
||||
help: boolean;
|
||||
profileRef: string;
|
||||
surface?: ProfileOpenSurface;
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help || !options.profileRef) {
|
||||
printHelp(options.help ? 0 : 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const configFile = process.env.CCR_CONFIG_FILE?.trim() || path.join(os.homedir(), ".claude-code-router", "config.json");
|
||||
const configDir = path.dirname(configFile);
|
||||
const config = readConfig(configFile);
|
||||
const profile = findProfileForOpen(config, options.profileRef);
|
||||
const surface = options.surface ?? (profile.surface === "app" ? "app" : "cli");
|
||||
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
|
||||
if (profile.agent === "codex" && resolvedSurface === "app" && options.agentArgs.length === 0) {
|
||||
launchCodexAppProfile(configDir, profile, config);
|
||||
process.stdout.write(`Opened Codex App with ${profile.name || profile.id}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, options.agentArgs);
|
||||
|
||||
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
|
||||
throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`);
|
||||
}
|
||||
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
...plan.env
|
||||
};
|
||||
delete childEnv.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(plan.command, plan.args, {
|
||||
env: childEnv,
|
||||
stdio: "inherit"
|
||||
});
|
||||
const code = await waitForChild(child);
|
||||
process.exitCode = code;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
agentArgs: [],
|
||||
help: false,
|
||||
profileRef: ""
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--") {
|
||||
options.agentArgs.push(...args.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--app") {
|
||||
options.surface = "app";
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cli") {
|
||||
options.surface = "cli";
|
||||
continue;
|
||||
}
|
||||
if (!options.profileRef) {
|
||||
options.profileRef = arg;
|
||||
continue;
|
||||
}
|
||||
options.agentArgs.push(arg);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function readConfig(file: string): AppConfig {
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`CCR config was not found: ${file}`);
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<AppConfig>;
|
||||
if (!parsed.profile || !Array.isArray(parsed.profile.profiles)) {
|
||||
throw new Error(`CCR config has no profiles: ${file}`);
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
profile: {
|
||||
...parsed.profile,
|
||||
profiles: parsed.profile.profiles
|
||||
} as AppConfig["profile"]
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
function printHelp(exitCode: number): void {
|
||||
const output = [
|
||||
"Usage:",
|
||||
" ccr <profile-name-or-id> [--cli|--app] [-- <agent args>]",
|
||||
"",
|
||||
"Examples:",
|
||||
" ccr Codex",
|
||||
" ccr default-codex -- --model gpt-5-codex",
|
||||
" ccr default-codex --app"
|
||||
].join("\n");
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${output}\n`);
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
function waitForChild(child: ReturnType<typeof spawn>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1)));
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`${formatError(error)}\n`);
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${formatError(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
383
src/main/codex-app-launch.ts
Normal file
383
src/main/codex-app-launch.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "../shared/app";
|
||||
import { codexModelCatalogBase64 } from "./codex-model-catalog";
|
||||
import { buildProfileLaunchPlan, resolveCodexConfigFile } from "./profile-launch-core";
|
||||
|
||||
type CodexAppLookupResult = {
|
||||
checked: string[];
|
||||
executable?: string;
|
||||
};
|
||||
|
||||
export type CodexAppLaunchResult = {
|
||||
command: string;
|
||||
pid?: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
const macCodexAppNames = ["Codex.app", "OpenAI Codex.app"];
|
||||
const windowsCodexAppDirs = ["Codex", "OpenAI Codex", "OpenAICodex"];
|
||||
const windowsCodexExeNames = ["Codex.exe", "codex.exe", "OpenAI Codex.exe", "OpenAICodex.exe"];
|
||||
|
||||
export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult {
|
||||
const lookup = findInstalledCodexAppExecutable();
|
||||
if (!lookup.executable) {
|
||||
throw new Error([
|
||||
"Codex App was not found. Install Codex App or set CODEX_APP_PATH to its executable, then try again.",
|
||||
lookup.checked.length ? `Checked: ${lookup.checked.join(", ")}` : ""
|
||||
].filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
const plan = buildProfileLaunchPlan(configDir, profile, "app");
|
||||
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
|
||||
throw new Error(`Profile launcher was not found: ${plan.command}. Re-save the profile and try again.`);
|
||||
}
|
||||
|
||||
const configFile = resolveCodexConfigFile(configDir, profile);
|
||||
const codexHome = path.dirname(configFile);
|
||||
const userDataDir = codexElectronUserDataDir(codexHome, profile);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...plan.env,
|
||||
...codexProfileEnv(profile),
|
||||
CODEX_CLI_PATH: plan.command,
|
||||
CODEX_ELECTRON_USER_DATA_PATH: userDataDir,
|
||||
CODEX_HOME: codexHome,
|
||||
CODEXL_PROFILE_SURFACE: "app",
|
||||
CCR_PROFILE_SURFACE: "app",
|
||||
CCR_CODEX_MODEL_CATALOG_B64: codexModelCatalogBase64(config, profile.model),
|
||||
ELECTRON_ENABLE_LOGGING: "1"
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
|
||||
const child = spawn(lookup.executable, codexElectronArgs(userDataDir), {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: "ignore"
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return {
|
||||
command: lookup.executable,
|
||||
pid: child.pid,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
function codexProfileEnv(profile: ProfileConfig): Record<string, string> {
|
||||
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const realCliPath = profile.codexCliPath?.trim() || "codex";
|
||||
const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode);
|
||||
return {
|
||||
...(profile.model.trim() ? { CCR_CODEX_MODEL: profile.model.trim() } : {}),
|
||||
CCR_CODEX_MODEL_PROVIDER: providerId,
|
||||
CCR_CODEX_PROFILE: providerId,
|
||||
CCR_CODEX_REMOTE_FRONTEND_MODE: remoteFrontendMode,
|
||||
CCR_REAL_CODEX_CLI_PATH: realCliPath,
|
||||
CODEXL_CODEX_CORE_MODE: remoteFrontendMode,
|
||||
CODEXL_CODEX_MODEL_PROVIDER: providerId,
|
||||
CODEXL_CODEX_PROFILE: providerId,
|
||||
CODEXL_CODEX_WORKSPACE_NAME: profile.name || providerId,
|
||||
CODEXL_REAL_CODEX_CLI_PATH: realCliPath
|
||||
};
|
||||
}
|
||||
|
||||
function codexElectronArgs(userDataDir: string): string[] {
|
||||
return [
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows"
|
||||
];
|
||||
}
|
||||
|
||||
function codexElectronUserDataDir(codexHome: string, profile: ProfileConfig): string {
|
||||
return path.join(
|
||||
codexHome,
|
||||
".claude-code-router",
|
||||
"codex-app-user-data",
|
||||
sanitizeProfilePathSegment(profile.id || profile.name || "default") || "default"
|
||||
);
|
||||
}
|
||||
|
||||
function findInstalledCodexAppExecutable(): CodexAppLookupResult {
|
||||
const checked: string[] = [];
|
||||
const envCandidate = findFirstExecutable(envCodexAppPathCandidates(), checked);
|
||||
if (envCandidate) {
|
||||
return { checked, executable: envCandidate };
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return { checked, executable: findFirstExecutable(macCodexAppCandidates(), checked) };
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return { checked, executable: findFirstExecutable(windowsCodexAppCandidates(), checked) };
|
||||
}
|
||||
return { checked, executable: findFirstExecutable(linuxCodexAppCandidates(), checked) };
|
||||
}
|
||||
|
||||
function findFirstExecutable(candidates: string[], checked: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || checked.includes(candidate)) {
|
||||
continue;
|
||||
}
|
||||
checked.push(candidate);
|
||||
const executable = normalizeCodexAppCandidate(candidate);
|
||||
if (executable) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function envCodexAppPathCandidates(): string[] {
|
||||
return ["CCR_CODEX_APP_PATH", "CODEX_APP_PATH", "CODEXL_CODEX_PATH"]
|
||||
.map((key) => process.env[key]?.trim() || "")
|
||||
.filter(Boolean)
|
||||
.map(resolveUserPath);
|
||||
}
|
||||
|
||||
function macCodexAppCandidates(): string[] {
|
||||
const roots = [
|
||||
"/Applications",
|
||||
path.join(os.homedir(), "Applications")
|
||||
];
|
||||
return roots.flatMap((root) => macCodexAppNames.map((name) => path.join(root, name)));
|
||||
}
|
||||
|
||||
function windowsCodexAppCandidates(): string[] {
|
||||
const roots = [
|
||||
process.env.LOCALAPPDATA,
|
||||
process.env.APPDATA,
|
||||
process.env.ProgramFiles,
|
||||
process.env["ProgramFiles(x86)"],
|
||||
process.env.ProgramW6432,
|
||||
path.join(os.homedir(), "AppData", "Local"),
|
||||
path.join(os.homedir(), "AppData", "Roaming")
|
||||
].filter((value): value is string => Boolean(value?.trim()));
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
const installRoots = [
|
||||
root,
|
||||
path.join(root, "Programs"),
|
||||
path.join(root, "Programs", "OpenAI"),
|
||||
path.join(root, "OpenAI"),
|
||||
path.join(root, "Microsoft", "WindowsApps")
|
||||
];
|
||||
for (const installRoot of installRoots) {
|
||||
for (const exeName of windowsCodexExeNames) {
|
||||
pushUnique(candidates, path.join(installRoot, exeName));
|
||||
}
|
||||
for (const dirName of windowsCodexAppDirs) {
|
||||
const appDir = path.join(installRoot, dirName);
|
||||
pushUnique(candidates, appDir);
|
||||
for (const exeName of windowsCodexExeNames) {
|
||||
pushUnique(candidates, path.join(appDir, exeName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const whereCandidate of windowsWhereCodexCandidates()) {
|
||||
pushUnique(candidates, whereCandidate);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function linuxCodexAppCandidates(): string[] {
|
||||
return [
|
||||
"/opt/Codex/codex",
|
||||
"/opt/Codex/Codex",
|
||||
"/opt/OpenAI Codex/codex",
|
||||
"/opt/OpenAI Codex/Codex",
|
||||
"/usr/local/bin/codex-app",
|
||||
"/usr/bin/codex-app"
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeCodexAppCandidate(candidate: string): string | undefined {
|
||||
if (process.platform === "darwin") {
|
||||
if (candidate.endsWith(".app")) {
|
||||
return executableFromMacAppBundle(candidate);
|
||||
}
|
||||
return isFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return normalizeWindowsCodexAppCandidate(candidate);
|
||||
}
|
||||
return isFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
|
||||
function executableFromMacAppBundle(appPath: string): string | undefined {
|
||||
if (!isDirectory(appPath)) {
|
||||
return undefined;
|
||||
}
|
||||
const infoPath = path.join(appPath, "Contents", "Info.plist");
|
||||
const macosDir = path.join(appPath, "Contents", "MacOS");
|
||||
const bundleExecutable = readBundleExecutable(infoPath);
|
||||
if (bundleExecutable) {
|
||||
const executable = path.join(macosDir, bundleExecutable);
|
||||
if (isFile(executable)) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
|
||||
const appName = path.basename(appPath, ".app");
|
||||
for (const name of [appName, "Codex", "OpenAI Codex", "codex"]) {
|
||||
const executable = path.join(macosDir, name);
|
||||
if (isFile(executable)) {
|
||||
return executable;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return readdirSync(macosDir)
|
||||
.map((entry) => path.join(macosDir, entry))
|
||||
.find((entry) => isFile(entry));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readBundleExecutable(infoPath: string): string | undefined {
|
||||
if (!isFile(infoPath)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(infoPath, "utf8");
|
||||
return content.match(/<key>CFBundleExecutable<\/key>\s*<string>([^<]+)<\/string>/)?.[1]?.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsCodexAppCandidate(candidate: string): string | undefined {
|
||||
if (isDirectory(candidate)) {
|
||||
return windowsCodexExecutableInDir(candidate);
|
||||
}
|
||||
if (!isFile(candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
const fileName = path.basename(candidate).toLowerCase();
|
||||
if (windowsCodexExeNames.some((name) => name.toLowerCase() === fileName)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const parent = path.basename(path.dirname(candidate)).toLowerCase();
|
||||
if (parent === "resources") {
|
||||
const appDir = path.dirname(path.dirname(candidate));
|
||||
return windowsCodexExecutableInDir(appDir);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function windowsCodexExecutableInDir(dir: string): string | undefined {
|
||||
if (!isDirectory(dir)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const exeName of windowsCodexExeNames) {
|
||||
const candidate = path.join(dir, exeName);
|
||||
if (isFile(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nested of ["app", "current", "Current"]) {
|
||||
const candidate = windowsCodexExecutableInDir(path.join(dir, nested));
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const versionedDirs = readdirSync(dir)
|
||||
.filter((entry) => entry.toLowerCase().startsWith("app-"))
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.filter(isDirectory)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const versionedDir of versionedDirs) {
|
||||
const candidate = windowsCodexExecutableInDir(versionedDir);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function windowsWhereCodexCandidates(): string[] {
|
||||
if (process.platform !== "win32") {
|
||||
return [];
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
for (const name of ["Codex", "codex", "OpenAI Codex"]) {
|
||||
const result = spawnSync("where.exe", [name], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
continue;
|
||||
}
|
||||
for (const line of result.stdout.split(/\r?\n/)) {
|
||||
if (line.trim()) {
|
||||
pushUnique(candidates, line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function normalizeCodexRemoteFrontendMode(value: ProfileConfig["remoteFrontendMode"]): "app" | "cli" | "claude-code" {
|
||||
return value === "cli" || value === "claude-code" ? value : "app";
|
||||
}
|
||||
|
||||
function sanitizeCodexProviderId(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function sanitizeProfilePathSegment(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function resolveUserPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function isFile(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], value: string): void {
|
||||
if (!values.includes(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,13 @@ async function main() {
|
|||
await runClaudeCodeAppServer(args);
|
||||
return;
|
||||
}
|
||||
await runCodexCliMiddleware(args.length === 0 ? ["app-server", "--analytics-default-enabled"] : args);
|
||||
await runCodexCliMiddleware(args.length === 0 ? defaultCodexArgs() : args);
|
||||
}
|
||||
|
||||
function defaultCodexArgs() {
|
||||
return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli"
|
||||
? []
|
||||
: ["app-server", "--analytics-default-enabled"];
|
||||
}
|
||||
|
||||
async function runCodexCliMiddleware(args) {
|
||||
|
|
@ -33,6 +39,12 @@ async function runCodexCliMiddleware(args) {
|
|||
const configFormat = normalizeConfigFormat(nonEmptyEnv("CCR_CODEX_PROFILE_CONFIG_FORMAT") || nonEmptyEnv("CODEXL_CODEX_PROFILE_CONFIG_FORMAT"));
|
||||
const realArgs = realCliArgs(profile, modelProvider, configFormat, args);
|
||||
log("codex_cli_start", { realCli, realArgs });
|
||||
|
||||
if (shouldRunDirectCodexCli(args)) {
|
||||
await runDirectCodexCli(realCli, realArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = childProcess.spawn(realCli, realArgs, {
|
||||
env: withoutKeys(process.env, ["CODEX_CLI_PATH", "CCR_REAL_CODEX_CLI_PATH", "CODEXL_REAL_CODEX_CLI_PATH"]),
|
||||
stdio: ["pipe", "pipe", "inherit"]
|
||||
|
|
@ -68,6 +80,23 @@ async function runCodexCliMiddleware(args) {
|
|||
process.exitCode = code;
|
||||
}
|
||||
|
||||
async function runDirectCodexCli(realCli, realArgs) {
|
||||
const child = childProcess.spawn(realCli, realArgs, {
|
||||
env: withoutKeys(process.env, ["CODEX_CLI_PATH", "CCR_REAL_CODEX_CLI_PATH", "CODEXL_REAL_CODEX_CLI_PATH"]),
|
||||
stdio: "inherit"
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
log("codex_cli_spawn_error", { error: formatError(error) });
|
||||
});
|
||||
const code = await waitForChild(child);
|
||||
log("codex_cli_exit", { code });
|
||||
process.exitCode = code;
|
||||
}
|
||||
|
||||
function shouldRunDirectCodexCli(args) {
|
||||
return codexPositionalArgs(args)[0] !== "app-server";
|
||||
}
|
||||
|
||||
function realCliArgs(profile, modelProvider, configFormat, args) {
|
||||
const realArgs = [];
|
||||
if (profile) {
|
||||
|
|
@ -143,6 +172,8 @@ function rewriteCodexStdoutLine(line, requestMap) {
|
|||
value.result = mockAuthStatus(request.includeToken);
|
||||
} else if (request.method === "thread/list") {
|
||||
value = mergeForeignThreadList(value, request.params);
|
||||
} else if (request.method === "model/list") {
|
||||
value.result = modelList(request.params, value.result);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
|
@ -1075,19 +1106,152 @@ function collaborationModes() {
|
|||
] };
|
||||
}
|
||||
|
||||
function modelList(params) {
|
||||
const configured = nonEmptyEnv("CCR_CODEX_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || DEFAULT_MODEL;
|
||||
const models = Array.from(new Set([configured, DEFAULT_MODEL, "claude-opus-4-5", "claude-haiku-4-5"].filter(Boolean))).map((model) => ({
|
||||
function modelList(params, existingResult) {
|
||||
const isClaudeCodeRuntime = normalizeRemoteFrontendMode(nonEmptyEnv("CCR_CODEX_REMOTE_FRONTEND_MODE") || nonEmptyEnv("CODEXL_CODEX_CORE_MODE")) === "claude-code";
|
||||
const configured = normalizeModelSelector(nonEmptyEnv("CCR_CODEX_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || (isClaudeCodeRuntime ? DEFAULT_MODEL : ""));
|
||||
const fallbackIds = isClaudeCodeRuntime
|
||||
? [configured, DEFAULT_MODEL, "claude-opus-4-5", "claude-haiku-4-5"].filter(Boolean)
|
||||
: [configured].filter((model) => model && !isClaudeCodeOnlyModel(model));
|
||||
const models = mergeModelListItems(extractModelListItems(existingResult), [...catalogModelIds(), ...fallbackIds], configured);
|
||||
const offset = Number(params.cursor || 0) || 0;
|
||||
const limit = Number(params.limit || models.length) || models.length;
|
||||
const data = models.slice(offset, offset + limit);
|
||||
return {
|
||||
...(existingResult && typeof existingResult === "object" && !Array.isArray(existingResult) ? existingResult : {}),
|
||||
data,
|
||||
models: data,
|
||||
nextCursor: offset + limit < models.length ? String(offset + limit) : null
|
||||
};
|
||||
}
|
||||
|
||||
function catalogModelIds() {
|
||||
const values = parseModelCatalogEnv();
|
||||
return values.map(normalizeModelSelector).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseModelCatalogEnv() {
|
||||
const encoded = nonEmptyEnv("CCR_CODEX_MODEL_CATALOG_B64") || nonEmptyEnv("CODEXL_CODEX_MODEL_CATALOG_B64");
|
||||
if (encoded) {
|
||||
try {
|
||||
return modelIdsFromJson(JSON.parse(Buffer.from(encoded, "base64").toString("utf8")));
|
||||
} catch (error) {
|
||||
log("model_catalog_parse_error", { source: "base64", error: formatError(error) });
|
||||
}
|
||||
}
|
||||
const raw = nonEmptyEnv("CCR_CODEX_MODEL_CATALOG") || nonEmptyEnv("CODEXL_CODEX_MODEL_CATALOG");
|
||||
if (raw) {
|
||||
try {
|
||||
return modelIdsFromJson(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
log("model_catalog_parse_error", { source: "json", error: formatError(error) });
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function modelIdsFromJson(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const output = [];
|
||||
for (const item of value) {
|
||||
const id = typeof item === "string"
|
||||
? item
|
||||
: item && typeof item === "object"
|
||||
? firstString(item, ["/model", "/id", "/slug", "/name", "/label"])
|
||||
: "";
|
||||
if (id) output.push(id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function mergeModelListItems(existingItems, catalogIds, selectedModel) {
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const item of existingItems) {
|
||||
const id = normalizeModelSelector(modelItemId(item));
|
||||
if (!id || seen.has(id.toLowerCase())) continue;
|
||||
seen.add(id.toLowerCase());
|
||||
output.push(typeof item === "object" && item !== null ? { ...item, id: item.id || id, model: item.model || id } : codexModelItem(id, selectedModel));
|
||||
}
|
||||
for (const rawId of catalogIds) {
|
||||
const id = normalizeModelSelector(rawId);
|
||||
if (!id || seen.has(id.toLowerCase())) continue;
|
||||
seen.add(id.toLowerCase());
|
||||
output.push(codexModelItem(id, selectedModel));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractModelListItems(result) {
|
||||
if (Array.isArray(result)) return result;
|
||||
if (!result || typeof result !== "object") return [];
|
||||
for (const key of ["models", "data", "items"]) {
|
||||
if (Array.isArray(result[key])) return result[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function modelItemId(item) {
|
||||
if (typeof item === "string") return item;
|
||||
if (!item || typeof item !== "object") return "";
|
||||
return firstString(item, ["/model", "/id", "/slug", "/name", "/label"]) || "";
|
||||
}
|
||||
|
||||
function codexModelItem(model, selectedModel) {
|
||||
const provider = modelProviderFromSelector(model) || nonEmptyEnv("CCR_CODEX_MODEL_PROVIDER") || "claude-code-router";
|
||||
const displayName = modelDisplayName(model);
|
||||
return {
|
||||
id: model,
|
||||
model,
|
||||
name: model,
|
||||
label: model,
|
||||
provider: "claude-code"
|
||||
}));
|
||||
const offset = Number(params.cursor || 0) || 0;
|
||||
const limit = Number(params.limit || models.length) || models.length;
|
||||
const data = models.slice(offset, offset + limit);
|
||||
return { data, models: data, nextCursor: offset + limit < models.length ? String(offset + limit) : null };
|
||||
provider,
|
||||
providerName: provider,
|
||||
modelProvider: provider,
|
||||
displayName,
|
||||
description: "CCR model",
|
||||
hidden: false,
|
||||
isDefault: model === selectedModel,
|
||||
contextWindow: 0,
|
||||
inputModalities: ["text", "image"],
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
supportsPersonality: false,
|
||||
additionalSpeedTiers: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
upgrade: null,
|
||||
upgradeInfo: null,
|
||||
availabilityNux: null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelSelector(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) return "";
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const provider = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return provider && model ? provider + "/" + model : "";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function modelProviderFromSelector(model) {
|
||||
const slashIndex = model.indexOf("/");
|
||||
return slashIndex > 0 && slashIndex < model.length - 1 ? model.slice(0, slashIndex) : "";
|
||||
}
|
||||
|
||||
function modelDisplayName(model) {
|
||||
const slashIndex = model.indexOf("/");
|
||||
return slashIndex > 0 && slashIndex < model.length - 1 ? model.slice(slashIndex + 1) : model;
|
||||
}
|
||||
|
||||
function isClaudeCodeOnlyModel(model) {
|
||||
const normalized = String(model || "").trim().toLowerCase();
|
||||
return normalized === DEFAULT_MODEL ||
|
||||
normalized === "claude-opus-4-5" ||
|
||||
normalized === "claude-haiku-4-5";
|
||||
}
|
||||
|
||||
function configRead(params, values) {
|
||||
|
|
@ -1097,6 +1261,7 @@ function configRead(params, values) {
|
|||
...values,
|
||||
cwd,
|
||||
model: nonEmptyEnv("CCR_CODEX_MODEL") || DEFAULT_MODEL,
|
||||
model_catalog_json: JSON.stringify(catalogModelIds()),
|
||||
model_provider: nonEmptyEnv("CCR_CODEX_MODEL_PROVIDER") || "claude-code",
|
||||
approval_policy: "default",
|
||||
sandbox_mode: "workspace-write"
|
||||
|
|
@ -1397,7 +1562,7 @@ function numberEnv(name, fallback) {
|
|||
}
|
||||
|
||||
function normalizeConfigFormat(value) {
|
||||
return String(value || "").replace(/-/g, "_").toLowerCase() === "separate_profile_files" ? "separate_profile_files" : "legacy";
|
||||
return "separate_profile_files";
|
||||
}
|
||||
|
||||
function normalizeRemoteFrontendMode(value) {
|
||||
|
|
@ -1405,6 +1570,11 @@ function normalizeRemoteFrontendMode(value) {
|
|||
return normalized === "cli" || normalized === "claude-code" ? normalized : "app";
|
||||
}
|
||||
|
||||
function normalizeProfileSurface(value) {
|
||||
const normalized = String(value || "").replace(/_/g, "-").toLowerCase();
|
||||
return normalized === "cli" || normalized === "app" ? normalized : "auto";
|
||||
}
|
||||
|
||||
function expandHome(value) {
|
||||
const text = String(value || "");
|
||||
if (text === "~") return os.homedir();
|
||||
|
|
|
|||
116
src/main/codex-model-catalog.ts
Normal file
116
src/main/codex-model-catalog.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { AppConfig, VirtualModelProfileConfig } from "../shared/app";
|
||||
|
||||
const fusionModelProviderName = "Fusion";
|
||||
|
||||
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string[] {
|
||||
const ids: string[] = [];
|
||||
pushUniqueModel(ids, normalizeModelSelector(selectedModel));
|
||||
|
||||
const baseEntries: Array<{ modelName: string; providerName: string }> = [];
|
||||
for (const provider of config?.Providers ?? []) {
|
||||
const providerName = provider.name?.trim();
|
||||
if (!providerName || !Array.isArray(provider.models)) {
|
||||
continue;
|
||||
}
|
||||
for (const rawModel of provider.models) {
|
||||
const modelName = rawModel.trim();
|
||||
if (!modelName) {
|
||||
continue;
|
||||
}
|
||||
baseEntries.push({ modelName, providerName });
|
||||
pushUniqueModel(ids, `${providerName}/${modelName}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of config?.virtualModelProfiles ?? []) {
|
||||
if (!virtualModelIsCatalogVisible(profile)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of baseEntries) {
|
||||
for (const prefix of profile.match?.prefixes ?? []) {
|
||||
const normalizedPrefix = prefix.trim();
|
||||
if (normalizedPrefix) {
|
||||
pushUniqueModel(ids, `${entry.providerName}/${normalizedPrefix}${entry.modelName}`);
|
||||
}
|
||||
}
|
||||
for (const suffix of profile.match?.suffixes ?? []) {
|
||||
const normalizedSuffix = suffix.trim();
|
||||
if (normalizedSuffix) {
|
||||
pushUniqueModel(ids, `${entry.providerName}/${entry.modelName}${normalizedSuffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const alias of virtualModelRawCatalogNames(profile)) {
|
||||
pushUniqueModel(ids, fusionModelSelector(alias));
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): string {
|
||||
const catalog = buildCodexModelCatalog(config, selectedModel);
|
||||
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean {
|
||||
return profile.enabled !== false &&
|
||||
profile.materialization?.enabled !== false &&
|
||||
profile.materialization?.includeInGatewayModels !== false;
|
||||
}
|
||||
|
||||
function virtualModelRawCatalogNames(profile: VirtualModelProfileConfig): string[] {
|
||||
const exactAliases = uniqueStrings(profile.match?.exactAliases ?? []);
|
||||
if (exactAliases.length > 0) {
|
||||
return exactAliases;
|
||||
}
|
||||
return [profile.key || profile.displayName].filter(Boolean);
|
||||
}
|
||||
|
||||
function fusionModelSelector(model: string): string {
|
||||
const normalized = fusionModelNameFromSelector(model);
|
||||
return normalized ? `${fusionModelProviderName}/${normalized}` : "";
|
||||
}
|
||||
|
||||
function fusionModelNameFromSelector(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
const prefix = `${fusionModelProviderName}/`;
|
||||
return trimmed.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? trimmed.slice(prefix.length).trim()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function normalizeModelSelector(value: string | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const provider = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return provider && model ? `${provider}/${model}` : "";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function pushUniqueModel(models: string[], model: string | undefined): void {
|
||||
const normalized = model?.trim();
|
||||
if (normalized && !models.includes(normalized)) {
|
||||
models.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const output: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
|
||||
import { CONFIGDIR, CONFIG_FILE, GATEWAY_CONFIG_FILE } from "./constants";
|
||||
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../shared/app";
|
||||
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../shared/app";
|
||||
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "../shared/provider-presets";
|
||||
import type {
|
||||
AppConfig,
|
||||
|
|
@ -116,7 +116,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
cliMiddleware: true,
|
||||
codexCliPath: "",
|
||||
codexHome: "",
|
||||
configFormat: "legacy",
|
||||
configFormat: "separate_profile_files",
|
||||
configFile: "~/.codex/config.toml",
|
||||
enabled: true,
|
||||
model: "",
|
||||
|
|
@ -143,7 +143,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
cliMiddleware: true,
|
||||
codexCliPath: "",
|
||||
codexHome: "",
|
||||
configFormat: "legacy",
|
||||
configFormat: "separate_profile_files",
|
||||
configFile: "~/.codex/config.toml",
|
||||
enabled: true,
|
||||
env: {},
|
||||
|
|
@ -524,7 +524,9 @@ function parseOverviewWidget(value: unknown): OverviewWidgetConfig | undefined {
|
|||
return undefined;
|
||||
}
|
||||
const metric = type === "metric" ? parseOverviewMetricKind(value.metric) ?? "requests" : undefined;
|
||||
const accountProvider = type === "account-balance" ? readString(value.accountProvider) : undefined;
|
||||
return {
|
||||
...(accountProvider ? { accountProvider } : {}),
|
||||
enabled: typeof value.enabled === "boolean" ? value.enabled : true,
|
||||
id: readString(value.id) || overviewWidgetId(type, metric),
|
||||
...(metric ? { metric } : {}),
|
||||
|
|
@ -559,7 +561,7 @@ function parseOverviewWidgetSize(value: unknown, type: OverviewWidgetType): Over
|
|||
}
|
||||
|
||||
function parseOverviewWidgetVariant(value: unknown): OverviewWidgetVariant | undefined {
|
||||
return parseEnumValue(value, ["area", "bar", "bars", "card", "cards", "compact", "composed", "donut", "line", "pie", "ring", "stacked", "table", "timeline"], undefined);
|
||||
return parseEnumValue(value, ["arc", "area", "bar", "bars", "card", "cards", "compact", "composed", "donut", "line", "nested-rings", "pie", "ring", "semicircle", "stacked", "table", "timeline"], undefined);
|
||||
}
|
||||
|
||||
function parseOverviewMetricKind(value: unknown): OverviewMetricKind | undefined {
|
||||
|
|
@ -628,9 +630,9 @@ function parseTrayWidgets(value: unknown): TrayWidgetConfig[] | undefined {
|
|||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return dedupeTraySingletonWidgets(value
|
||||
return orderTrayWidgetsForLayout(dedupeTraySingletonWidgets(value
|
||||
.map(parseTrayWidget)
|
||||
.filter((widget): widget is TrayWidgetConfig => Boolean(widget)));
|
||||
.filter((widget): widget is TrayWidgetConfig => Boolean(widget))));
|
||||
}
|
||||
|
||||
function parseTrayWidget(value: unknown): TrayWidgetConfig | undefined {
|
||||
|
|
@ -688,6 +690,17 @@ function isTraySingletonWidgetType(type: TrayWidgetType): boolean {
|
|||
return (TRAY_SINGLETON_WIDGET_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
function isTrayPinnedTopWidgetType(type: TrayWidgetType): boolean {
|
||||
return (TRAY_TOP_WIDGET_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
function orderTrayWidgetsForLayout(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
|
||||
return [
|
||||
...widgets.filter((widget) => isTrayPinnedTopWidgetType(widget.type)),
|
||||
...widgets.filter((widget) => !isTrayPinnedTopWidgetType(widget.type))
|
||||
];
|
||||
}
|
||||
|
||||
function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
|
||||
const seenSingletons = new Set<TrayWidgetType>();
|
||||
return widgets.filter((widget) => {
|
||||
|
|
@ -703,7 +716,7 @@ function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConf
|
|||
}
|
||||
|
||||
function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayComponentVariants): TrayWidgetConfig[] {
|
||||
return modules
|
||||
return orderTrayWidgetsForLayout(modules
|
||||
.filter((moduleId): moduleId is TrayWidgetType => moduleId !== "footer")
|
||||
.map((type) => ({
|
||||
id: trayWidgetId(type),
|
||||
|
|
@ -714,7 +727,7 @@ function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayCom
|
|||
...((type === "stats") ? { variant: variants.stats } : {}),
|
||||
...((type === "token-flow") ? { variant: variants.tokenFlow } : {}),
|
||||
...((type === "token-mix") ? { variant: variants.tokenMix } : {})
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
function parseTrayComponentVariants(value: unknown): TrayComponentVariants | undefined {
|
||||
|
|
@ -1451,7 +1464,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined {
|
|||
cliMiddleware: true,
|
||||
codexCliPath: readString(item.codexCliPath) || readString(item.cliPath) || readString(item.codexPath) || "",
|
||||
codexHome: readString(item.codexHome) || readString(item.home) || "",
|
||||
configFormat: parseCodexProfileConfigFormat(readString(item.configFormat) || readString(item.profileConfigFormat)) || "legacy",
|
||||
configFormat: parseCodexProfileConfigFormat(readString(item.configFormat) || readString(item.profileConfigFormat)) || "separate_profile_files",
|
||||
configFile: readString(item.configFile) || readString(item.settingsFile) || "~/.codex/config.toml",
|
||||
enabled,
|
||||
env,
|
||||
|
|
@ -1564,7 +1577,7 @@ function parseCodexProfileConfigFormat(value: string | undefined): "legacy" | "s
|
|||
}
|
||||
const normalized = value.trim().toLowerCase().replace(/-/g, "_").replace(/\s+/g, "_");
|
||||
if (normalized === "legacy" || normalized === "profiles" || normalized === "profile_table" || normalized === "profiles_table") {
|
||||
return "legacy";
|
||||
return "separate_profile_files";
|
||||
}
|
||||
if (normalized === "separate" || normalized === "separate_profile_files" || normalized === "profile_files" || normalized === "profile_file" || normalized === "new") {
|
||||
return "separate_profile_files";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AppConfig, GatewayProviderConfig } from "../../shared/app";
|
||||
import type { AppConfig, VirtualModelProfileConfig } from "../../shared/app";
|
||||
import { normalizeRouteSelector } from "./claude-code-router-plugin";
|
||||
|
||||
type CodexAppRequestPreparationInput = {
|
||||
|
|
@ -11,21 +11,12 @@ type CodexAppRequestPreparationInput = {
|
|||
|
||||
type CodexAppRequestPreparation = {
|
||||
body?: Buffer;
|
||||
diagnostic: "model-remembered" | "model-rewritten";
|
||||
diagnostic: "model-rewritten";
|
||||
routedModel?: string;
|
||||
};
|
||||
|
||||
type RememberedCodexModel = {
|
||||
expiresAt: number;
|
||||
model: string;
|
||||
providerName?: string;
|
||||
};
|
||||
|
||||
const codexModelRewriteSessionTtlMs = 6 * 60 * 60 * 1000;
|
||||
const rememberedCodexModels = new Map<string, RememberedCodexModel>();
|
||||
|
||||
export function prepareCodexAppRequest(input: CodexAppRequestPreparationInput): CodexAppRequestPreparation | undefined {
|
||||
if (!isOpenAIResponsesPath(input.path) || !isCodexClient(input.client, input.headers)) {
|
||||
if (!isOpenAIModelRequestPath(input.path) || !isCodexClient(input.client, input.headers)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -41,125 +32,72 @@ export function prepareCodexAppRequest(input: CodexAppRequestPreparationInput):
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const sessionKeys = codexSessionKeys(input.headers, body);
|
||||
const provider = providerForModelSelector(input.config, requestedModel);
|
||||
if (provider && isRememberableGatewayModel(requestedModel)) {
|
||||
rememberCodexModel(sessionKeys, {
|
||||
expiresAt: Date.now() + codexModelRewriteSessionTtlMs,
|
||||
model: requestedModel,
|
||||
providerName: provider.name
|
||||
});
|
||||
return {
|
||||
diagnostic: "model-remembered"
|
||||
};
|
||||
}
|
||||
|
||||
if (!isCodexInternalModel(requestedModel)) {
|
||||
const canonicalFusionModel = visibleFusionModelSelector(input.config, requestedModel);
|
||||
if (!canonicalFusionModel || canonicalFusionModel === requestedModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const remembered = findRememberedCodexModel(sessionKeys);
|
||||
if (!remembered || remembered.model === requestedModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextBody = {
|
||||
...body,
|
||||
model: remembered.model
|
||||
};
|
||||
if (headerModel) {
|
||||
input.headers["x-target-model"] = remembered.model;
|
||||
input.headers["x-target-model"] = canonicalFusionModel;
|
||||
}
|
||||
|
||||
return {
|
||||
body: Buffer.from(`${JSON.stringify(nextBody)}\n`, "utf8"),
|
||||
body: encodeJsonBody({
|
||||
...body,
|
||||
model: canonicalFusionModel
|
||||
}),
|
||||
diagnostic: "model-rewritten",
|
||||
routedModel: remembered.model
|
||||
routedModel: canonicalFusionModel
|
||||
};
|
||||
}
|
||||
|
||||
function rememberCodexModel(keys: string[], value: RememberedCodexModel): void {
|
||||
pruneRememberedCodexModels();
|
||||
for (const key of keys) {
|
||||
rememberedCodexModels.set(key, value);
|
||||
function visibleFusionModelSelector(config: AppConfig, selector: string): string | undefined {
|
||||
const normalized = fusionModelNameFromSelector(selector).toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function findRememberedCodexModel(keys: string[]): RememberedCodexModel | undefined {
|
||||
pruneRememberedCodexModels();
|
||||
for (const key of keys) {
|
||||
const value = rememberedCodexModels.get(key);
|
||||
if (value) {
|
||||
return value;
|
||||
for (const profile of config.virtualModelProfiles ?? []) {
|
||||
if (!isVisibleVirtualModelProfile(profile)) {
|
||||
continue;
|
||||
}
|
||||
const match = virtualModelRawCatalogNames(profile).find((name) =>
|
||||
fusionModelNameFromSelector(name).toLowerCase() === normalized
|
||||
);
|
||||
const canonical = match ? fusionModelNameFromSelector(match) : "";
|
||||
if (canonical) {
|
||||
return `Fusion/${canonical}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pruneRememberedCodexModels(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rememberedCodexModels) {
|
||||
if (value.expiresAt <= now) {
|
||||
rememberedCodexModels.delete(key);
|
||||
}
|
||||
}
|
||||
function isVisibleVirtualModelProfile(profile: VirtualModelProfileConfig): boolean {
|
||||
return profile.enabled !== false &&
|
||||
profile.materialization?.enabled !== false &&
|
||||
profile.materialization?.includeInGatewayModels !== false;
|
||||
}
|
||||
|
||||
function codexSessionKeys(headers: Record<string, string>, body: Record<string, unknown>): string[] {
|
||||
const accountId =
|
||||
readHeader(headers, "x-codex-account-id") ||
|
||||
stringValue(body.account_id) ||
|
||||
stringValue(body.accountId);
|
||||
const sessionId =
|
||||
readHeader(headers, "x-codex-session-id") ||
|
||||
readHeader(headers, "x-codex-conversation-id") ||
|
||||
readHeader(headers, "x-codex-thread-id") ||
|
||||
readHeader(headers, "x-agent-session-id") ||
|
||||
stringValue(body.session_id) ||
|
||||
stringValue(body.sessionId) ||
|
||||
stringValue(body.conversation_id) ||
|
||||
stringValue(body.conversationId);
|
||||
const keys: string[] = [];
|
||||
if (accountId && sessionId) {
|
||||
keys.push(`codex:${accountId}:${sessionId}`);
|
||||
}
|
||||
if (sessionId) {
|
||||
keys.push(`codex:session:${sessionId}`);
|
||||
}
|
||||
if (accountId) {
|
||||
keys.push(`codex:account:${accountId}`);
|
||||
}
|
||||
keys.push("codex");
|
||||
return [...new Set(keys)];
|
||||
function virtualModelRawCatalogNames(profile: VirtualModelProfileConfig): string[] {
|
||||
const exactAliases = uniqueStrings(profile.match?.exactAliases ?? []);
|
||||
return exactAliases.length > 0 ? exactAliases : [profile.key || profile.displayName].filter(Boolean);
|
||||
}
|
||||
|
||||
function providerForModelSelector(config: AppConfig, selector: string): GatewayProviderConfig | undefined {
|
||||
const providerName = selector.split("/", 1)[0]?.trim().toLowerCase();
|
||||
if (!providerName || providerName === selector.toLowerCase()) {
|
||||
return undefined;
|
||||
}
|
||||
return config.Providers.find((provider) =>
|
||||
provider.name.trim().toLowerCase() === providerName ||
|
||||
provider.provider?.trim().toLowerCase() === providerName
|
||||
);
|
||||
function fusionModelNameFromSelector(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
const prefix = "Fusion/";
|
||||
return trimmed.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? trimmed.slice(prefix.length).trim()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function isRememberableGatewayModel(model: string): boolean {
|
||||
const slashIndex = model.indexOf("/");
|
||||
return slashIndex > 0 && slashIndex < model.length - 1;
|
||||
}
|
||||
|
||||
function isCodexInternalModel(model: string): boolean {
|
||||
if (model.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const normalized = model.trim().toLowerCase();
|
||||
return /^gpt(?:[-_]|$)/.test(normalized) || /^o\d(?:[-_]|$)/.test(normalized);
|
||||
}
|
||||
|
||||
function isOpenAIResponsesPath(path: string): boolean {
|
||||
function isOpenAIModelRequestPath(path: string): boolean {
|
||||
const normalized = path.toLowerCase();
|
||||
return normalized === "/v1/responses" || normalized === "/responses" || normalized.endsWith("/responses");
|
||||
return normalized === "/v1/responses" ||
|
||||
normalized === "/responses" ||
|
||||
normalized.endsWith("/responses") ||
|
||||
normalized === "/v1/chat/completions" ||
|
||||
normalized === "/chat/completions" ||
|
||||
normalized.endsWith("/chat/completions");
|
||||
}
|
||||
|
||||
function isCodexClient(client: string | undefined, headers: Record<string, string>): boolean {
|
||||
|
|
@ -200,6 +138,24 @@ function stringValue(value: unknown): string | undefined {
|
|||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function encodeJsonBody(value: Record<string, unknown>): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const output: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,15 @@ import type {
|
|||
GatewayProviderProtocol,
|
||||
GatewayStatus,
|
||||
RouterFallbackConfig,
|
||||
RouterFallbackMode
|
||||
RouterFallbackMode,
|
||||
VirtualModelFusionVisionConfig,
|
||||
VirtualModelFusionWebSearchConfig,
|
||||
VirtualModelFusionWebSearchProvider
|
||||
} from "../../shared/app";
|
||||
import {
|
||||
BUILTIN_FUSION_VISION_TOOL_NAME,
|
||||
BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME
|
||||
} from "../../shared/app";
|
||||
import { BUILTIN_UNIMCP_SERVER_NAME } from "../../shared/app";
|
||||
import { providerApiKeySafetyIssue } from "../../shared/provider-presets";
|
||||
import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "../../shared/provider-url";
|
||||
import { backendService } from "../backend-service";
|
||||
|
|
@ -40,6 +46,9 @@ type CoreGatewayProvider = {
|
|||
type: GatewayProviderProtocol;
|
||||
};
|
||||
|
||||
const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave";
|
||||
const fusionModelProviderName = "Fusion";
|
||||
|
||||
type ApiKeyAuthorizationResult =
|
||||
| { ok: true; apiKey?: ApiKeyConfig }
|
||||
| { ok: false };
|
||||
|
|
@ -429,6 +438,7 @@ class GatewayService {
|
|||
|
||||
const headers = forwardHeaders(request.headers);
|
||||
if (apiKey) {
|
||||
stripLocalGatewayAuthHeaders(headers);
|
||||
headers["x-auth-api-key-id"] = apiKey.id;
|
||||
headers["x-auth-sub"] = apiKey.id;
|
||||
}
|
||||
|
|
@ -721,24 +731,30 @@ export const gatewayService = new GatewayService();
|
|||
|
||||
function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): void {
|
||||
mkdirSync(dirname(config.gateway.generatedConfigFile), { recursive: true });
|
||||
const providers = config.Providers
|
||||
.flatMap(toCoreGatewayProviders)
|
||||
.filter((provider): provider is CoreGatewayProvider => Boolean(provider));
|
||||
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
|
||||
const providerPlugins = [
|
||||
...(config.providerPlugins ?? []),
|
||||
...pluginService.getCoreProviderPlugins()
|
||||
];
|
||||
const virtualModelProfiles = [
|
||||
const virtualModelProfiles = withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
|
||||
...(config.virtualModelProfiles ?? []),
|
||||
...pluginService.getVirtualModelProfiles()
|
||||
]));
|
||||
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
|
||||
const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint);
|
||||
const providers = [
|
||||
...config.Providers
|
||||
.flatMap(toCoreGatewayProviders)
|
||||
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
|
||||
...builtinToolArtifacts.providers
|
||||
];
|
||||
const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {};
|
||||
const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : [];
|
||||
const mcpServers = withBuiltInUnimcpMcpServer([
|
||||
const mcpServers = [
|
||||
...builtinToolArtifacts.mcpServers,
|
||||
...pluginMcpServers,
|
||||
...(config.agent?.mcpServers ?? [])
|
||||
]);
|
||||
];
|
||||
const payload = {
|
||||
auth: {
|
||||
enabled: false
|
||||
|
|
@ -773,22 +789,86 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
|
|||
writeFileSync(config.gateway.generatedConfigFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function withBuiltInUnimcpMcpServer(servers: unknown[]): unknown[] {
|
||||
if (servers.some((server) => isRecord(server) && server.name === BUILTIN_UNIMCP_SERVER_NAME)) {
|
||||
return servers;
|
||||
}
|
||||
return [builtInUnimcpMcpServer(), ...servers];
|
||||
function fusionBuiltinToolArtifacts(
|
||||
profiles: unknown[],
|
||||
coreEndpoint: string
|
||||
): { mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] } {
|
||||
const providers: CoreGatewayProvider[] = [];
|
||||
const mcpServers: GatewayMcpServerConfig[] = [];
|
||||
const toolServerKeys = new Set<string>();
|
||||
const entry = bundledFusionBuiltinMcpEntryPath();
|
||||
|
||||
profiles.forEach((profile, index) => {
|
||||
if (!isRecord(profile) || profile.enabled === false) {
|
||||
return;
|
||||
}
|
||||
const metadata = isRecord(profile.metadata) ? profile.metadata : undefined;
|
||||
const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`;
|
||||
const sanitizedProfileId = sanitizeMcpServerName(profileId);
|
||||
|
||||
const visionConfig = readFusionVisionConfig(metadata?.fusionVision) ?? legacyFusionVisionConfig(profile);
|
||||
if (visionConfig?.toolName) {
|
||||
const resolvedVision = resolveFusionVisionRuntime(visionConfig);
|
||||
providers.push(...resolvedVision.providers);
|
||||
const toolServerKey = `vision:${visionConfig.toolName}`;
|
||||
if (!toolServerKeys.has(toolServerKey)) {
|
||||
toolServerKeys.add(toolServerKey);
|
||||
mcpServers.push(fusionBuiltinMcpServer({
|
||||
entry,
|
||||
env: {
|
||||
FUSION_BUILTIN_TOOL_KIND: "vision",
|
||||
FUSION_TOOL_NAME: visionConfig.toolName,
|
||||
...(visionConfig.baseUrl ? { VISION_BASE_URL: visionConfig.baseUrl } : { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` }),
|
||||
...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}),
|
||||
...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}),
|
||||
...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {})
|
||||
},
|
||||
name: `fusion-vision-${sanitizedProfileId}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const webSearchConfig = readFusionWebSearchConfig(metadata?.fusionWebSearch) ?? legacyFusionWebSearchConfig(profile);
|
||||
if (webSearchConfig?.toolName) {
|
||||
const toolServerKey = `web_search:${webSearchConfig.toolName}`;
|
||||
if (!toolServerKeys.has(toolServerKey)) {
|
||||
toolServerKeys.add(toolServerKey);
|
||||
mcpServers.push(fusionBuiltinMcpServer({
|
||||
entry,
|
||||
env: {
|
||||
FUSION_BUILTIN_TOOL_KIND: "web_search",
|
||||
FUSION_TOOL_NAME: webSearchConfig.toolName,
|
||||
SEARCH_PROVIDER: webSearchConfig.provider ?? defaultFusionWebSearchProvider,
|
||||
...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}),
|
||||
...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}),
|
||||
...(webSearchConfig.env ?? {})
|
||||
},
|
||||
name: `fusion-web-search-${sanitizedProfileId}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { mcpServers, providers };
|
||||
}
|
||||
|
||||
function builtInUnimcpMcpServer(): GatewayMcpServerConfig {
|
||||
const entry = bundledUnimcpEntryPath();
|
||||
function fusionBuiltinMcpServer({
|
||||
entry,
|
||||
env,
|
||||
name
|
||||
}: {
|
||||
entry: string;
|
||||
env: Record<string, string>;
|
||||
name: string;
|
||||
}): GatewayMcpServerConfig {
|
||||
return {
|
||||
args: [entry],
|
||||
command: process.execPath,
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: "1"
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
...env
|
||||
},
|
||||
name: BUILTIN_UNIMCP_SERVER_NAME,
|
||||
name,
|
||||
protocolVersion: "2024-11-05",
|
||||
requestTimeoutMs: 600000,
|
||||
startupTimeoutMs: 600000,
|
||||
|
|
@ -797,8 +877,240 @@ function builtInUnimcpMcpServer(): GatewayMcpServerConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function bundledUnimcpEntryPath(): string {
|
||||
return pathJoin(__dirname, "..", "vendor", "unimcp", "dist", "index.js");
|
||||
function bundledFusionBuiltinMcpEntryPath(): string {
|
||||
return pathJoin(__dirname, "fusion-vision-mcp.js");
|
||||
}
|
||||
|
||||
function withFusionVirtualModelAliases(profiles: unknown[]): unknown[] {
|
||||
return profiles.map((profile) => {
|
||||
if (!isRecord(profile)) {
|
||||
return profile;
|
||||
}
|
||||
const match = isRecord(profile.match) ? profile.match : {};
|
||||
const exactAliases = stringListValue(match.exactAliases);
|
||||
const catalogNames = exactAliases.length > 0
|
||||
? exactAliases
|
||||
: [stringValue(profile.key) || stringValue(profile.displayName)].filter((value): value is string => Boolean(value));
|
||||
const fusionAliases = catalogNames.flatMap(fusionModelSelectors).filter(Boolean);
|
||||
if (fusionAliases.length === 0) {
|
||||
return profile;
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
match: {
|
||||
...match,
|
||||
exactAliases: uniqueStrings([...exactAliases, ...fusionAliases])
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[] {
|
||||
return profiles.map((profile) => {
|
||||
if (!isRecord(profile) || profile.enabled === false) {
|
||||
return profile;
|
||||
}
|
||||
const materialization = isRecord(profile.materialization) ? profile.materialization : {};
|
||||
if (materialization.enabled === false || materialization.includeInGatewayModels === false) {
|
||||
return profile;
|
||||
}
|
||||
const execution = isRecord(profile.execution) ? profile.execution : {};
|
||||
if (execution.clientToolsPolicy === "allow") {
|
||||
return profile;
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
execution: {
|
||||
...execution,
|
||||
clientToolsPolicy: "allow"
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fusionModelSelector(model: string): string {
|
||||
const normalized = fusionModelNameFromSelector(model);
|
||||
return normalized ? `${fusionModelProviderName}/${normalized}` : "";
|
||||
}
|
||||
|
||||
function fusionModelSelectors(model: string): string[] {
|
||||
const normalized = fusionModelNameFromSelector(model);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
const lowerModel = normalized.toLowerCase();
|
||||
return uniqueStrings([
|
||||
fusionModelSelector(normalized),
|
||||
lowerModel,
|
||||
`${fusionModelProviderName}/${lowerModel}`,
|
||||
`${fusionModelProviderName.toLowerCase()}/${lowerModel}`
|
||||
]);
|
||||
}
|
||||
|
||||
function fusionModelNameFromSelector(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
const prefix = `${fusionModelProviderName}/`;
|
||||
return trimmed.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? trimmed.slice(prefix.length).trim()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function legacyFusionVisionConfig(profile: Record<string, unknown>): VirtualModelFusionVisionConfig | undefined {
|
||||
const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_VISION_TOOL_NAME, "matchMultimodal");
|
||||
return toolName ? { toolName } : undefined;
|
||||
}
|
||||
|
||||
function legacyFusionWebSearchConfig(profile: Record<string, unknown>): VirtualModelFusionWebSearchConfig | undefined {
|
||||
const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, "matchWebSearch");
|
||||
return toolName ? { provider: defaultFusionWebSearchProvider, toolName } : undefined;
|
||||
}
|
||||
|
||||
function legacyFusionBuiltinToolName(
|
||||
profile: Record<string, unknown>,
|
||||
baseToolName: string,
|
||||
executionFlag: "matchMultimodal" | "matchWebSearch"
|
||||
): string | undefined {
|
||||
const tools = Array.isArray(profile.tools) ? profile.tools : [];
|
||||
const toolName = tools
|
||||
.map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "")
|
||||
.find((name) => name === baseToolName || name.startsWith(`${baseToolName}_`));
|
||||
if (toolName) {
|
||||
return toolName;
|
||||
}
|
||||
const execution = isRecord(profile.execution) ? profile.execution : {};
|
||||
return execution[executionFlag] === true ? baseToolName : undefined;
|
||||
}
|
||||
|
||||
function readFusionVisionConfig(value: unknown): VirtualModelFusionVisionConfig | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const toolName = stringValue(value.toolName);
|
||||
if (!toolName) {
|
||||
return undefined;
|
||||
}
|
||||
const config: VirtualModelFusionVisionConfig = {
|
||||
toolName,
|
||||
apiKey: stringValue(value.apiKey),
|
||||
baseUrl: stringValue(value.baseUrl),
|
||||
model: stringValue(value.model),
|
||||
modelSelector: stringValue(value.modelSelector)
|
||||
};
|
||||
const timeoutMs = numberValue(value.timeoutMs);
|
||||
if (timeoutMs) {
|
||||
config.timeoutMs = timeoutMs;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function readFusionWebSearchConfig(value: unknown): VirtualModelFusionWebSearchConfig | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const toolName = stringValue(value.toolName);
|
||||
if (!toolName) {
|
||||
return undefined;
|
||||
}
|
||||
const config: VirtualModelFusionWebSearchConfig = {
|
||||
toolName,
|
||||
env: isRecord(value.env) ? stringRecordFromUnknown(value.env) : undefined,
|
||||
provider: parseFusionWebSearchProvider(value.provider)
|
||||
};
|
||||
const resultCount = numberValue(value.resultCount);
|
||||
if (resultCount) {
|
||||
config.resultCount = resultCount;
|
||||
}
|
||||
const timeoutMs = numberValue(value.timeoutMs);
|
||||
if (timeoutMs) {
|
||||
config.timeoutMs = timeoutMs;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function resolveFusionVisionRuntime(
|
||||
config: VirtualModelFusionVisionConfig
|
||||
): { model?: string; providers: CoreGatewayProvider[] } {
|
||||
const selector = config.modelSelector || config.model;
|
||||
if (config.baseUrl) {
|
||||
return {
|
||||
model: config.model || config.modelSelector,
|
||||
providers: []
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseFusionModelSelector(selector);
|
||||
if (!parsed) {
|
||||
return {
|
||||
model: selector ? normalizeGatewayModelSelector(selector) : undefined,
|
||||
providers: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
model: `${parsed.providerName}/${parsed.model}`,
|
||||
providers: []
|
||||
};
|
||||
}
|
||||
|
||||
function parseFusionModelSelector(value: string | undefined): { model: string; providerName: string } | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const providerName = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return providerName && model ? { model, providerName } : undefined;
|
||||
}
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
|
||||
const providerName = trimmed.slice(0, slashIndex).trim();
|
||||
const model = trimmed.slice(slashIndex + 1).trim();
|
||||
return providerName && model ? { model, providerName } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeGatewayModelSelector(value: string): string {
|
||||
const parsed = parseFusionModelSelector(value);
|
||||
return parsed ? `${parsed.providerName}/${parsed.model}` : value.trim();
|
||||
}
|
||||
|
||||
function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSearchProvider | undefined {
|
||||
const normalized = stringValue(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "brave" ||
|
||||
normalized === "bing" ||
|
||||
normalized === "google_cse" ||
|
||||
normalized === "serper" ||
|
||||
normalized === "serpapi" ||
|
||||
normalized === "tavily" ||
|
||||
normalized === "exa"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringRecordFromUnknown(value: Record<string, unknown>): Record<string, string> | undefined {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
const normalizedKey = key.trim();
|
||||
const normalizedValue = stringValue(rawValue);
|
||||
if (normalizedKey && normalizedValue) {
|
||||
result[normalizedKey] = normalizedValue;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
function sanitizeMcpServerName(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "fusion";
|
||||
}
|
||||
|
||||
function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record<string, unknown> {
|
||||
|
|
@ -846,6 +1158,7 @@ function readRawTraceRequestLogUpdate(manifest: Record<string, unknown>): Reques
|
|||
requestBodyText: upstreamRequestBody?.text,
|
||||
requestHeaders: headerRecordFromUnknown(upstreamRequestMetadata?.headers),
|
||||
requestId,
|
||||
isStream: upstreamResponseStream !== undefined,
|
||||
responseBodyContentType: upstreamResponseBody?.contentType,
|
||||
responseBodyText: upstreamResponseBody?.text,
|
||||
responseHeaders: headerRecordFromUnknown(upstreamResponseMetadata?.headers),
|
||||
|
|
@ -1919,6 +2232,10 @@ function stringValue(value: unknown): string | undefined {
|
|||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function stringListValue(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) : [];
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.trunc(number) : undefined;
|
||||
|
|
@ -2035,17 +2352,12 @@ function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
const fixedModel = profile.baseModel?.fixedModel?.trim();
|
||||
for (const alias of profile.match?.exactAliases ?? []) {
|
||||
const normalizedAlias = alias.trim();
|
||||
if (!normalizedAlias) {
|
||||
continue;
|
||||
}
|
||||
ids.push(
|
||||
normalizedAlias.includes("/") || !fixedModel
|
||||
? normalizedAlias
|
||||
: `${providerNameFromModelId(fixedModel) ?? ""}/${normalizedAlias}`.replace(/^\//, "")
|
||||
);
|
||||
ids.push(fusionModelSelector(normalizedAlias));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2058,11 +2370,6 @@ function isVisibleVirtualModelProfile(profile: NonNullable<AppConfig["virtualMod
|
|||
profile.materialization?.includeInGatewayModels !== false;
|
||||
}
|
||||
|
||||
function providerNameFromModelId(value: string): string | undefined {
|
||||
const separator = value.indexOf("/");
|
||||
return separator > 0 ? value.slice(0, separator).trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function resolveClaudeCodeDiscoveredModelId(model: string | undefined, config: AppConfig): string | undefined {
|
||||
const normalized = normalizeRouteSelector(model);
|
||||
if (!normalized || !normalized.toLowerCase().startsWith("claude-")) {
|
||||
|
|
@ -2423,6 +2730,12 @@ function forwardHeaders(headers: IncomingHttpHeaders): Record<string, string> {
|
|||
return forwarded;
|
||||
}
|
||||
|
||||
function stripLocalGatewayAuthHeaders(headers: Record<string, string>): void {
|
||||
delete headers.authorization;
|
||||
delete headers["x-api-key"];
|
||||
delete headers["api-key"];
|
||||
}
|
||||
|
||||
function omitLocalObservabilityHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const forwarded = { ...headers };
|
||||
for (const name of localObservabilityHeaderNames) {
|
||||
|
|
|
|||
|
|
@ -13,13 +13,15 @@ import { detectProviderIcon } from "./provider-icons";
|
|||
import { fetchProviderManifest } from "./provider-manifest-service";
|
||||
import { probeGatewayProvider } from "./provider-probe";
|
||||
import { applyProfileConfig } from "./profile-service";
|
||||
import { getProfileOpenCommand, openProfileFromCcr } from "./profile-launch-service";
|
||||
import { ensureProxyCertificateAuthority } from "./proxy/certificates";
|
||||
import { proxyService } from "./proxy/service";
|
||||
import { listMcpServerTools } from "./mcp/tool-discovery";
|
||||
import { getAgentAnalysis, getRequestLogs } from "./request-log-store";
|
||||
import trayController from "./tray-controller";
|
||||
import { getUsageStats } from "./usage-store";
|
||||
import windowsManager from "./windows";
|
||||
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
|
||||
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
|
||||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
|
|
@ -66,6 +68,9 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
|||
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, () => existsSync(ONBOARDING_FINISHED_FILE));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => {
|
||||
return getProfileOpenCommand(await loadAppConfig(), request);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProviderAccountSnapshots, (_event, provider?: string) => getProviderAccountSnapshots(provider));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetAgentAnalysis, (_event, filter?: AgentAnalysisFilter) => getAgentAnalysis(filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetGatewayStatus, () => gatewayService.getStatus());
|
||||
|
|
@ -77,6 +82,7 @@ ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListF
|
|||
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appFetchProviderManifest, (_event, request: ProviderManifestFetchRequest) => fetchProviderManifest(request));
|
||||
ipcMain.handle(IPC_CHANNELS.appInstallProxyCertificate, () => proxyService.installCertificate());
|
||||
ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, (_event, server: GatewayMcpServerConfig) => listMcpServerTools(server));
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {
|
||||
const config = await loadAppConfig();
|
||||
await ensureBuiltInBrowserProxyReady(config);
|
||||
|
|
@ -112,6 +118,16 @@ ipcMain.handle(IPC_CHANNELS.appOpenExternal, async (_event, url: string) => {
|
|||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenRequest) => {
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
if (status.state !== "running") {
|
||||
throw new Error(status.lastError || "CCR gateway did not start.");
|
||||
}
|
||||
logProfileApplyResult(await applyProfileConfig(config));
|
||||
return openProfileFromCcr(config, request);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: AppConfig) => {
|
||||
const previousConfig = await loadAppConfig();
|
||||
const baseConfig = config ? await saveAppConfig(config) : previousConfig;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import { proxyService } from "./proxy/service";
|
|||
import trayController from "./tray-controller";
|
||||
import windowsManager from "./windows";
|
||||
|
||||
deepLinkService.register();
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
const quitProxyRestoreTimeoutMs = 30_000;
|
||||
let quitPrepared = false;
|
||||
|
|
@ -22,6 +20,11 @@ let stopForQuitPromise: Promise<void> | undefined;
|
|||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
startPrimaryInstance();
|
||||
}
|
||||
|
||||
function startPrimaryInstance(): void {
|
||||
deepLinkService.register();
|
||||
deepLinkService.handleArgv(process.argv);
|
||||
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
|
|
@ -29,43 +32,43 @@ if (!gotTheLock) {
|
|||
deepLinkService.handleArgv(argv);
|
||||
queueEnsureConfiguredProxyModeActive("second-instance");
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
windowsManager.createMainWindow();
|
||||
trayController.start();
|
||||
void startConfiguredServices("startup");
|
||||
app.whenReady().then(() => {
|
||||
windowsManager.createMainWindow();
|
||||
trayController.start();
|
||||
void startConfiguredServices("startup");
|
||||
|
||||
app.on("activate", () => {
|
||||
windowsManager.showMainWindow();
|
||||
queueEnsureConfiguredProxyModeActive("activate");
|
||||
app.on("activate", () => {
|
||||
windowsManager.showMainWindow();
|
||||
queueEnsureConfiguredProxyModeActive("activate");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
|
||||
app.on("will-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
app.on("will-quit", (event) => {
|
||||
if (quitPrepared) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
prepareAndQuit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
process.once("SIGINT", () => handleTerminationSignal("SIGINT"));
|
||||
process.once("SIGTERM", () => handleTerminationSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => handleTerminationSignal("SIGINT"));
|
||||
process.once("SIGTERM", () => handleTerminationSignal("SIGTERM"));
|
||||
}
|
||||
|
||||
function prepareAndQuit(): void {
|
||||
if (stoppingForQuit) {
|
||||
|
|
|
|||
729
src/main/mcp/fusion-vision-mcp.ts
Normal file
729
src/main/mcp/fusion-vision-mcp.ts
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
|
||||
type JsonPrimitive = boolean | null | number | string;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
type JsonRpcRequest = {
|
||||
id?: null | number | string;
|
||||
jsonrpc?: string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type JsonRpcResponse =
|
||||
| {
|
||||
id: null | number | string;
|
||||
jsonrpc: "2.0";
|
||||
result: JsonValue;
|
||||
}
|
||||
| {
|
||||
error: {
|
||||
code: number;
|
||||
data?: JsonValue;
|
||||
message: string;
|
||||
};
|
||||
id: null | number | string;
|
||||
jsonrpc: "2.0";
|
||||
};
|
||||
|
||||
type ToolCallResult = {
|
||||
content: Array<{ text: string; type: "text" }>;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type FusionBuiltinToolKind = "vision" | "web_search";
|
||||
type SearchProvider = "auto" | "bing" | "brave" | "exa" | "google_cse" | "serpapi" | "serper" | "tavily";
|
||||
type SearchInput = {
|
||||
count: number;
|
||||
country?: string;
|
||||
excludeDomains: string[];
|
||||
freshness?: string;
|
||||
includeDomains: string[];
|
||||
includeRaw: boolean;
|
||||
language?: string;
|
||||
prompt: string;
|
||||
safeSearch?: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
type SearchResult = {
|
||||
snippet?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const protocolVersion = "2024-11-05";
|
||||
const defaultVisionBaseUrl = "https://api.openai.com/v1";
|
||||
const defaultVisionModel = "gpt-4o-mini";
|
||||
const defaultTimeoutMs = 30000;
|
||||
const maxLocalImageBytes = 20 * 1024 * 1024;
|
||||
|
||||
const toolKind = parseToolKind(env("FUSION_BUILTIN_TOOL_KIND"));
|
||||
const toolName = env("FUSION_TOOL_NAME") || env("FUSION_VISION_TOOL_NAME") || (toolKind === "web_search" ? "web_search" : "vision_understand");
|
||||
const toolTitle = env("FUSION_TOOL_TITLE") || env("FUSION_VISION_TOOL_TITLE") || (toolKind === "web_search" ? "Fusion Web Search" : "Fusion Vision Understand");
|
||||
|
||||
const visionTool = {
|
||||
description: "Analyze one or more images with this Fusion profile's configured OpenAI-compatible vision model.",
|
||||
inputSchema: objectSchema({
|
||||
detail: { enum: ["auto", "low", "high"], type: "string" },
|
||||
imageBase64: { description: "Single raw base64 image payload or data URL.", type: "string" },
|
||||
imagePath: { description: "Single local image path.", type: "string" },
|
||||
imageUrl: { description: "Single HTTP(S) image URL or data URL.", type: "string" },
|
||||
images: {
|
||||
items: objectSchema({
|
||||
base64: { type: "string" },
|
||||
label: { type: "string" },
|
||||
mimeType: { type: "string" },
|
||||
path: { type: "string" },
|
||||
url: { type: "string" }
|
||||
}),
|
||||
type: "array"
|
||||
},
|
||||
prompt: { description: "Task instruction for image analysis.", type: "string" },
|
||||
systemPrompt: { type: "string" },
|
||||
timeoutMs: { minimum: 100, type: "number" }
|
||||
}, ["prompt"]),
|
||||
name: toolName,
|
||||
title: toolTitle
|
||||
};
|
||||
|
||||
const webSearchTool = {
|
||||
description: "Search the web with this Fusion profile's configured search provider.",
|
||||
inputSchema: objectSchema({
|
||||
count: { maximum: 20, minimum: 1, type: "number" },
|
||||
country: { type: "string" },
|
||||
excludeDomains: { items: { type: "string" }, type: "array" },
|
||||
freshness: { enum: ["day", "week", "month"], type: "string" },
|
||||
includeDomains: { items: { type: "string" }, type: "array" },
|
||||
includeRaw: { type: "boolean" },
|
||||
language: { type: "string" },
|
||||
prompt: { description: "Natural-language search query.", type: "string" },
|
||||
safeSearch: { enum: ["off", "moderate", "strict"], type: "string" },
|
||||
timeoutMs: { minimum: 100, type: "number" }
|
||||
}, ["prompt"]),
|
||||
name: toolName,
|
||||
title: toolTitle
|
||||
};
|
||||
|
||||
const activeTool = toolKind === "web_search" ? webSearchTool : visionTool;
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
inputBuffer = Buffer.concat([inputBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
drainInputBuffer().catch((error) => {
|
||||
writeJsonRpc(jsonRpcError(null, -32603, formatError(error)));
|
||||
});
|
||||
});
|
||||
|
||||
process.stdin.resume();
|
||||
|
||||
async function drainInputBuffer(): Promise<void> {
|
||||
while (true) {
|
||||
const headerEnd = inputBuffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerText = inputBuffer.subarray(0, headerEnd).toString("utf8");
|
||||
const lengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
||||
if (!lengthMatch) {
|
||||
inputBuffer = inputBuffer.subarray(headerEnd + 4);
|
||||
writeJsonRpc(jsonRpcError(null, -32600, "Missing Content-Length header."));
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLength = Number(lengthMatch[1]);
|
||||
const messageStart = headerEnd + 4;
|
||||
const messageEnd = messageStart + contentLength;
|
||||
if (inputBuffer.length < messageEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = inputBuffer.subarray(messageStart, messageEnd).toString("utf8");
|
||||
inputBuffer = inputBuffer.subarray(messageEnd);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(message) as unknown;
|
||||
} catch (error) {
|
||||
writeJsonRpc(jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await handleJsonRpcRequest(payload);
|
||||
if (response) {
|
||||
writeJsonRpc(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJsonRpcRequest(payload: unknown): Promise<JsonRpcResponse | undefined> {
|
||||
if (!isRecord(payload)) {
|
||||
return jsonRpcError(null, -32600, "JSON-RPC request must be an object.");
|
||||
}
|
||||
|
||||
const request = payload as JsonRpcRequest;
|
||||
const id = request.id ?? null;
|
||||
if (request.id === undefined && request.method?.startsWith("notifications/")) {
|
||||
return undefined;
|
||||
}
|
||||
if (request.jsonrpc !== "2.0" || !request.method) {
|
||||
return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request.");
|
||||
}
|
||||
|
||||
try {
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
return jsonRpcResult(id, {
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
protocolVersion,
|
||||
serverInfo: {
|
||||
name: "ccr-fusion-builtins",
|
||||
title: "CCR Fusion Builtins",
|
||||
version: "1.0.0"
|
||||
}
|
||||
});
|
||||
case "ping":
|
||||
return jsonRpcResult(id, {});
|
||||
case "tools/list":
|
||||
return jsonRpcResult(id, { tools: [activeTool] as unknown as JsonValue });
|
||||
case "tools/call":
|
||||
return jsonRpcResult(id, await callTool(request.params) as unknown as JsonValue);
|
||||
default:
|
||||
return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`);
|
||||
}
|
||||
} catch (error) {
|
||||
return jsonRpcError(id, -32603, formatError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool(params: unknown): Promise<ToolCallResult> {
|
||||
if (!isRecord(params) || typeof params.name !== "string") {
|
||||
throw new Error("tools/call params must include a tool name.");
|
||||
}
|
||||
if (params.name !== toolName) {
|
||||
throw new Error(`Unknown fusion tool: ${params.name}`);
|
||||
}
|
||||
|
||||
const args = isRecord(params.arguments) ? params.arguments : {};
|
||||
try {
|
||||
const text = toolKind === "web_search" ? await analyzeWebSearch(args) : await analyzeVision(args);
|
||||
return textResult(text);
|
||||
} catch (error) {
|
||||
return {
|
||||
...textResult(formatError(error)),
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeVision(args: Record<string, unknown>): Promise<string> {
|
||||
const prompt = readString(args.prompt);
|
||||
if (!prompt) {
|
||||
throw new Error(`${toolName} requires prompt.`);
|
||||
}
|
||||
|
||||
const gatewayBaseUrl = env("VISION_GATEWAY_BASE_URL");
|
||||
const baseUrl = gatewayBaseUrl || env("VISION_BASE_URL") || env("OPENAI_BASE_URL") || defaultVisionBaseUrl;
|
||||
const apiKey = gatewayBaseUrl ? env("VISION_GATEWAY_API_KEY") : env("VISION_API_KEY") || env("OPENAI_API_KEY");
|
||||
if (!gatewayBaseUrl && !apiKey) {
|
||||
throw new Error("Missing vision API key. Set VISION_API_KEY.");
|
||||
}
|
||||
const model = env("VISION_MODEL") || env("OPENAI_MODEL") || defaultVisionModel;
|
||||
const detail = readString(args.detail);
|
||||
const imageParts = await buildImageParts(args, detail === "low" || detail === "high" ? detail : "auto");
|
||||
if (imageParts.length === 0) {
|
||||
throw new Error(`${toolName} requires imageUrl, imagePath, imageBase64, or images.`);
|
||||
}
|
||||
|
||||
const systemPrompt = readString(args.systemPrompt);
|
||||
const messages = [
|
||||
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
...imageParts
|
||||
]
|
||||
}
|
||||
];
|
||||
const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? readNumber(env("VISION_TIMEOUT_MS")) ?? defaultTimeoutMs, 100, 600000);
|
||||
const response = await fetch(resolveChatCompletionsUrl(baseUrl), {
|
||||
body: JSON.stringify({ model, messages }),
|
||||
headers: {
|
||||
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
const rawText = await response.text();
|
||||
const payload = parseJson(rawText);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vision request failed (${response.status}): ${extractProviderError(rawText, payload)}`);
|
||||
}
|
||||
|
||||
return extractResponseText(payload) || rawText;
|
||||
}
|
||||
|
||||
async function analyzeWebSearch(args: Record<string, unknown>): Promise<string> {
|
||||
const prompt = readString(args.prompt);
|
||||
if (!prompt) {
|
||||
throw new Error(`${toolName} requires prompt.`);
|
||||
}
|
||||
|
||||
const provider = resolveSearchProvider();
|
||||
const count = clampInteger(readNumber(args.count) ?? readNumber(env("SEARCH_RESULT_COUNT")) ?? 5, 1, 20);
|
||||
const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? readNumber(env("SEARCH_TIMEOUT_MS")) ?? defaultTimeoutMs, 100, 600000);
|
||||
const input = {
|
||||
count,
|
||||
country: readString(args.country),
|
||||
excludeDomains: readStringArray(args.excludeDomains),
|
||||
freshness: readString(args.freshness),
|
||||
includeDomains: readStringArray(args.includeDomains),
|
||||
includeRaw: args.includeRaw === true,
|
||||
language: readString(args.language),
|
||||
prompt,
|
||||
safeSearch: readString(args.safeSearch),
|
||||
timeoutMs
|
||||
};
|
||||
const results = await searchWithProvider(provider, input);
|
||||
if (results.length === 0) {
|
||||
return `Search provider: ${provider}\nNo results.`;
|
||||
}
|
||||
return [
|
||||
`Search provider: ${provider}`,
|
||||
...results.slice(0, count).map((result, index) => [
|
||||
`${index + 1}. ${result.title || result.url || "Untitled"}`,
|
||||
result.url ? `URL: ${result.url}` : "",
|
||||
result.snippet ? `Snippet: ${result.snippet}` : ""
|
||||
].filter(Boolean).join("\n"))
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function searchWithProvider(
|
||||
provider: Exclude<SearchProvider, "auto">,
|
||||
input: {
|
||||
count: number;
|
||||
country?: string;
|
||||
excludeDomains: string[];
|
||||
freshness?: string;
|
||||
includeDomains: string[];
|
||||
includeRaw: boolean;
|
||||
language?: string;
|
||||
prompt: string;
|
||||
safeSearch?: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<Array<{ snippet?: string; title?: string; url?: string }>> {
|
||||
if (provider === "brave") return searchBrave(input);
|
||||
if (provider === "bing") return searchBing(input);
|
||||
if (provider === "google_cse") return searchGoogleCse(input);
|
||||
if (provider === "serper") return searchSerper(input);
|
||||
if (provider === "serpapi") return searchSerpApi(input);
|
||||
if (provider === "tavily") return searchTavily(input);
|
||||
return searchExa(input);
|
||||
}
|
||||
|
||||
async function searchBrave(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("BRAVE_SEARCH_API_KEY", "Brave Search API key");
|
||||
const url = new URL(env("BRAVE_SEARCH_ENDPOINT") || "https://api.search.brave.com/res/v1/web/search");
|
||||
url.searchParams.set("q", scopedSearchQuery(input));
|
||||
url.searchParams.set("count", String(input.count));
|
||||
if (input.country) url.searchParams.set("country", input.country);
|
||||
if (input.language) url.searchParams.set("search_lang", input.language);
|
||||
if (input.safeSearch) url.searchParams.set("safesearch", input.safeSearch);
|
||||
const raw = await fetchJson(url.toString(), {
|
||||
headers: { "x-subscription-token": apiKey },
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && isRecord(raw.web) && Array.isArray(raw.web.results) ? raw.web.results : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "url", "description")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchBing(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("BING_SEARCH_API_KEY", "Bing Web Search API key");
|
||||
const url = new URL(env("BING_SEARCH_ENDPOINT") || "https://api.bing.microsoft.com/v7.0/search");
|
||||
url.searchParams.set("q", scopedSearchQuery(input));
|
||||
url.searchParams.set("count", String(input.count));
|
||||
if (input.country || input.language) url.searchParams.set("mkt", [input.language, input.country].filter(Boolean).join("-"));
|
||||
if (input.safeSearch) url.searchParams.set("safeSearch", input.safeSearch);
|
||||
const raw = await fetchJson(url.toString(), {
|
||||
headers: { "ocp-apim-subscription-key": apiKey },
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && isRecord(raw.webPages) && Array.isArray(raw.webPages.value) ? raw.webPages.value : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "name", "url", "snippet")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchGoogleCse(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("GOOGLE_SEARCH_API_KEY", "Google Programmable Search API key");
|
||||
const cx = requireEnv("GOOGLE_SEARCH_CX", "Google Programmable Search Engine ID");
|
||||
const url = new URL(env("GOOGLE_SEARCH_ENDPOINT") || "https://www.googleapis.com/customsearch/v1");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("cx", cx);
|
||||
url.searchParams.set("q", scopedSearchQuery(input));
|
||||
url.searchParams.set("num", String(Math.min(input.count, 10)));
|
||||
if (input.country) url.searchParams.set("gl", input.country);
|
||||
if (input.language) url.searchParams.set("hl", input.language);
|
||||
const raw = await fetchJson(url.toString(), { signal: AbortSignal.timeout(input.timeoutMs) });
|
||||
const items = isRecord(raw) && Array.isArray(raw.items) ? raw.items : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchSerper(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("SERPER_API_KEY", "Serper API key");
|
||||
const raw = await fetchJson(env("SERPER_SEARCH_ENDPOINT") || "https://google.serper.dev/search", {
|
||||
body: JSON.stringify({
|
||||
gl: input.country,
|
||||
hl: input.language,
|
||||
num: input.count,
|
||||
q: scopedSearchQuery(input),
|
||||
tbs: freshnessToGoogleTbs(input.freshness)
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": apiKey
|
||||
},
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && Array.isArray(raw.organic) ? raw.organic : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchSerpApi(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("SERPAPI_API_KEY", "SerpAPI key");
|
||||
const url = new URL(env("SERPAPI_SEARCH_ENDPOINT") || "https://serpapi.com/search.json");
|
||||
url.searchParams.set("api_key", apiKey);
|
||||
url.searchParams.set("engine", "google");
|
||||
url.searchParams.set("q", scopedSearchQuery(input));
|
||||
url.searchParams.set("num", String(input.count));
|
||||
if (input.country) url.searchParams.set("gl", input.country);
|
||||
if (input.language) url.searchParams.set("hl", input.language);
|
||||
if (input.safeSearch) url.searchParams.set("safe", input.safeSearch === "off" ? "off" : "active");
|
||||
const raw = await fetchJson(url.toString(), { signal: AbortSignal.timeout(input.timeoutMs) });
|
||||
const items = isRecord(raw) && Array.isArray(raw.organic_results) ? raw.organic_results : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchTavily(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("TAVILY_API_KEY", "Tavily API key");
|
||||
const raw = await fetchJson(env("TAVILY_SEARCH_ENDPOINT") || "https://api.tavily.com/search", {
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
include_raw_content: input.includeRaw,
|
||||
max_results: input.count,
|
||||
query: scopedSearchQuery(input),
|
||||
search_depth: "basic"
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "url", "content")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchExa(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("EXA_API_KEY", "Exa API key");
|
||||
const raw = await fetchJson(env("EXA_SEARCH_ENDPOINT") || "https://api.exa.ai/search", {
|
||||
body: JSON.stringify({
|
||||
numResults: input.count,
|
||||
query: scopedSearchQuery(input)
|
||||
}),
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "url", "text")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function buildImageParts(args: Record<string, unknown>, detail: "auto" | "high" | "low"): Promise<JsonValue[]> {
|
||||
const inputs: Array<{ base64?: string; label?: string; mimeType?: string; path?: string; url?: string }> = [];
|
||||
const imageUrl = readString(args.imageUrl);
|
||||
const imagePath = readString(args.imagePath);
|
||||
const imageBase64 = readString(args.imageBase64);
|
||||
if (imageUrl) inputs.push({ url: imageUrl });
|
||||
if (imagePath) inputs.push({ path: imagePath });
|
||||
if (imageBase64) inputs.push({ base64: imageBase64, mimeType: readString(args.mimeType) });
|
||||
|
||||
const images = Array.isArray(args.images) ? args.images : [];
|
||||
for (const item of images) {
|
||||
if (!isRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
inputs.push({
|
||||
base64: readString(item.base64),
|
||||
label: readString(item.label),
|
||||
mimeType: readString(item.mimeType),
|
||||
path: readString(item.path),
|
||||
url: readString(item.url)
|
||||
});
|
||||
}
|
||||
|
||||
const parts: JsonValue[] = [];
|
||||
for (const input of inputs) {
|
||||
const url = await imageInputToUrl(input);
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
if (input.label) {
|
||||
parts.push({ text: `Image: ${input.label}`, type: "text" });
|
||||
}
|
||||
parts.push({
|
||||
image_url: {
|
||||
detail,
|
||||
url
|
||||
},
|
||||
type: "image_url"
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function imageInputToUrl(input: { base64?: string; mimeType?: string; path?: string; url?: string }): Promise<string | undefined> {
|
||||
if (input.url) {
|
||||
return input.url;
|
||||
}
|
||||
if (input.base64) {
|
||||
return toDataUrl(input.base64, input.mimeType || "image/png");
|
||||
}
|
||||
if (!input.path) {
|
||||
return undefined;
|
||||
}
|
||||
const buffer = await readFile(input.path);
|
||||
if (buffer.byteLength > maxLocalImageBytes) {
|
||||
throw new Error(`Local image exceeds ${maxLocalImageBytes} bytes: ${input.path}`);
|
||||
}
|
||||
return toDataUrl(buffer.toString("base64"), input.mimeType || mimeTypeFromPath(input.path));
|
||||
}
|
||||
|
||||
function toDataUrl(value: string, mimeType: string): string {
|
||||
return value.startsWith("data:") ? value : `data:${mimeType};base64,${value}`;
|
||||
}
|
||||
|
||||
function mimeTypeFromPath(path: string): string {
|
||||
const ext = extname(path).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
function resolveChatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
return trimmed.endsWith("/chat/completions") ? trimmed : `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function extractResponseText(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const choices = Array.isArray(value.choices) ? value.choices : [];
|
||||
const first = isRecord(choices[0]) ? choices[0] : undefined;
|
||||
const message = isRecord(first?.message) ? first.message : undefined;
|
||||
const content = message?.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map((item) => isRecord(item) ? readString(item.text) : undefined)
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.join("\n");
|
||||
return text || undefined;
|
||||
}
|
||||
return readString(value.output_text);
|
||||
}
|
||||
|
||||
function extractProviderError(rawText: string, json: unknown): string {
|
||||
if (isRecord(json)) {
|
||||
const error = json.error;
|
||||
if (typeof error === "string") return error;
|
||||
if (isRecord(error) && typeof error.message === "string") return error.message;
|
||||
if (typeof json.message === "string") return json.message;
|
||||
}
|
||||
return rawText.slice(0, 500);
|
||||
}
|
||||
|
||||
function parseJson(rawText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(rawText) as unknown;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON from provider: ${rawText.slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolKind(value: string | undefined): FusionBuiltinToolKind {
|
||||
return value === "web_search" ? "web_search" : "vision";
|
||||
}
|
||||
|
||||
function resolveSearchProvider(): Exclude<SearchProvider, "auto"> {
|
||||
const configured = parseSearchProvider(env("SEARCH_PROVIDER")) ?? "auto";
|
||||
if (configured !== "auto") {
|
||||
return configured;
|
||||
}
|
||||
const candidates: Array<Exclude<SearchProvider, "auto">> = ["brave", "bing", "google_cse", "serper", "serpapi", "tavily", "exa"];
|
||||
const provider = candidates.find(searchProviderIsConfigured);
|
||||
if (!provider) {
|
||||
throw new Error("No search provider configured. Set SEARCH_PROVIDER and its API key.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function parseSearchProvider(value: string | undefined): SearchProvider | undefined {
|
||||
if (
|
||||
value === "auto" ||
|
||||
value === "brave" ||
|
||||
value === "bing" ||
|
||||
value === "google_cse" ||
|
||||
value === "serper" ||
|
||||
value === "serpapi" ||
|
||||
value === "tavily" ||
|
||||
value === "exa"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function searchProviderIsConfigured(provider: Exclude<SearchProvider, "auto">): boolean {
|
||||
if (provider === "brave") return Boolean(env("BRAVE_SEARCH_API_KEY"));
|
||||
if (provider === "bing") return Boolean(env("BING_SEARCH_API_KEY"));
|
||||
if (provider === "google_cse") return Boolean(env("GOOGLE_SEARCH_API_KEY") && env("GOOGLE_SEARCH_CX"));
|
||||
if (provider === "serper") return Boolean(env("SERPER_API_KEY"));
|
||||
if (provider === "serpapi") return Boolean(env("SERPAPI_API_KEY"));
|
||||
if (provider === "tavily") return Boolean(env("TAVILY_API_KEY"));
|
||||
return Boolean(env("EXA_API_KEY"));
|
||||
}
|
||||
|
||||
function requireEnv(name: string, label: string): string {
|
||||
const value = env(name);
|
||||
if (!value) {
|
||||
throw new Error(`Missing ${label}. Set ${name}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
const rawText = await response.text();
|
||||
const payload = rawText ? parseJson(rawText) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search request failed (${response.status}): ${extractProviderError(rawText, payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function scopedSearchQuery(input: SearchInput): string {
|
||||
const include = input.includeDomains.map((domain) => `site:${domain}`).join(" ");
|
||||
const exclude = input.excludeDomains.map((domain) => `-site:${domain}`).join(" ");
|
||||
return [input.prompt, include, exclude].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function freshnessToGoogleTbs(value: string | undefined): string | undefined {
|
||||
if (value === "day") return "qdr:d";
|
||||
if (value === "week") return "qdr:w";
|
||||
if (value === "month") return "qdr:m";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeSearchResult(value: unknown, titleKey: string, urlKey: string, snippetKey: string): SearchResult {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
snippet: readString(value[snippetKey]),
|
||||
title: readString(value[titleKey]),
|
||||
url: readString(value[urlKey])
|
||||
};
|
||||
}
|
||||
|
||||
function isSearchResult(value: SearchResult): value is Required<Pick<SearchResult, "url">> & SearchResult {
|
||||
return Boolean(value.title || value.url || value.snippet);
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(readString).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
function textResult(text: string): ToolCallResult {
|
||||
return {
|
||||
content: [{ text, type: "text" }]
|
||||
};
|
||||
}
|
||||
|
||||
function objectSchema(properties: Record<string, JsonValue>, required: string[] = []): JsonValue {
|
||||
return {
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
required,
|
||||
type: "object"
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse {
|
||||
return {
|
||||
id,
|
||||
jsonrpc: "2.0",
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRpcError(id: null | number | string, code: number, message: string, data?: JsonValue): JsonRpcResponse {
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
...(data === undefined ? {} : { data }),
|
||||
message
|
||||
},
|
||||
id,
|
||||
jsonrpc: "2.0"
|
||||
};
|
||||
}
|
||||
|
||||
function writeJsonRpc(response: JsonRpcResponse): void {
|
||||
const payload = JSON.stringify(response);
|
||||
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function env(name: string): string | undefined {
|
||||
return readString(process.env[name]);
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
559
src/main/mcp/tool-discovery.ts
Normal file
559
src/main/mcp/tool-discovery.ts
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type {
|
||||
GatewayMcpRemoteServerConfig,
|
||||
GatewayMcpServerConfig,
|
||||
GatewayMcpStdioServerConfig,
|
||||
GatewayMcpToolInfo
|
||||
} from "../../shared/app";
|
||||
|
||||
type JsonRpcMessage = {
|
||||
error?: unknown;
|
||||
id?: number | string | null;
|
||||
jsonrpc?: string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (message: JsonRpcMessage) => void;
|
||||
};
|
||||
|
||||
type SseEvent = {
|
||||
data: string;
|
||||
event: string;
|
||||
};
|
||||
|
||||
const mcpClientInfo = {
|
||||
name: "CCR",
|
||||
version: "3.0.0"
|
||||
};
|
||||
|
||||
export async function listMcpServerTools(server: GatewayMcpServerConfig): Promise<GatewayMcpToolInfo[]> {
|
||||
if (server.transport === "stdio") {
|
||||
return listStdioMcpServerTools(server);
|
||||
}
|
||||
|
||||
try {
|
||||
return await listStreamableHttpMcpServerTools(server);
|
||||
} catch (error) {
|
||||
if (server.transport !== "sse") {
|
||||
throw error;
|
||||
}
|
||||
return listLegacySseMcpServerTools(server, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function listStdioMcpServerTools(server: GatewayMcpStdioServerConfig): Promise<GatewayMcpToolInfo[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(server.command, server.args, {
|
||||
cwd: server.cwd || undefined,
|
||||
env: {
|
||||
...process.env,
|
||||
...server.env
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
const timeout = setTimeout(() => finish(new Error(`MCP tools discovery timed out after ${mcpTimeoutMs(server)} ms.`)), mcpTimeoutMs(server));
|
||||
const readMessage = createStdioMessageReader(server.stdioMessageMode, routeMessage);
|
||||
let nextId = 1;
|
||||
let settled = false;
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => readMessage(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-4000);
|
||||
});
|
||||
child.on("error", (error) => finish(error));
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!settled) {
|
||||
const detail = stderr.trim() ? ` ${stderr.trim()}` : "";
|
||||
finish(new Error(`MCP server exited before tools/list completed (${signal ?? code ?? "unknown"}).${detail}`));
|
||||
}
|
||||
});
|
||||
|
||||
run().catch((error: unknown) => finish(error));
|
||||
|
||||
async function run() {
|
||||
await request("initialize", {
|
||||
capabilities: {},
|
||||
clientInfo: mcpClientInfo,
|
||||
protocolVersion: server.protocolVersion || "2024-11-05"
|
||||
});
|
||||
notify("notifications/initialized", {});
|
||||
const response = await request("tools/list", {});
|
||||
finish(undefined, normalizeToolList(response.result));
|
||||
}
|
||||
|
||||
function request(method: string, params: unknown): Promise<JsonRpcMessage> {
|
||||
const id = nextId++;
|
||||
const message: JsonRpcMessage = {
|
||||
id,
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params
|
||||
};
|
||||
return new Promise((resolveRequest, rejectRequest) => {
|
||||
pending.set(String(id), {
|
||||
reject: rejectRequest,
|
||||
resolve: resolveRequest
|
||||
});
|
||||
writeStdioMessage(child, server.stdioMessageMode, message);
|
||||
});
|
||||
}
|
||||
|
||||
function notify(method: string, params: unknown) {
|
||||
writeStdioMessage(child, server.stdioMessageMode, {
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
function routeMessage(message: JsonRpcMessage) {
|
||||
const key = message.id === undefined || message.id === null ? "" : String(message.id);
|
||||
const request = key ? pending.get(key) : undefined;
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
pending.delete(key);
|
||||
if (message.error) {
|
||||
request.reject(new Error(jsonRpcErrorMessage(message.error)));
|
||||
return;
|
||||
}
|
||||
request.resolve(message);
|
||||
}
|
||||
|
||||
function finish(error?: unknown, tools: GatewayMcpToolInfo[] = []) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
for (const request of pending.values()) {
|
||||
request.reject(toError(error ?? "MCP tools discovery stopped."));
|
||||
}
|
||||
pending.clear();
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
if (error) {
|
||||
reject(toError(error));
|
||||
return;
|
||||
}
|
||||
resolve(tools);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listStreamableHttpMcpServerTools(server: GatewayMcpRemoteServerConfig): Promise<GatewayMcpToolInfo[]> {
|
||||
let nextId = 1;
|
||||
let sessionId = "";
|
||||
|
||||
async function send(message: JsonRpcMessage): Promise<JsonRpcMessage | undefined> {
|
||||
const response = await postJsonRpc(server, server.url, message, sessionId);
|
||||
sessionId = response.sessionId || sessionId;
|
||||
return response.message;
|
||||
}
|
||||
|
||||
await send({
|
||||
id: nextId++,
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
capabilities: {},
|
||||
clientInfo: mcpClientInfo,
|
||||
protocolVersion: server.protocolVersion || "2024-11-05"
|
||||
}
|
||||
});
|
||||
await send({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
params: {}
|
||||
});
|
||||
const response = await send({
|
||||
id: nextId++,
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/list",
|
||||
params: {}
|
||||
});
|
||||
|
||||
return normalizeToolList(response?.result);
|
||||
}
|
||||
|
||||
async function listLegacySseMcpServerTools(
|
||||
server: GatewayMcpRemoteServerConfig,
|
||||
originalError: unknown
|
||||
): Promise<GatewayMcpToolInfo[]> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), mcpTimeoutMs(server));
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let endpointResolve: (endpoint: string) => void = () => {};
|
||||
let endpointReject: (error: Error) => void = () => {};
|
||||
const endpointPromise = new Promise<string>((resolve, reject) => {
|
||||
endpointResolve = resolve;
|
||||
endpointReject = reject;
|
||||
});
|
||||
let streamBuffer = "";
|
||||
let nextId = 1;
|
||||
|
||||
try {
|
||||
const response = await fetch(server.url, {
|
||||
headers: mcpHttpHeaders(server, "", false),
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE MCP discovery failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const readLoop = (async () => {
|
||||
const decoder = new TextDecoder();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
streamBuffer += decoder.decode(value, { stream: true });
|
||||
streamBuffer = consumeSseEvents(streamBuffer, routeSseEvent);
|
||||
}
|
||||
})();
|
||||
readLoop.catch((error: unknown) => rejectPending(error));
|
||||
|
||||
const endpoint = await endpointPromise;
|
||||
const messageUrl = new URL(endpoint, server.url).toString();
|
||||
|
||||
await request(messageUrl, "initialize", {
|
||||
capabilities: {},
|
||||
clientInfo: mcpClientInfo,
|
||||
protocolVersion: server.protocolVersion || "2024-11-05"
|
||||
});
|
||||
await postJsonRpc(server, messageUrl, {
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
params: {}
|
||||
});
|
||||
const responseMessage = await request(messageUrl, "tools/list", {});
|
||||
return normalizeToolList(responseMessage.result);
|
||||
} catch (error) {
|
||||
if (originalError instanceof Error && !(error instanceof DOMException && error.name === "AbortError")) {
|
||||
throw new Error(`${toError(error).message} Streamable HTTP fallback failed first: ${originalError.message}`);
|
||||
}
|
||||
throw toError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
rejectPending(new Error("MCP SSE discovery closed."));
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
function routeSseEvent(event: SseEvent) {
|
||||
if (event.event === "endpoint") {
|
||||
endpointResolve(event.data.trim());
|
||||
return;
|
||||
}
|
||||
const message = parseJsonRpcMessage(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const key = message.id === undefined || message.id === null ? "" : String(message.id);
|
||||
const request = key ? pending.get(key) : undefined;
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
pending.delete(key);
|
||||
if (message.error) {
|
||||
request.reject(new Error(jsonRpcErrorMessage(message.error)));
|
||||
return;
|
||||
}
|
||||
request.resolve(message);
|
||||
}
|
||||
|
||||
function request(messageUrl: string, method: string, params: unknown): Promise<JsonRpcMessage> {
|
||||
const id = nextId++;
|
||||
const message: JsonRpcMessage = {
|
||||
id,
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(String(id), { reject, resolve });
|
||||
postJsonRpc(server, messageUrl, message).catch((error: unknown) => {
|
||||
pending.delete(String(id));
|
||||
reject(toError(error));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rejectPending(error: unknown) {
|
||||
endpointReject(toError(error));
|
||||
for (const request of pending.values()) {
|
||||
request.reject(toError(error));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function postJsonRpc(
|
||||
server: GatewayMcpRemoteServerConfig,
|
||||
url: string,
|
||||
message: JsonRpcMessage,
|
||||
sessionId = ""
|
||||
): Promise<{ message?: JsonRpcMessage; sessionId?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), mcpTimeoutMs(server));
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
body: JSON.stringify(message),
|
||||
headers: mcpHttpHeaders(server, sessionId, true),
|
||||
method: "POST",
|
||||
signal: controller.signal
|
||||
});
|
||||
const nextSessionId = response.headers.get("mcp-session-id") ?? undefined;
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`MCP discovery request failed with HTTP ${response.status}${text.trim() ? `: ${text.trim().slice(0, 300)}` : ""}`);
|
||||
}
|
||||
const parsed = parseJsonRpcMessageFromResponse(text, message.id);
|
||||
if (parsed?.error) {
|
||||
throw new Error(jsonRpcErrorMessage(parsed.error));
|
||||
}
|
||||
return {
|
||||
message: parsed,
|
||||
sessionId: nextSessionId
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function writeStdioMessage(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
mode: GatewayMcpStdioServerConfig["stdioMessageMode"],
|
||||
message: JsonRpcMessage
|
||||
) {
|
||||
const body = JSON.stringify(message);
|
||||
if (mode === "newline-json") {
|
||||
child.stdin.write(`${body}\n`);
|
||||
return;
|
||||
}
|
||||
child.stdin.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
|
||||
}
|
||||
|
||||
function createStdioMessageReader(
|
||||
mode: GatewayMcpStdioServerConfig["stdioMessageMode"],
|
||||
onMessage: (message: JsonRpcMessage) => void
|
||||
): (chunk: Buffer) => void {
|
||||
if (mode === "newline-json") {
|
||||
let textBuffer = "";
|
||||
return (chunk: Buffer) => {
|
||||
textBuffer += chunk.toString("utf8");
|
||||
for (;;) {
|
||||
const newlineIndex = textBuffer.indexOf("\n");
|
||||
if (newlineIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const line = textBuffer.slice(0, newlineIndex).trim();
|
||||
textBuffer = textBuffer.slice(newlineIndex + 1);
|
||||
const message = parseJsonRpcMessage(line);
|
||||
if (message) {
|
||||
onMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
return (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
for (;;) {
|
||||
const delimiter = contentLengthHeaderDelimiter(buffer);
|
||||
if (!delimiter) {
|
||||
return;
|
||||
}
|
||||
const header = buffer.subarray(0, delimiter.index).toString("utf8");
|
||||
const match = /content-length:\s*(\d+)/i.exec(header);
|
||||
if (!match) {
|
||||
buffer = buffer.subarray(delimiter.index + delimiter.length);
|
||||
continue;
|
||||
}
|
||||
const length = Number(match[1]);
|
||||
const bodyStart = delimiter.index + delimiter.length;
|
||||
const bodyEnd = bodyStart + length;
|
||||
if (buffer.length < bodyEnd) {
|
||||
return;
|
||||
}
|
||||
const message = parseJsonRpcMessage(buffer.subarray(bodyStart, bodyEnd).toString("utf8"));
|
||||
buffer = buffer.subarray(bodyEnd);
|
||||
if (message) {
|
||||
onMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function contentLengthHeaderDelimiter(buffer: Buffer): { index: number; length: number } | undefined {
|
||||
const crlfIndex = buffer.indexOf("\r\n\r\n");
|
||||
if (crlfIndex >= 0) {
|
||||
return { index: crlfIndex, length: 4 };
|
||||
}
|
||||
const lfIndex = buffer.indexOf("\n\n");
|
||||
return lfIndex >= 0 ? { index: lfIndex, length: 2 } : undefined;
|
||||
}
|
||||
|
||||
function parseJsonRpcMessageFromResponse(text: string, expectedId: JsonRpcMessage["id"]): JsonRpcMessage | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.startsWith("event:") || trimmed.startsWith("data:")) {
|
||||
const messages: JsonRpcMessage[] = [];
|
||||
consumeSseEvents(trimmed, (event) => {
|
||||
const message = parseJsonRpcMessage(event.data);
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
});
|
||||
return findExpectedMessage(messages, expectedId);
|
||||
}
|
||||
const parsed = parseJsonValue(trimmed);
|
||||
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return findExpectedMessage(messages.filter(isJsonRpcMessage), expectedId);
|
||||
}
|
||||
|
||||
function findExpectedMessage(messages: JsonRpcMessage[], expectedId: JsonRpcMessage["id"]): JsonRpcMessage | undefined {
|
||||
if (expectedId === undefined || expectedId === null) {
|
||||
return messages[0];
|
||||
}
|
||||
return messages.find((message) => message.id === expectedId) ?? messages[0];
|
||||
}
|
||||
|
||||
function consumeSseEvents(buffer: string, onEvent: (event: SseEvent) => void): string {
|
||||
for (;;) {
|
||||
const delimiter = sseDelimiter(buffer);
|
||||
if (!delimiter) {
|
||||
return buffer;
|
||||
}
|
||||
const block = buffer.slice(0, delimiter.index);
|
||||
buffer = buffer.slice(delimiter.index + delimiter.length);
|
||||
const event = parseSseEvent(block);
|
||||
if (event.data) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sseDelimiter(buffer: string): { index: number; length: number } | undefined {
|
||||
const crlfIndex = buffer.indexOf("\r\n\r\n");
|
||||
if (crlfIndex >= 0) {
|
||||
return { index: crlfIndex, length: 4 };
|
||||
}
|
||||
const lfIndex = buffer.indexOf("\n\n");
|
||||
return lfIndex >= 0 ? { index: lfIndex, length: 2 } : undefined;
|
||||
}
|
||||
|
||||
function parseSseEvent(block: string): SseEvent {
|
||||
let event = "message";
|
||||
const data: string[] = [];
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim() || "message";
|
||||
} else if (line.startsWith("data:")) {
|
||||
data.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: data.join("\n"),
|
||||
event
|
||||
};
|
||||
}
|
||||
|
||||
function mcpHttpHeaders(server: GatewayMcpRemoteServerConfig, sessionId = "", includeBodyHeaders = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json, text/event-stream",
|
||||
...server.headers
|
||||
};
|
||||
if (includeBodyHeaders) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (server.protocolVersion) {
|
||||
headers["MCP-Protocol-Version"] = server.protocolVersion;
|
||||
}
|
||||
if (sessionId) {
|
||||
headers["Mcp-Session-Id"] = sessionId;
|
||||
}
|
||||
const apiKey = server.apiKey || (server.apiKeyEnv ? process.env[server.apiKeyEnv] : "");
|
||||
if (apiKey && !hasHeader(headers, "authorization")) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
||||
const normalized = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function mcpTimeoutMs(server: GatewayMcpServerConfig): number {
|
||||
return Math.max(1000, Math.min(600000, server.startupTimeoutMs || server.requestTimeoutMs || 30000));
|
||||
}
|
||||
|
||||
function normalizeToolList(value: unknown): GatewayMcpToolInfo[] {
|
||||
const tools = isRecord(value) && Array.isArray(value.tools) ? value.tools : [];
|
||||
return tools
|
||||
.filter(isRecord)
|
||||
.map((tool): GatewayMcpToolInfo | undefined => {
|
||||
const name = stringValue(tool.name);
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
const inputSchema = isRecord(tool.inputSchema) ? { ...tool.inputSchema } : undefined;
|
||||
return {
|
||||
...(stringValue(tool.description) ? { description: stringValue(tool.description) } : {}),
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
name
|
||||
};
|
||||
})
|
||||
.filter((tool): tool is GatewayMcpToolInfo => Boolean(tool));
|
||||
}
|
||||
|
||||
function parseJsonRpcMessage(value: string): JsonRpcMessage | undefined {
|
||||
return isJsonRpcMessage(parseJsonValue(value)) ? parseJsonValue(value) as JsonRpcMessage : undefined;
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonRpcMessage(value: unknown): value is JsonRpcMessage {
|
||||
return isRecord(value);
|
||||
}
|
||||
|
||||
function jsonRpcErrorMessage(error: unknown): string {
|
||||
if (isRecord(error)) {
|
||||
const message = stringValue(error.message);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return typeof error === "string" ? error : "MCP JSON-RPC request failed.";
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
|
@ -7,11 +7,16 @@ import type {
|
|||
AppInfo,
|
||||
ApiKeyConfig,
|
||||
ClaudeAppGatewayApplyResult,
|
||||
GatewayMcpServerConfig,
|
||||
GatewayMcpToolInfo,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayStatus,
|
||||
PluginDirectorySelection,
|
||||
PluginMarketplaceEntry,
|
||||
ProfileOpenCommandResult,
|
||||
ProfileOpenRequest,
|
||||
ProfileOpenResult,
|
||||
ProviderAccountTestRequest,
|
||||
ProviderAccountTestResult,
|
||||
ProviderIconDetectionRequest,
|
||||
|
|
@ -45,6 +50,7 @@ contextBridge.exposeInMainWorld("ccr", {
|
|||
getGatewayStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetGatewayStatus) as Promise<GatewayStatus>,
|
||||
getOnboardingFinished: () => ipcRenderer.invoke(IPC_CHANNELS.appGetOnboardingFinished) as Promise<boolean>,
|
||||
getPendingProviderDeepLinks: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPendingProviderDeepLinks) as Promise<ProviderDeepLinkRequest[]>,
|
||||
getProfileOpenCommand: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appGetProfileOpenCommand, request) as Promise<ProfileOpenCommandResult>,
|
||||
getProviderAccountSnapshots: (provider?: string) => ipcRenderer.invoke(IPC_CHANNELS.appGetProviderAccountSnapshots, provider) as Promise<ProviderAccountSnapshot[]>,
|
||||
getPluginMarketplace: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPluginMarketplace) as Promise<PluginMarketplaceEntry[]>,
|
||||
getProxyCertificateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise<ProxyCertificateStatus>,
|
||||
|
|
@ -53,8 +59,10 @@ contextBridge.exposeInMainWorld("ccr", {
|
|||
getRequestLogs: (filter?: RequestLogListFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise<RequestLogPage>,
|
||||
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,
|
||||
installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise<ProxyCertificateInstallResult>,
|
||||
listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise<GatewayMcpToolInfo[]>,
|
||||
openBuiltInBrowser: () => ipcRenderer.invoke(IPC_CHANNELS.appOpenBuiltInBrowser) as Promise<void>,
|
||||
openExternal: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.appOpenExternal, url) as Promise<void>,
|
||||
openProfile: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appOpenProfile, request) as Promise<ProfileOpenResult>,
|
||||
probeProvider: (request: GatewayProviderProbeRequest) => ipcRenderer.invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
|
||||
quitApp: () => ipcRenderer.invoke(IPC_CHANNELS.appQuit) as Promise<void>,
|
||||
revealProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appRevealProxyCertificate) as Promise<void>,
|
||||
|
|
|
|||
196
src/main/profile-launch-core.ts
Normal file
196
src/main/profile-launch-core.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app";
|
||||
|
||||
export type ProfileLaunchPlan = {
|
||||
args: string[];
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
profile: ProfileConfig;
|
||||
surface: ProfileOpenSurface;
|
||||
};
|
||||
|
||||
export function findProfileForOpen(config: Pick<AppConfig, "profile">, profileRef: string): ProfileConfig {
|
||||
const needle = profileRef.trim();
|
||||
if (!needle) {
|
||||
throw new Error("Profile name is required.");
|
||||
}
|
||||
|
||||
const profiles = config.profile.profiles.filter((profile) => profile.enabled);
|
||||
const exactId = profiles.find((profile) => profile.id === needle);
|
||||
if (exactId) {
|
||||
return exactId;
|
||||
}
|
||||
|
||||
const normalizedNeedle = normalizeLookupValue(needle);
|
||||
const matches = profiles.filter((profile) =>
|
||||
normalizeLookupValue(profile.name) === normalizedNeedle ||
|
||||
normalizeLookupValue(profile.id) === normalizedNeedle ||
|
||||
sanitizePathSegment(profile.name) === normalizedNeedle ||
|
||||
sanitizePathSegment(profile.id) === normalizedNeedle
|
||||
);
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Profile "${needle}" is ambiguous. Use the profile ID instead.`);
|
||||
}
|
||||
throw new Error(`Profile "${needle}" was not found or is disabled.`);
|
||||
}
|
||||
|
||||
export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] {
|
||||
const surface = normalizeProfileSurface(profile.surface);
|
||||
if (surface === "cli") {
|
||||
return ["cli"];
|
||||
}
|
||||
if (surface === "app") {
|
||||
return ["app"];
|
||||
}
|
||||
return ["cli", "app"];
|
||||
}
|
||||
|
||||
export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: ProfileOpenSurface): ProfileOpenSurface {
|
||||
const surfaces = profileOpenSurfaces(profile);
|
||||
if (surface) {
|
||||
if (!surfaces.includes(surface)) {
|
||||
throw new Error(`${profile.name || profile.id} does not support ${surface.toUpperCase()} opening.`);
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
return surfaces[0];
|
||||
}
|
||||
|
||||
export function profileOpenCommand(
|
||||
profile: ProfileConfig,
|
||||
surface: ProfileOpenSurface = "cli",
|
||||
command = "ccr",
|
||||
profileRef = profile.name?.trim() || profile.id
|
||||
): string {
|
||||
return [shellQuote(command), shellQuote(profileRef), ...(surface === "app" ? ["--app"] : [])].join(" ");
|
||||
}
|
||||
|
||||
export function buildProfileLaunchPlan(
|
||||
configDir: string,
|
||||
profile: ProfileConfig,
|
||||
surface: ProfileOpenSurface,
|
||||
extraArgs: string[] = []
|
||||
): ProfileLaunchPlan {
|
||||
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
|
||||
if (profile.agent === "codex") {
|
||||
return buildCodexLaunchPlan(configDir, profile, resolvedSurface, extraArgs);
|
||||
}
|
||||
return buildClaudeCodeLaunchPlan(configDir, profile, resolvedSurface, extraArgs);
|
||||
}
|
||||
|
||||
export function ccrManagedProfileDir(configDir: string, profile: ProfileConfig): string {
|
||||
const slug = sanitizePathSegment(profile.id || profile.name || profile.agent);
|
||||
const baseDir = path.join(configDir, "profiles", slug || "profile");
|
||||
return profile.scope === "custom" ? path.join(baseDir, "custom") : baseDir;
|
||||
}
|
||||
|
||||
export function resolveClaudeCodeSettingsFile(configDir: string, profile: ProfileConfig): string {
|
||||
if (isGeneratedProfileScope(profile.scope)) {
|
||||
return path.join(ccrManagedProfileDir(configDir, profile), "claude", "settings.json");
|
||||
}
|
||||
return resolveUserPath(profile.settingsFile || "~/.claude/settings.json");
|
||||
}
|
||||
|
||||
export function resolveCodexConfigFile(configDir: string, profile: ProfileConfig): string {
|
||||
if (isGeneratedProfileScope(profile.scope)) {
|
||||
return path.join(ccrManagedProfileDir(configDir, profile), "codex", "config.toml");
|
||||
}
|
||||
const codexHome = profile.codexHome?.trim();
|
||||
if (codexHome) {
|
||||
return path.join(resolveUserPath(codexHome), "config.toml");
|
||||
}
|
||||
return resolveUserPath(profile.configFile || "~/.codex/config.toml");
|
||||
}
|
||||
|
||||
function buildCodexLaunchPlan(
|
||||
configDir: string,
|
||||
profile: ProfileConfig,
|
||||
surface: ProfileOpenSurface,
|
||||
extraArgs: string[]
|
||||
): ProfileLaunchPlan {
|
||||
const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router";
|
||||
const launcher = path.join(configDir, "bin", codexMiddlewareFilename(profile, providerId));
|
||||
return {
|
||||
args: surface === "app" && extraArgs.length === 0 ? ["app"] : extraArgs,
|
||||
command: launcher,
|
||||
env: {
|
||||
CCR_PROFILE_SURFACE: surface
|
||||
},
|
||||
profile,
|
||||
surface
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeCodeLaunchPlan(
|
||||
configDir: string,
|
||||
profile: ProfileConfig,
|
||||
surface: ProfileOpenSurface,
|
||||
extraArgs: string[]
|
||||
): ProfileLaunchPlan {
|
||||
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);
|
||||
return {
|
||||
args: extraArgs,
|
||||
command,
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: path.dirname(settingsFile),
|
||||
CCR_PROFILE_SURFACE: surface
|
||||
},
|
||||
profile,
|
||||
surface
|
||||
};
|
||||
}
|
||||
|
||||
function codexMiddlewareFilename(profile: ProfileConfig, providerId: string): string {
|
||||
const slug = sanitizeCodexProviderId(profile.id || profile.name || providerId) || "codex";
|
||||
return process.platform === "win32"
|
||||
? `ccr-codex-cli-stdio-${slug}.cmd`
|
||||
: `ccr-codex-cli-stdio-${slug}`;
|
||||
}
|
||||
|
||||
function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli" | "app" {
|
||||
return value === "cli" || value === "app" ? value : "auto";
|
||||
}
|
||||
|
||||
function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean {
|
||||
return value === "ccr" || value === "custom";
|
||||
}
|
||||
|
||||
function resolveUserPath(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "~") {
|
||||
return homeDir();
|
||||
}
|
||||
if (trimmed.startsWith("~/")) {
|
||||
return path.join(homeDir(), trimmed.slice(2));
|
||||
}
|
||||
return path.resolve(trimmed || ".");
|
||||
}
|
||||
|
||||
function homeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || ".";
|
||||
}
|
||||
|
||||
function sanitizeCodexProviderId(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeLookupValue(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return /^[A-Za-z0-9_./:-]+$/.test(value)
|
||||
? value
|
||||
: `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
420
src/main/profile-launch-service.ts
Normal file
420
src/main/profile-launch-service.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult } from "../shared/app";
|
||||
import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service";
|
||||
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch";
|
||||
import { launchCodexAppProfile } from "./codex-app-launch";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { buildProfileLaunchPlan, findProfileForOpen, profileOpenCommand, resolveProfileOpenSurface } from "./profile-launch-core";
|
||||
import { applyProfileConfig } from "./profile-service";
|
||||
|
||||
const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>";
|
||||
const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<";
|
||||
|
||||
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenCommandResult> {
|
||||
await applyProfileConfig(config);
|
||||
const profile = findProfileForOpen(config, request.profileId);
|
||||
const surface = resolveProfileOpenSurface(profile, request.surface);
|
||||
ensureCcrCliLauncher();
|
||||
return {
|
||||
command: profileOpenCommand(profile, surface, "ccr", commandProfileRef(config, profile)),
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface
|
||||
};
|
||||
}
|
||||
|
||||
export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise<ProfileOpenResult> {
|
||||
const profile = findProfileForOpen(config, request.profileId);
|
||||
const surface = resolveProfileOpenSurface(profile, request.surface);
|
||||
if (profile.agent === "claude-code" && surface === "app") {
|
||||
return openClaudeAppProfile(config, profile);
|
||||
}
|
||||
if (profile.agent === "codex" && surface === "app") {
|
||||
return openCodexAppProfile(config, profile);
|
||||
}
|
||||
const plan = buildProfileLaunchPlan(CONFIGDIR, profile, surface);
|
||||
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
|
||||
throw new Error(`Profile launcher was not found: ${plan.command}. Re-save the profile and try again.`);
|
||||
}
|
||||
|
||||
const child = spawn(plan.command, plan.args, {
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...plan.env
|
||||
},
|
||||
stdio: "ignore"
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return {
|
||||
message: `Opened ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface
|
||||
};
|
||||
}
|
||||
|
||||
function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): ProfileOpenResult {
|
||||
launchCodexAppProfile(CONFIGDIR, profile, config);
|
||||
return {
|
||||
message: `Opened Codex App with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
|
||||
async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): Promise<ProfileOpenResult> {
|
||||
const token = findProfileApiKey(config, profile);
|
||||
if (!token) {
|
||||
throw new Error(`No CCR API key was found for profile "${profile.name || profile.id}". Re-save the profile and try again.`);
|
||||
}
|
||||
|
||||
const profileGatewayConfig = {
|
||||
...config,
|
||||
APIKEY: token,
|
||||
APIKEYS: [
|
||||
{
|
||||
createdAt: new Date().toISOString(),
|
||||
id: profileApiKeyId(profile),
|
||||
key: token,
|
||||
name: `Profile: ${profile.name?.trim() || profile.id || profile.agent}`
|
||||
}
|
||||
],
|
||||
Router: {
|
||||
...config.Router,
|
||||
...(profile.model.trim() ? { default: profile.model.trim() } : {})
|
||||
}
|
||||
};
|
||||
applyClaudeAppGatewayConfig(profileGatewayConfig);
|
||||
applyClaudeAppGatewayConfig(profileGatewayConfig, {
|
||||
backup: false,
|
||||
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile)
|
||||
});
|
||||
launchClaudeAppProfile(CONFIGDIR, profile);
|
||||
return {
|
||||
message: `Opened Claude App with ${profile.name || profile.id}.`,
|
||||
profileId: profile.id,
|
||||
profileName: profile.name,
|
||||
surface: "app"
|
||||
};
|
||||
}
|
||||
|
||||
function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
const name = profile.name?.trim();
|
||||
if (!name) {
|
||||
return profile.id;
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
const duplicateName = config.profile.profiles.some((item) =>
|
||||
item.enabled &&
|
||||
item.id !== profile.id &&
|
||||
item.name.trim().toLowerCase() === normalizedName
|
||||
);
|
||||
return duplicateName ? profile.id : name;
|
||||
}
|
||||
|
||||
function ensureCcrCliLauncher(): string {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const runtimeFile = path.join(binDir, "ccr-cli.js");
|
||||
const runtimeSource = findBundledCcrCliSource();
|
||||
writeFileIfChanged(runtimeFile, readFileSync(runtimeSource, "utf8"));
|
||||
chmodSafe(runtimeFile);
|
||||
|
||||
const launcherFile = path.join(binDir, process.platform === "win32" ? "ccr.cmd" : "ccr");
|
||||
const launcherContent = process.platform === "win32"
|
||||
? windowsCcrLauncher(runtimeFile)
|
||||
: posixCcrLauncher(runtimeFile);
|
||||
writeFileIfChanged(launcherFile, launcherContent);
|
||||
chmodSafe(launcherFile);
|
||||
ensureCcrBinOnPath(binDir);
|
||||
|
||||
return launcherFile;
|
||||
}
|
||||
|
||||
function findBundledCcrCliSource(): string {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
const candidates = [
|
||||
path.join(__dirname, "cli.js"),
|
||||
...(resourcesPath
|
||||
? [
|
||||
path.join(resourcesPath, "app.asar", "dist", "main", "cli.js"),
|
||||
path.join(resourcesPath, "app", "dist", "main", "cli.js")
|
||||
]
|
||||
: []),
|
||||
path.join(process.cwd(), "dist", "main", "cli.js")
|
||||
];
|
||||
const source = candidates.find((candidate) => existsSync(candidate));
|
||||
if (!source) {
|
||||
throw new Error(`CCR CLI runtime was not found. Rebuild or reinstall CCR and try again. Checked: ${candidates.join(", ")}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function posixCcrLauncher(runtimeFile: string): string {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
'if [ -n "$CCR_NODE_BIN" ]; then',
|
||||
` exec "$CCR_NODE_BIN" ${shQuote(runtimeFile)} "$@"`,
|
||||
"fi",
|
||||
"if command -v node >/dev/null 2>&1; then",
|
||||
` exec node ${shQuote(runtimeFile)} "$@"`,
|
||||
"fi",
|
||||
`ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"`
|
||||
].join("\n") + "\n";
|
||||
}
|
||||
|
||||
function windowsCcrLauncher(runtimeFile: string): string {
|
||||
return [
|
||||
"@echo off",
|
||||
"setlocal",
|
||||
`set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`,
|
||||
"if defined CCR_NODE_BIN (",
|
||||
' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*',
|
||||
" exit /b %ERRORLEVEL%",
|
||||
")",
|
||||
"where node >nul 2>nul",
|
||||
"if %ERRORLEVEL%==0 (",
|
||||
' node "%CCR_CLI_RUNTIME%" %*',
|
||||
" exit /b %ERRORLEVEL%",
|
||||
")",
|
||||
"set \"ELECTRON_RUN_AS_NODE=1\"",
|
||||
`${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`,
|
||||
"exit /b %ERRORLEVEL%"
|
||||
].join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
function writeFileIfChanged(file: string, content: string): void {
|
||||
if (existsSync(file) && readFileSync(file, "utf8") === content) {
|
||||
return;
|
||||
}
|
||||
writeFileSync(file, content, "utf8");
|
||||
}
|
||||
|
||||
function ensureCcrBinOnPath(binDir: string): void {
|
||||
prependProcessPath(binDir);
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
ensureWindowsUserPath(binDir);
|
||||
return;
|
||||
}
|
||||
ensurePosixShellPath(binDir);
|
||||
} catch (error) {
|
||||
console.warn(`[profile] Failed to persist ccr PATH: ${formatError(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function prependProcessPath(binDir: string): void {
|
||||
const pathKey = process.platform === "win32"
|
||||
? Object.keys(process.env).find((key) => key.toLowerCase() === "path") || "Path"
|
||||
: "PATH";
|
||||
const delimiter = path.delimiter;
|
||||
const currentPath = process.env[pathKey] || "";
|
||||
const segments = currentPath.split(delimiter).filter(Boolean);
|
||||
if (pathSegmentsInclude(segments, binDir)) {
|
||||
return;
|
||||
}
|
||||
process.env[pathKey] = [binDir, ...segments].join(delimiter);
|
||||
}
|
||||
|
||||
function ensureWindowsUserPath(binDir: string): void {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`$bin = ${powershellString(binDir)}`,
|
||||
"$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')",
|
||||
"$segments = @()",
|
||||
"if (-not [string]::IsNullOrWhiteSpace($userPath)) {",
|
||||
" $segments = $userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }",
|
||||
"}",
|
||||
"$expandedBin = [Environment]::ExpandEnvironmentVariables($bin).TrimEnd('\\\\')",
|
||||
"$expandedSegments = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\\\') }",
|
||||
"if ($expandedSegments -notcontains $expandedBin) {",
|
||||
" [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $segments) -join ';'), 'User')",
|
||||
"}"
|
||||
].join("\n");
|
||||
const result = spawnSync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script
|
||||
], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || `powershell.exe exited with ${result.status}`).trim());
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePosixShellPath(binDir: string): void {
|
||||
const shellName = path.basename(process.env.SHELL || "").toLowerCase();
|
||||
if (shellName.includes("fish")) {
|
||||
ensureFishPathBlock(path.join(os.homedir(), ".config", "fish", "conf.d", "ccr.fish"), binDir);
|
||||
return;
|
||||
}
|
||||
ensureShellRcPathBlock(preferredShellRcFile(shellName), binDir);
|
||||
}
|
||||
|
||||
function preferredShellRcFile(shellName = path.basename(process.env.SHELL || "").toLowerCase()): string {
|
||||
const home = os.homedir();
|
||||
if (shellName.includes("zsh")) {
|
||||
return path.join(home, ".zshrc");
|
||||
}
|
||||
if (shellName.includes("bash")) {
|
||||
if (process.platform === "darwin") {
|
||||
const bashProfile = path.join(home, ".bash_profile");
|
||||
return existsSync(bashProfile) ? bashProfile : path.join(home, ".bashrc");
|
||||
}
|
||||
return path.join(home, ".bashrc");
|
||||
}
|
||||
return path.join(home, ".profile");
|
||||
}
|
||||
|
||||
function pathSegmentsInclude(segments: string[], target: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
const normalizedTarget = normalizeWindowsPathSegment(target);
|
||||
return segments.some((segment) => normalizeWindowsPathSegment(segment) === normalizedTarget);
|
||||
}
|
||||
return segments.includes(target);
|
||||
}
|
||||
|
||||
function normalizeWindowsPathSegment(value: string): string {
|
||||
return value.trim().replace(/[\\/]+$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function ensureShellRcPathBlock(rcFile: string, binDir: string): void {
|
||||
mkdirSync(path.dirname(rcFile), { recursive: true });
|
||||
const source = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
|
||||
const block = shellRcPathBlock();
|
||||
const managedPattern = new RegExp(
|
||||
`\\n?${escapeRegExp(ccrPathBlockStart)}[\\s\\S]*?${escapeRegExp(ccrPathBlockEnd)}\\n?`,
|
||||
"m"
|
||||
);
|
||||
if (managedPattern.test(source)) {
|
||||
const next = ensureTrailingNewline(source.replace(managedPattern, `\n${block}\n`)).replace(/^\n+/, "");
|
||||
writeFileIfChanged(rcFile, next);
|
||||
return;
|
||||
}
|
||||
if (shellRcAlreadyAddsCcrBin(source, binDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separator = source.trim() ? (source.endsWith("\n") ? "\n" : "\n\n") : "";
|
||||
writeFileIfChanged(rcFile, `${source}${separator}${block}\n`);
|
||||
}
|
||||
|
||||
function shellRcPathBlock(): string {
|
||||
const binDir = "$HOME/.claude-code-router/bin";
|
||||
return [
|
||||
ccrPathBlockStart,
|
||||
"# Added by Claude Code Router. Enables the ccr command in new shells.",
|
||||
'case ":$PATH:" in',
|
||||
` *":${binDir}:"*) ;;`,
|
||||
` *) export PATH="${binDir}:$PATH" ;;`,
|
||||
"esac",
|
||||
ccrPathBlockEnd
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function ensureFishPathBlock(file: string, binDir: string): void {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
const source = existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
const block = fishPathBlock();
|
||||
const managedPattern = new RegExp(
|
||||
`\\n?${escapeRegExp(ccrPathBlockStart)}[\\s\\S]*?${escapeRegExp(ccrPathBlockEnd)}\\n?`,
|
||||
"m"
|
||||
);
|
||||
if (managedPattern.test(source)) {
|
||||
const next = ensureTrailingNewline(source.replace(managedPattern, `\n${block}\n`)).replace(/^\n+/, "");
|
||||
writeFileIfChanged(file, next);
|
||||
return;
|
||||
}
|
||||
if (shellRcAlreadyAddsCcrBin(source, binDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separator = source.trim() ? (source.endsWith("\n") ? "\n" : "\n\n") : "";
|
||||
writeFileIfChanged(file, `${source}${separator}${block}\n`);
|
||||
}
|
||||
|
||||
function fishPathBlock(): string {
|
||||
return [
|
||||
ccrPathBlockStart,
|
||||
"# Added by Claude Code Router. Enables the ccr command in new shells.",
|
||||
'set -l ccr_bin "$HOME/.claude-code-router/bin"',
|
||||
"if not contains $ccr_bin $PATH",
|
||||
" set -gx PATH $ccr_bin $PATH",
|
||||
"end",
|
||||
ccrPathBlockEnd
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function shellRcAlreadyAddsCcrBin(source: string, binDir: string): boolean {
|
||||
return source.includes("$HOME/.claude-code-router/bin") ||
|
||||
source.includes("~/.claude-code-router/bin") ||
|
||||
source.includes(binDir);
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(value: string): string {
|
||||
return value.endsWith("\n") ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
function chmodSafe(file: string): void {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
chmodSync(file, 0o755);
|
||||
} catch {
|
||||
// The launcher can still be shown; execution will surface the filesystem error.
|
||||
}
|
||||
}
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function cmdQuote(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function cmdEnvValue(value: string): string {
|
||||
return value.replace(/%/g, "%%").replace(/"/g, '""');
|
||||
}
|
||||
|
||||
function powershellString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function findProfileApiKey(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
const keyId = profileApiKeyId(profile);
|
||||
const key = config.APIKEYS.find((apiKey) => apiKey.id === keyId)?.key.trim();
|
||||
return key || config.APIKEYS.find((apiKey) => apiKey.key.trim())?.key.trim() || config.APIKEY.trim();
|
||||
}
|
||||
|
||||
function profileApiKeyId(profile: ReturnType<typeof findProfileForOpen>): string {
|
||||
return `profile:${sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "profile"}`;
|
||||
}
|
||||
|
||||
function sanitizeProfilePathSegment(value: string): string {
|
||||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
|||
import type { ApiKeyConfig, AppConfig, ProfileApplyResult, ProfileClientApplyStatus, ProfileConfig } from "../shared/app";
|
||||
import { replacePersistedApiKeys } from "./api-key-store";
|
||||
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
|
||||
import { codexModelCatalogBase64 } from "./codex-model-catalog";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import { normalizeRouteSelector } from "./gateway/claude-code-router-plugin";
|
||||
|
||||
|
|
@ -126,6 +127,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
|
|||
? writeCodexCliMiddleware(profile, {
|
||||
configFormat,
|
||||
configFile,
|
||||
modelCatalogBase64: codexModelCatalogBase64(config, model),
|
||||
model,
|
||||
providerId
|
||||
})
|
||||
|
|
@ -388,6 +390,7 @@ function writeCodexCliMiddleware(
|
|||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
modelCatalogBase64: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
|
@ -429,6 +432,7 @@ function codexMiddlewareShellScript(
|
|||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
modelCatalogBase64: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
},
|
||||
|
|
@ -446,17 +450,21 @@ function codexMiddlewareShellScript(
|
|||
`export CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
|
||||
`export CCR_CODEX_PROFILE=${shellQuote(values.providerId)}`,
|
||||
`export CCR_CODEX_MODEL=${shellQuote(values.model)}`,
|
||||
`export CCR_CODEX_MODEL_CATALOG_B64=${shellQuote(values.modelCatalogBase64)}`,
|
||||
`export CCR_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`,
|
||||
`export CCR_CODEX_REMOTE_FRONTEND_MODE=${shellQuote(remoteFrontendMode)}`,
|
||||
`export CCR_CODEX_PROFILE_CONFIG_FORMAT=${shellQuote(values.configFormat)}`,
|
||||
`export CCR_PROFILE_SCOPE=${shellQuote(normalizeProfileScope(profile.scope))}`,
|
||||
`export CCR_PROFILE_SURFACE=${shellQuote(surface)}`,
|
||||
`: "\${CCR_PROFILE_SURFACE:=${surface}}"`,
|
||||
"export CCR_PROFILE_SURFACE",
|
||||
`export CODEXL_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`,
|
||||
`export CODEXL_CODEX_PROFILE=${shellQuote(values.providerId)}`,
|
||||
`export CODEXL_CODEX_MODEL_PROVIDER=${shellQuote(values.providerId)}`,
|
||||
`export CODEXL_CODEX_WORKSPACE_NAME=${shellQuote(profile.name || values.providerId)}`,
|
||||
`export CODEXL_CODEX_CORE_MODE=${shellQuote(remoteFrontendMode)}`,
|
||||
`export CODEXL_CODEX_PROFILE_CONFIG_FORMAT=${shellQuote(values.configFormat)}`,
|
||||
": \"${CODEXL_PROFILE_SURFACE:=$CCR_PROFILE_SURFACE}\"",
|
||||
"export CODEXL_PROFILE_SURFACE",
|
||||
"NODE_BIN=${CCR_NODE_BIN:-node}",
|
||||
`exec "$NODE_BIN" ${shellQuote(runtimeFile)} "$@"`,
|
||||
""
|
||||
|
|
@ -468,6 +476,7 @@ function codexMiddlewareCmdScript(
|
|||
values: {
|
||||
configFormat: "legacy" | "separate_profile_files";
|
||||
configFile: string;
|
||||
modelCatalogBase64: string;
|
||||
model: string;
|
||||
providerId: string;
|
||||
},
|
||||
|
|
@ -487,17 +496,19 @@ function codexMiddlewareCmdScript(
|
|||
`set "CCR_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`,
|
||||
`set "CCR_CODEX_PROFILE=${providerId}"`,
|
||||
`set "CCR_CODEX_MODEL=${values.model.replace(/"/g, '\\"')}"`,
|
||||
`set "CCR_CODEX_MODEL_CATALOG_B64=${values.modelCatalogBase64}"`,
|
||||
`set "CCR_CODEX_MODEL_PROVIDER=${providerId}"`,
|
||||
`set "CCR_CODEX_REMOTE_FRONTEND_MODE=${remoteFrontendMode}"`,
|
||||
`set "CCR_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`,
|
||||
`set "CCR_PROFILE_SCOPE=${normalizeProfileScope(profile.scope)}"`,
|
||||
`set "CCR_PROFILE_SURFACE=${surface}"`,
|
||||
`if not defined CCR_PROFILE_SURFACE set "CCR_PROFILE_SURFACE=${surface}"`,
|
||||
`set "CODEXL_REAL_CODEX_CLI_PATH=${codexCli.replace(/"/g, '\\"')}"`,
|
||||
`set "CODEXL_CODEX_PROFILE=${providerId}"`,
|
||||
`set "CODEXL_CODEX_MODEL_PROVIDER=${providerId}"`,
|
||||
`set "CODEXL_CODEX_WORKSPACE_NAME=${workspaceName}"`,
|
||||
`set "CODEXL_CODEX_CORE_MODE=${remoteFrontendMode}"`,
|
||||
`set "CODEXL_CODEX_PROFILE_CONFIG_FORMAT=${values.configFormat}"`,
|
||||
"if not defined CODEXL_PROFILE_SURFACE set \"CODEXL_PROFILE_SURFACE=%CCR_PROFILE_SURFACE%\"",
|
||||
"if not defined CCR_NODE_BIN set \"CCR_NODE_BIN=node\"",
|
||||
"if \"%~1\"==\"\" (",
|
||||
` "%CCR_NODE_BIN%" "${runtimeFile.replace(/"/g, '\\"')}"`,
|
||||
|
|
@ -661,8 +672,8 @@ function sanitizeProfilePathSegment(value: string): string {
|
|||
return value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeCodexConfigFormat(value: ProfileConfig["configFormat"]): "legacy" | "separate_profile_files" {
|
||||
return value === "separate_profile_files" ? "separate_profile_files" : "legacy";
|
||||
function normalizeCodexConfigFormat(_value: ProfileConfig["configFormat"]): "legacy" | "separate_profile_files" {
|
||||
return "separate_profile_files";
|
||||
}
|
||||
|
||||
function normalizeCodexRemoteFrontendMode(value: ProfileConfig["remoteFrontendMode"]): "app" | "cli" | "claude-code" {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export type RequestLogRawTraceUpdateInput = {
|
|||
requestBodyTruncated?: boolean;
|
||||
requestHeaders?: HeaderRecord;
|
||||
requestId: string;
|
||||
isStream?: boolean;
|
||||
responseBodyContentType?: string;
|
||||
responseBodyText?: string;
|
||||
responseBodyTruncated?: boolean;
|
||||
|
|
@ -99,6 +100,7 @@ type StoredRequestLogEntry = {
|
|||
error: string;
|
||||
id: number;
|
||||
inputTokens: number;
|
||||
isStream: boolean;
|
||||
method: string;
|
||||
model: string;
|
||||
ok: boolean;
|
||||
|
|
@ -217,6 +219,14 @@ class RequestLogStore {
|
|||
headerValue(responseHeaders, "content-type"),
|
||||
Boolean(input.responseBodyTruncated)
|
||||
);
|
||||
const isStream = inferRequestLogIsStream({
|
||||
path: input.path,
|
||||
requestBodyText: requestBody.encoding === "utf8" ? requestBody.text : undefined,
|
||||
requestHeaders,
|
||||
responseBodyContentType: responseBody.contentType,
|
||||
responseHeaders,
|
||||
url: input.url
|
||||
});
|
||||
|
||||
const statement = database.prepare(`
|
||||
INSERT INTO request_logs (
|
||||
|
|
@ -229,6 +239,7 @@ class RequestLogStore {
|
|||
url,
|
||||
provider,
|
||||
model,
|
||||
is_stream,
|
||||
status_code,
|
||||
ok,
|
||||
duration_ms,
|
||||
|
|
@ -251,7 +262,7 @@ class RequestLogStore {
|
|||
response_body_size_bytes,
|
||||
response_body_truncated,
|
||||
error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
try {
|
||||
|
|
@ -265,6 +276,7 @@ class RequestLogStore {
|
|||
input.url,
|
||||
providerName,
|
||||
model,
|
||||
isStream ? 1 : 0,
|
||||
normalizeCount(input.statusCode),
|
||||
isSuccessStatus(input.statusCode, input.error) ? 1 : 0,
|
||||
normalizeCount(input.durationMs),
|
||||
|
|
@ -340,6 +352,25 @@ class RequestLogStore {
|
|||
if (responseHeaders) {
|
||||
pushValue("response_headers", JSON.stringify(responseHeaders));
|
||||
}
|
||||
const hasStreamSignal =
|
||||
input.isStream !== undefined ||
|
||||
input.path !== undefined ||
|
||||
input.url !== undefined ||
|
||||
input.requestBodyText !== undefined ||
|
||||
input.requestHeaders !== undefined ||
|
||||
input.responseBodyContentType !== undefined ||
|
||||
input.responseHeaders !== undefined;
|
||||
if (hasStreamSignal) {
|
||||
pushValue("is_stream", inferRequestLogIsStream({
|
||||
path,
|
||||
requestBodyText: input.requestBodyText,
|
||||
requestHeaders: mergedRequestHeaders,
|
||||
responseBodyContentType: input.responseBodyContentType,
|
||||
responseHeaders,
|
||||
responseWasStream: input.isStream,
|
||||
url
|
||||
}) ? 1 : 0);
|
||||
}
|
||||
if (input.requestBodyText !== undefined) {
|
||||
const requestBody = bodyFromText(
|
||||
input.requestBodyText,
|
||||
|
|
@ -390,6 +421,7 @@ class RequestLogStore {
|
|||
url,
|
||||
provider,
|
||||
model,
|
||||
is_stream,
|
||||
status_code,
|
||||
ok,
|
||||
duration_ms,
|
||||
|
|
@ -452,6 +484,7 @@ class RequestLogStore {
|
|||
url,
|
||||
provider,
|
||||
model,
|
||||
is_stream,
|
||||
status_code,
|
||||
ok,
|
||||
duration_ms,
|
||||
|
|
@ -549,6 +582,7 @@ class RequestLogStore {
|
|||
url TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL DEFAULT 'unknown',
|
||||
model TEXT NOT NULL DEFAULT 'unknown',
|
||||
is_stream INTEGER NOT NULL DEFAULT 0,
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
ok INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -574,6 +608,7 @@ class RequestLogStore {
|
|||
);
|
||||
`);
|
||||
ensureRequestLogSchema(database);
|
||||
backfillRequestLogStreamFlags(database);
|
||||
|
||||
this.database = database;
|
||||
this.pruneOldRequestLogs(database);
|
||||
|
|
@ -1577,6 +1612,7 @@ function ensureRequestLogSchema(database: SqlDatabase): void {
|
|||
addColumn("url", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("provider", "TEXT NOT NULL DEFAULT 'unknown'");
|
||||
addColumn("model", "TEXT NOT NULL DEFAULT 'unknown'");
|
||||
addColumn("is_stream", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("status_code", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("ok", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("duration_ms", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
|
@ -1607,6 +1643,117 @@ function ensureRequestLogSchema(database: SqlDatabase): void {
|
|||
database.run("CREATE INDEX IF NOT EXISTS request_logs_status_idx ON request_logs(ok, status_code)");
|
||||
}
|
||||
|
||||
function backfillRequestLogStreamFlags(database: SqlDatabase): void {
|
||||
const rows = readRows(
|
||||
database.exec(
|
||||
`
|
||||
SELECT
|
||||
rowid AS id,
|
||||
path,
|
||||
url,
|
||||
request_headers,
|
||||
response_headers,
|
||||
request_body_text,
|
||||
request_body_encoding,
|
||||
response_body_content_type
|
||||
FROM request_logs
|
||||
WHERE source_usage_id IS NULL
|
||||
AND is_stream = 0
|
||||
AND (
|
||||
path LIKE '%stream%' OR
|
||||
url LIKE '%stream%' OR
|
||||
request_body_text LIKE '%stream%' OR
|
||||
response_headers LIKE '%event-stream%' OR
|
||||
response_body_content_type LIKE '%event-stream%'
|
||||
)
|
||||
`
|
||||
)[0]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statement = database.prepare("UPDATE request_logs SET is_stream = 1 WHERE rowid = ?");
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const requestBodyText = String(row.request_body_encoding ?? "utf8") === "utf8"
|
||||
? String(row.request_body_text ?? "")
|
||||
: undefined;
|
||||
const isStream = inferRequestLogIsStream({
|
||||
path: String(row.path ?? ""),
|
||||
requestBodyText,
|
||||
requestHeaders: parseHeaderJson(row.request_headers),
|
||||
responseBodyContentType: String(row.response_body_content_type ?? ""),
|
||||
responseHeaders: parseHeaderJson(row.response_headers),
|
||||
url: String(row.url ?? "")
|
||||
});
|
||||
if (isStream) {
|
||||
statement.run([normalizeCount(row.id)]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
statement.free();
|
||||
}
|
||||
}
|
||||
|
||||
type RequestLogStreamInferenceInput = {
|
||||
path?: string;
|
||||
requestBodyText?: string;
|
||||
requestHeaders?: Record<string, string | string[]>;
|
||||
responseBodyContentType?: string;
|
||||
responseHeaders?: Record<string, string | string[]>;
|
||||
responseWasStream?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
function inferRequestLogIsStream(input: RequestLogStreamInferenceInput): boolean {
|
||||
return Boolean(
|
||||
input.responseWasStream ||
|
||||
requestPathLooksStreaming(input.path) ||
|
||||
requestPathLooksStreaming(input.url) ||
|
||||
contentTypeLooksStreaming(input.responseBodyContentType) ||
|
||||
contentTypeLooksStreaming(headerValue(input.responseHeaders ?? {}, "content-type")) ||
|
||||
contentTypeLooksStreaming(headerValue(input.requestHeaders ?? {}, "accept")) ||
|
||||
requestBodyHasStreamFlag(input.requestBodyText)
|
||||
);
|
||||
}
|
||||
|
||||
function requestPathLooksStreaming(value: string | undefined): boolean {
|
||||
const normalized = value?.toLowerCase() ?? "";
|
||||
return normalized.includes(":streamgeneratecontent");
|
||||
}
|
||||
|
||||
function contentTypeLooksStreaming(value: string | undefined): boolean {
|
||||
const normalized = value?.toLowerCase() ?? "";
|
||||
return normalized.includes("text/event-stream") || normalized.includes("application/x-ndjson");
|
||||
}
|
||||
|
||||
function requestBodyHasStreamFlag(text: string | undefined): boolean {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseJson(trimmed);
|
||||
return payloadHasStreamFlag(parsed);
|
||||
}
|
||||
|
||||
function payloadHasStreamFlag(value: unknown, depth = 0): boolean {
|
||||
if (depth > 3) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => payloadHasStreamFlag(item, depth + 1));
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.stream === true || value.stream === "true") {
|
||||
return true;
|
||||
}
|
||||
return Object.values(value).some((item) => payloadHasStreamFlag(item, depth + 1));
|
||||
}
|
||||
|
||||
function buildLogWhereClause(filter: RequestLogListFilter): { params: SqlValue[]; where: string } {
|
||||
const where: string[] = ["source_usage_id IS NULL", "path NOT LIKE ?"];
|
||||
const params: SqlValue[] = ["%/count_tokens%"];
|
||||
|
|
@ -1667,6 +1814,18 @@ function firstNumber(result: QueryExecResult | undefined, column: string): numbe
|
|||
|
||||
function toRequestLogEntry(row: Record<string, SqlValue>): StoredRequestLogEntry {
|
||||
const costUsd = asFloat(row.cost_usd);
|
||||
const requestBody = bodyFromRow(row, "request") ?? emptyBody();
|
||||
const responseBody = bodyFromRow(row, "response");
|
||||
const requestHeaders = parseHeaderJson(row.request_headers);
|
||||
const responseHeaders = parseHeaderJson(row.response_headers);
|
||||
const isStream = normalizeCount(row.is_stream) === 1 || inferRequestLogIsStream({
|
||||
path: String(row.path ?? ""),
|
||||
requestBodyText: requestBody.encoding === "utf8" ? requestBody.text : undefined,
|
||||
requestHeaders,
|
||||
responseBodyContentType: responseBody?.contentType,
|
||||
responseHeaders,
|
||||
url: String(row.url ?? "")
|
||||
});
|
||||
return {
|
||||
cacheReadTokens: normalizeCount(row.cache_read_tokens),
|
||||
cacheWriteTokens: normalizeCount(row.cache_write_tokens),
|
||||
|
|
@ -1678,17 +1837,18 @@ function toRequestLogEntry(row: Record<string, SqlValue>): StoredRequestLogEntry
|
|||
error: String(row.error ?? ""),
|
||||
id: normalizeCount(row.id),
|
||||
inputTokens: normalizeCount(row.input_tokens),
|
||||
isStream,
|
||||
method: String(row.method ?? ""),
|
||||
model: normalizeLabel(String(row.model ?? ""), "unknown"),
|
||||
ok: normalizeCount(row.ok) === 1,
|
||||
outputTokens: normalizeCount(row.output_tokens),
|
||||
path: normalizeLabel(String(row.path ?? ""), "/"),
|
||||
provider: normalizeLabel(String(row.provider ?? ""), "unknown"),
|
||||
requestBody: bodyFromRow(row, "request") ?? emptyBody(),
|
||||
requestHeaders: parseHeaderJson(row.request_headers),
|
||||
requestBody,
|
||||
requestHeaders,
|
||||
requestId: String(row.request_id ?? ""),
|
||||
responseBody: bodyFromRow(row, "response"),
|
||||
responseHeaders: parseHeaderJson(row.response_headers),
|
||||
responseBody,
|
||||
responseHeaders,
|
||||
statusCode: normalizeCount(row.status_code),
|
||||
totalTokens: normalizeCount(row.total_tokens),
|
||||
url: String(row.url ?? "")
|
||||
|
|
|
|||
|
|
@ -10,16 +10,17 @@ import {
|
|||
ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig,
|
||||
fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage,
|
||||
fallbackUsageStats, findProviderDeepLinkReplacementIndex, firstProviderConnectivityModel, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
|
||||
fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile,
|
||||
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
|
||||
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
|
||||
LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists,
|
||||
navigation, NavigationId, normalizeApiKeys, normalizeConfig, normalizeLanguagePreference, normalizeOverviewWidgets,
|
||||
normalizeProfileItem, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference,
|
||||
normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayIconPreference,
|
||||
normalizeTrayProgressTargetTokens, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingStepId, onboardingStepOrder,
|
||||
OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft,
|
||||
persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft,
|
||||
probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, ProfileConfig, profileConfigFromDraft, providerAccountApiKeySafetyIssue,
|
||||
ProviderAccountSnapshot, providerApiKeySafetyIssue, providerAutoProbeDelayMs, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, providerAutoProbeDelayMs, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates,
|
||||
providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage,
|
||||
ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage,
|
||||
ResolvedTheme, resolvePluginInstallPlan, RouterRule, ServerActionBusy,
|
||||
|
|
@ -32,6 +33,14 @@ import {
|
|||
AppDialogStack, LightToast, MainLayout, OnboardingLayout
|
||||
} from "./components";
|
||||
|
||||
type ProfileOpenDialogState = {
|
||||
busy?: "" | "cli" | "app";
|
||||
command?: string;
|
||||
error?: string;
|
||||
mode: "choose" | "cli";
|
||||
profile: ProfileConfig;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [activeView, setActiveView] = useState<ViewId>("onboarding");
|
||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(() => getDefaultOnboardingStep(fallbackConfig));
|
||||
|
|
@ -53,6 +62,8 @@ function App() {
|
|||
const [profileDraft, setProfileDraft] = useState<AddProfileDraft>(() => createProfileDraft());
|
||||
const [profileEditDraft, setProfileEditDraft] = useState<AddProfileDraft>(() => createProfileDraft());
|
||||
const [profileEditIndex, setProfileEditIndex] = useState<number>();
|
||||
const [profileOpenDialog, setProfileOpenDialog] = useState<ProfileOpenDialogState>();
|
||||
const [profileSubmitBusy, setProfileSubmitBusy] = useState<"" | "add" | "edit">("");
|
||||
const [apiKeyAddOpen, setApiKeyAddOpen] = useState(false);
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState<AddApiKeyDraft>(() => createApiKeyDraft());
|
||||
const [apiKeyEditDraft, setApiKeyEditDraft] = useState<AddApiKeyDraft>(() => createApiKeyDraft());
|
||||
|
|
@ -1125,7 +1136,7 @@ function App() {
|
|||
return;
|
||||
}
|
||||
setVirtualModelEditIndex(index);
|
||||
setVirtualModelDraft(createVirtualModelDraftFromProfile(profile));
|
||||
setVirtualModelDraft(createVirtualModelDraftFromProfile(profile, draftConfig));
|
||||
setVirtualModelError("");
|
||||
setVirtualModelDialogOpen(true);
|
||||
}
|
||||
|
|
@ -1143,13 +1154,33 @@ function App() {
|
|||
|
||||
updateConfig((config) => {
|
||||
const values = [...(config.virtualModelProfiles ?? [])];
|
||||
const previousProfile = virtualModelEditIndex === undefined ? undefined : values[virtualModelEditIndex];
|
||||
const profile = virtualModelProfileFromDraft(virtualModelDraft, values, virtualModelEditIndex);
|
||||
const previousMcpServerName = previousProfile ? fusionCustomToolConfigFromProfile(previousProfile)?.mcpServerName : undefined;
|
||||
if (virtualModelEditIndex === undefined) {
|
||||
values.push(profile);
|
||||
} else {
|
||||
values[virtualModelEditIndex] = profile;
|
||||
}
|
||||
config.virtualModelProfiles = values;
|
||||
const existingMcpServers = [...(config.agent?.mcpServers ?? [])];
|
||||
const replacementIndex = previousMcpServerName
|
||||
? existingMcpServers.findIndex((server) => server.name === previousMcpServerName)
|
||||
: existingMcpServers.findIndex((server) => server.name === virtualModelDraft.customMcpServer.name.trim());
|
||||
const customMcpServer = fusionCustomMcpServerFromDraft(virtualModelDraft, existingMcpServers, replacementIndex >= 0 ? replacementIndex : undefined);
|
||||
if (customMcpServer) {
|
||||
if (replacementIndex >= 0) {
|
||||
existingMcpServers[replacementIndex] = customMcpServer;
|
||||
} else {
|
||||
existingMcpServers.push(customMcpServer);
|
||||
}
|
||||
} else if (previousMcpServerName && replacementIndex >= 0) {
|
||||
existingMcpServers.splice(replacementIndex, 1);
|
||||
}
|
||||
config.agent = {
|
||||
...(config.agent ?? { mcpServers: [] }),
|
||||
mcpServers: existingMcpServers
|
||||
};
|
||||
return config;
|
||||
});
|
||||
setVirtualModelEditIndex(undefined);
|
||||
|
|
@ -1550,29 +1581,6 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function openBuiltInBrowser() {
|
||||
if (!window.ccr?.openBuiltInBrowser) {
|
||||
setActionError("APPs are available in the Electron app.");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionBusy("browser");
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
try {
|
||||
if (dirty && !(await persistConfig(draftConfig, setActionError))) {
|
||||
return;
|
||||
}
|
||||
await window.ccr.openBuiltInBrowser();
|
||||
const status = await window.ccr.getProxyStatus();
|
||||
setProxyStatus(status);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOnboarding() {
|
||||
if (window.ccr) {
|
||||
try {
|
||||
|
|
@ -1586,10 +1594,6 @@ function App() {
|
|||
}
|
||||
|
||||
function selectNavigationItem(id: NavigationId) {
|
||||
if (id === "browser") {
|
||||
void openBuiltInBrowser();
|
||||
return;
|
||||
}
|
||||
setActiveView(id);
|
||||
}
|
||||
|
||||
|
|
@ -1795,6 +1799,76 @@ function App() {
|
|||
setProfileActionError("");
|
||||
}
|
||||
|
||||
function openProfileDialog(index: number) {
|
||||
const profile = draftConfig.profile.profiles[index];
|
||||
if (!profile?.enabled || normalizeProfileScope(profile.scope) !== "ccr") {
|
||||
return;
|
||||
}
|
||||
setProfileActionError("");
|
||||
const surfaces = profileOpenSurfaces(profile);
|
||||
if (surfaces.length > 1) {
|
||||
void showProfileCliCommand(profile, "choose");
|
||||
return;
|
||||
}
|
||||
if (surfaces[0] === "app") {
|
||||
void openProfileApp(profile);
|
||||
return;
|
||||
}
|
||||
void showProfileCliCommand(profile);
|
||||
}
|
||||
|
||||
async function showProfileCliCommand(profile: ProfileConfig, mode: "choose" | "cli" = "cli") {
|
||||
const fallbackCommand = profileOpenCommandFallback(profile, "cli");
|
||||
setProfileOpenDialog({ busy: "cli", command: fallbackCommand, mode, profile });
|
||||
if (!(await persistConfig(draftConfig, setProfileActionError))) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", error: profileActionError || "Failed to save profile before opening." }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
if (!window.ccr?.getProfileOpenCommand) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id ? { ...current, busy: "" } : current);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await window.ccr.getProfileOpenCommand({ profileId: profile.id, surface: "cli" });
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", command: result.command, error: "" }
|
||||
: current);
|
||||
} catch (error) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", error: error instanceof Error ? error.message : String(error) }
|
||||
: current);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProfileApp(profile: ProfileConfig) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "app", error: "" }
|
||||
: { busy: "app", mode: "choose", profile });
|
||||
if (!(await persistConfig(draftConfig, setProfileActionError))) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", error: profileActionError || "Failed to save profile before opening." }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
if (!window.ccr?.openProfile) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", error: "Profile opening is only available in the Electron app." }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await window.ccr.openProfile({ profileId: profile.id, surface: "app" });
|
||||
setProfileOpenDialog(undefined);
|
||||
showToast(result.message);
|
||||
} catch (error) {
|
||||
setProfileOpenDialog((current) => current?.profile.id === profile.id
|
||||
? { ...current, busy: "", error: error instanceof Error ? error.message : String(error) }
|
||||
: current);
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfileDraft(patch: Partial<AddProfileDraft>) {
|
||||
setProfileDraft((current) => {
|
||||
const next = { ...current, ...patch };
|
||||
|
|
@ -1825,13 +1899,17 @@ function App() {
|
|||
setProfileActionError("");
|
||||
}
|
||||
|
||||
async function submitProfileDraft(): Promise<boolean> {
|
||||
if (!canSubmitProfile) {
|
||||
setProfileActionError("Profile name, required target settings, and environment variable keys are required.");
|
||||
return false;
|
||||
}
|
||||
const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles);
|
||||
setProfileAgentTab(profile.agent);
|
||||
async function submitProfileDraft(): Promise<boolean> {
|
||||
if (profileSubmitBusy) {
|
||||
return false;
|
||||
}
|
||||
if (!canSubmitProfile) {
|
||||
setProfileActionError("Profile name, required target settings, and environment variable keys are required.");
|
||||
return false;
|
||||
}
|
||||
setProfileSubmitBusy("add");
|
||||
const profile = profileConfigFromDraft(profileDraft, draftConfig.profile.profiles);
|
||||
setProfileAgentTab(profile.agent);
|
||||
const next = buildConfigUpdate((config) => ({
|
||||
...config,
|
||||
profile: {
|
||||
|
|
@ -1841,31 +1919,40 @@ function App() {
|
|||
}
|
||||
}));
|
||||
setConfigDraft(next);
|
||||
if (!(await persistConfig(next, setProfileActionError))) {
|
||||
return false;
|
||||
}
|
||||
setProfileAddOpen(false);
|
||||
setProfileDraft(createProfileDraft());
|
||||
setProfileActionError("");
|
||||
if (activeView === "onboarding") {
|
||||
setOnboardingStep("enter");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!(await persistConfig(next, setProfileActionError))) {
|
||||
return false;
|
||||
}
|
||||
setProfileAddOpen(false);
|
||||
setProfileDraft(createProfileDraft());
|
||||
setProfileActionError("");
|
||||
if (activeView === "onboarding") {
|
||||
setOnboardingStep("enter");
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
setProfileSubmitBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfileEditDraft(): Promise<boolean> {
|
||||
if (profileEditIndex === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (!canSubmitProfileEdit) {
|
||||
setProfileActionError("Profile name, required target settings, and environment variable keys are required.");
|
||||
return false;
|
||||
}
|
||||
const currentProfile = draftConfig.profile.profiles[profileEditIndex];
|
||||
if (!currentProfile) {
|
||||
setProfileActionError("Profile no longer exists.");
|
||||
return false;
|
||||
}
|
||||
async function submitProfileEditDraft(): Promise<boolean> {
|
||||
if (profileSubmitBusy) {
|
||||
return false;
|
||||
}
|
||||
if (profileEditIndex === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (!canSubmitProfileEdit) {
|
||||
setProfileActionError("Profile name, required target settings, and environment variable keys are required.");
|
||||
return false;
|
||||
}
|
||||
setProfileSubmitBusy("edit");
|
||||
const currentProfile = draftConfig.profile.profiles[profileEditIndex];
|
||||
if (!currentProfile) {
|
||||
setProfileSubmitBusy("");
|
||||
setProfileActionError("Profile no longer exists.");
|
||||
return false;
|
||||
}
|
||||
const nextProfile = profileConfigFromDraft(profileEditDraft, draftConfig.profile.profiles, currentProfile);
|
||||
setProfileAgentTab(nextProfile.agent);
|
||||
const next = buildConfigUpdate((config) => {
|
||||
|
|
@ -1880,14 +1967,18 @@ function App() {
|
|||
};
|
||||
});
|
||||
setConfigDraft(next);
|
||||
if (!(await persistConfig(next, setProfileActionError))) {
|
||||
return false;
|
||||
}
|
||||
setProfileEditIndex(undefined);
|
||||
setProfileEditDraft(createProfileDraft());
|
||||
setProfileActionError("");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!(await persistConfig(next, setProfileActionError))) {
|
||||
return false;
|
||||
}
|
||||
setProfileEditIndex(undefined);
|
||||
setProfileEditDraft(createProfileDraft());
|
||||
setProfileActionError("");
|
||||
return true;
|
||||
} finally {
|
||||
setProfileSubmitBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfileItem(index: number, patch: Partial<ProfileConfig>) {
|
||||
updateConfig((next) => {
|
||||
|
|
@ -2023,6 +2114,7 @@ function App() {
|
|||
applyError: profileActionError,
|
||||
config: draftConfig,
|
||||
editProfile: openEditProfileDialog,
|
||||
openProfile: openProfileDialog,
|
||||
removeProfile,
|
||||
updateProfileItem
|
||||
},
|
||||
|
|
@ -2147,11 +2239,13 @@ function App() {
|
|||
draft: profileDraft,
|
||||
error: profileActionError,
|
||||
mode: "add",
|
||||
onChange: updateProfileDraft,
|
||||
onClose: () => setProfileAddOpen(false),
|
||||
providers: draftConfig.Providers,
|
||||
onSubmit: submitProfileDraft
|
||||
} : undefined}
|
||||
onChange: updateProfileDraft,
|
||||
onClose: () => setProfileAddOpen(false),
|
||||
providers: draftConfig.Providers,
|
||||
submitting: profileSubmitBusy === "add",
|
||||
virtualModelProfiles: draftConfig.virtualModelProfiles ?? [],
|
||||
onSubmit: submitProfileDraft
|
||||
} : undefined}
|
||||
profileEdit={profileEditIndex !== undefined ? {
|
||||
canSubmit: canSubmitProfileEdit,
|
||||
draft: profileEditDraft,
|
||||
|
|
@ -2161,9 +2255,20 @@ function App() {
|
|||
onClose: () => {
|
||||
setProfileEditIndex(undefined);
|
||||
setProfileActionError("");
|
||||
},
|
||||
providers: draftConfig.Providers,
|
||||
onSubmit: submitProfileEditDraft
|
||||
},
|
||||
providers: draftConfig.Providers,
|
||||
submitting: profileSubmitBusy === "edit",
|
||||
virtualModelProfiles: draftConfig.virtualModelProfiles ?? [],
|
||||
onSubmit: submitProfileEditDraft
|
||||
} : undefined}
|
||||
profileOpen={profileOpenDialog ? {
|
||||
busy: profileOpenDialog.busy,
|
||||
command: profileOpenDialog.command,
|
||||
error: profileOpenDialog.error,
|
||||
mode: profileOpenDialog.mode,
|
||||
onChooseApp: () => void openProfileApp(profileOpenDialog.profile),
|
||||
onClose: () => setProfileOpenDialog(undefined),
|
||||
profile: profileOpenDialog.profile
|
||||
} : undefined}
|
||||
providerDeepLink={providerDeepLinkRequest ? {
|
||||
busy: providerDeepLinkBusy,
|
||||
|
|
@ -2235,6 +2340,7 @@ function App() {
|
|||
canSubmit: canSubmitVirtualModel,
|
||||
draft: virtualModelDraft,
|
||||
error: virtualModelError || virtualModelValidationError,
|
||||
mcpServers: draftConfig.agent?.mcpServers ?? [],
|
||||
mode: virtualModelEditIndex === undefined ? "add" : "edit",
|
||||
onChange: updateVirtualModelDraft,
|
||||
onClose: () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ import type { ComponentProps } from "react";
|
|||
import { AnimatePresence } from "../shared";
|
||||
import { AddApiKeyDialog, EditApiKeyDialog } from "./api-keys";
|
||||
import { ConfigureClaudeDesignDialog, DeleteExtensionDialog, PluginSettingsDialog } from "./extensions";
|
||||
import { AddProfileDialog } from "./profiles";
|
||||
import { AddProfileDialog, ProfileOpenDialog } from "./profiles";
|
||||
import { AddProviderDialog, DeleteProviderDialog, ProviderDeepLinkDialog } from "./providers";
|
||||
import { AddRoutingRuleDialog, DeleteRoutingRuleDialog } from "./routing";
|
||||
import { AppSettingsDialog } from "./settings";
|
||||
|
|
@ -18,6 +18,7 @@ export function AppDialogStack({
|
|||
extensionSettings,
|
||||
profileAdd,
|
||||
profileEdit,
|
||||
profileOpen,
|
||||
providerDeepLink,
|
||||
providerDelete,
|
||||
providerUpsert,
|
||||
|
|
@ -35,6 +36,7 @@ export function AppDialogStack({
|
|||
extensionSettings?: ComponentProps<typeof PluginSettingsDialog>;
|
||||
profileAdd?: ComponentProps<typeof AddProfileDialog>;
|
||||
profileEdit?: ComponentProps<typeof AddProfileDialog>;
|
||||
profileOpen?: ComponentProps<typeof ProfileOpenDialog>;
|
||||
providerDeepLink?: ComponentProps<typeof ProviderDeepLinkDialog>;
|
||||
providerDelete?: ComponentProps<typeof DeleteProviderDialog>;
|
||||
providerUpsert?: ComponentProps<typeof AddProviderDialog>;
|
||||
|
|
@ -48,6 +50,7 @@ export function AppDialogStack({
|
|||
{apiKeyAdd ? <AddApiKeyDialog {...apiKeyAdd} key="api-key-add" /> : null}
|
||||
{profileAdd ? <AddProfileDialog {...profileAdd} key="profile-add" /> : null}
|
||||
{profileEdit ? <AddProfileDialog {...profileEdit} key="profile-edit" /> : null}
|
||||
{profileOpen ? <ProfileOpenDialog {...profileOpen} key="profile-open" /> : null}
|
||||
{apiKeyEdit ? <EditApiKeyDialog {...apiKeyEdit} key="api-key-edit" /> : null}
|
||||
{providerDeepLink ? <ProviderDeepLinkDialog {...providerDeepLink} key="provider-deep-link" /> : null}
|
||||
{providerUpsert ? <AddProviderDialog {...providerUpsert} key="provider-upsert" /> : null}
|
||||
|
|
|
|||
|
|
@ -93,9 +93,11 @@ export function MainLayout({
|
|||
viewProps: MainViewProps;
|
||||
visibleNavigation: MainNavigationItem[];
|
||||
}) {
|
||||
const windowControlSafeAreaWidth = isMac ? 152 : 88;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("app-no-drag pointer-events-auto absolute top-2 z-[70] flex items-center gap-1", isMac ? "left-[76px]" : "left-3")}>
|
||||
<div className={cn("app-no-drag app-window-controls pointer-events-auto absolute top-2 z-[90] flex items-center gap-1", isMac ? "left-[76px]" : "left-3")}>
|
||||
<Button
|
||||
aria-controls="primary-sidebar"
|
||||
aria-expanded={sidebarOpen}
|
||||
|
|
@ -138,7 +140,7 @@ export function MainLayout({
|
|||
transition={shouldReduceMotion ? reducedMotionTransition : { duration: 0.14, ease: motionEase }}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 max-[720px]:h-12">
|
||||
<div className="shrink-0" style={{ width: isMac ? 116 : 52 }} />
|
||||
<div className="app-no-drag shrink-0" style={{ width: windowControlSafeAreaWidth }} />
|
||||
<div className="app-drag min-w-0 flex-1" />
|
||||
</div>
|
||||
|
||||
|
|
@ -147,7 +149,7 @@ export function MainLayout({
|
|||
<Button
|
||||
className={cn(
|
||||
"flex h-9 min-w-0 items-center gap-2 rounded-md px-2 text-left text-[12px] font-medium text-muted-foreground transition-all duration-150 max-[720px]:min-w-[118px]",
|
||||
item.id !== "browser" && activeView === item.id
|
||||
activeView === item.id
|
||||
? "bg-card text-foreground shadow-[0_1px_3px_rgba(0,0,0,0.06)]"
|
||||
: "hover:bg-muted/80 hover:text-foreground"
|
||||
)}
|
||||
|
|
@ -158,8 +160,8 @@ export function MainLayout({
|
|||
>
|
||||
<motion.span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
item.id !== "browser" && activeView === item.id && "bg-primary/10 text-primary"
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
activeView === item.id && "bg-primary/10 text-primary"
|
||||
)}
|
||||
layout="position"
|
||||
transition={shouldReduceMotion ? reducedMotionTransition : listSpringTransition}
|
||||
|
|
@ -198,7 +200,9 @@ export function MainLayout({
|
|||
needsTrafficLightSafeArea && "pl-[116px] max-[720px]:pl-[116px]"
|
||||
)}
|
||||
>
|
||||
{needsTrafficLightSafeArea ? <div className="app-no-drag absolute left-0 top-0 h-full w-[152px]" /> : null}
|
||||
{needsTrafficLightSafeArea || !sidebarOpen ? (
|
||||
<div className="app-no-drag absolute left-0 top-0 h-full" style={{ width: windowControlSafeAreaWidth }} />
|
||||
) : null}
|
||||
<EndpointTitleBar
|
||||
config={viewProps.server.config as AppConfig}
|
||||
endpoint={gatewayEndpoint}
|
||||
|
|
|
|||
|
|
@ -376,9 +376,10 @@ export function LogsView({
|
|||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="network-table-scroll min-h-0 flex-1 overflow-auto">
|
||||
<div className="w-full min-w-0">
|
||||
<div className="network-table-header sticky top-0 z-10 grid h-9 grid-cols-[minmax(0,0.9fr)_minmax(96px,0.45fr)_minmax(0,0.95fr)_minmax(0,0.85fr)_84px] items-center border-b text-[12px] font-semibold">
|
||||
<div className="network-table-header sticky top-0 z-10 grid h-9 grid-cols-[minmax(0,0.85fr)_minmax(96px,0.42fr)_minmax(104px,0.45fr)_minmax(0,0.9fr)_minmax(0,0.78fr)_82px] items-center border-b text-[12px] font-semibold">
|
||||
<NetworkHeaderCell label={t("时间")} />
|
||||
<NetworkHeaderCell label={t("状态")} />
|
||||
<NetworkHeaderCell label={t("Stream")} />
|
||||
<NetworkHeaderCell label={t("模型")} />
|
||||
<NetworkHeaderCell label={t("令牌")} />
|
||||
<NetworkHeaderCell label={t("持续时间")} />
|
||||
|
|
@ -398,7 +399,7 @@ export function LogsView({
|
|||
<button
|
||||
aria-expanded={expanded}
|
||||
className={cn(
|
||||
"network-row grid h-10 w-full grid-cols-[minmax(0,0.9fr)_minmax(96px,0.45fr)_minmax(0,0.95fr)_minmax(0,0.85fr)_84px] items-center border-0 px-0 text-left text-[12px] font-semibold outline-none transition-colors",
|
||||
"network-row grid h-10 w-full grid-cols-[minmax(0,0.85fr)_minmax(96px,0.42fr)_minmax(104px,0.45fr)_minmax(0,0.9fr)_minmax(0,0.78fr)_82px] items-center border-0 px-0 text-left text-[12px] font-semibold outline-none transition-colors",
|
||||
index % 2 === 0 ? "network-row-even" : "network-row-odd",
|
||||
expanded && "network-row-selected"
|
||||
)}
|
||||
|
|
@ -413,6 +414,7 @@ export function LogsView({
|
|||
<LogStatusDot entry={item} />
|
||||
<span className="network-row-secondary truncate">{item.statusCode || "-"}</span>
|
||||
</div>
|
||||
<LogStreamCell entry={item} />
|
||||
<LogModelRouteCell entry={item} />
|
||||
<div className="network-row-secondary truncate px-2" title={formatLogTokenSummary(item, t)}>{formatLogTokenSummary(item, t)}</div>
|
||||
<div className="network-row-secondary truncate px-2">{formatDuration(item.durationMs)}</div>
|
||||
|
|
@ -447,8 +449,9 @@ function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) {
|
|||
{entry.method} {entry.path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="network-body-meta grid grid-cols-2 gap-y-2 border-b px-3 py-2 text-[12px] sm:grid-cols-4 lg:grid-cols-6">
|
||||
<div className="network-body-meta grid grid-cols-2 gap-y-2 border-b px-3 py-2 text-[12px] sm:grid-cols-4 lg:grid-cols-8">
|
||||
<LogMetric label={t("持续时间")} value={formatDuration(entry.durationMs)} />
|
||||
<LogMetric label={t("Stream")} value={entry.isStream ? t("Streaming") : t("Non-streaming")} />
|
||||
<LogMetric label={t("输入")} value={formatCompactNumber(entry.inputTokens)} />
|
||||
<LogMetric label={t("输出")} value={formatCompactNumber(entry.outputTokens)} />
|
||||
<LogMetric label={t("缓存读取")} value={formatCompactNumber(entry.cacheReadTokens)} />
|
||||
|
|
@ -500,6 +503,15 @@ function LogStatusDot({ entry }: { entry: RequestLogEntry }) {
|
|||
);
|
||||
}
|
||||
|
||||
function LogStreamCell({ entry }: { entry: RequestLogEntry }) {
|
||||
const t = useAppText();
|
||||
const label = entry.isStream ? t("Streaming") : t("Non-streaming");
|
||||
|
||||
return (
|
||||
<div className="network-row-secondary truncate px-2" title={label}>{label}</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LogPayloadTab = "body" | "header";
|
||||
|
||||
function LogJsonPanel({
|
||||
|
|
|
|||
|
|
@ -216,10 +216,11 @@ export function OnboardingView({
|
|||
<div className="mx-auto w-full max-w-[720px]">
|
||||
<AddProfileForm
|
||||
draft={profileDraft}
|
||||
error={profileError}
|
||||
onChange={onChangeProfile}
|
||||
providers={config.Providers}
|
||||
/>
|
||||
error={profileError}
|
||||
onChange={onChangeProfile}
|
||||
providers={config.Providers}
|
||||
virtualModelProfiles={config.virtualModelProfiles ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import {
|
||||
AddProfileDraft, AgentLogo, AnimatePresence, AppConfig, Badge, Button,
|
||||
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown,
|
||||
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
|
||||
DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, motion,
|
||||
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, Copy,
|
||||
cn, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader,
|
||||
DialogTitle, Field, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle, motion,
|
||||
normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent,
|
||||
profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery,
|
||||
profileModelProviderOptions, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
|
||||
Search, SelectControl, Toggle, translateOptions, Trash2, useAppText,
|
||||
profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions,
|
||||
Play, Search, SelectControl, Toggle, translateOptions, Trash2, useAppText, type ProfileOpenSurface, type VirtualModelProfileConfig,
|
||||
copyTextToClipboard,
|
||||
useEffect, useLayoutEffect, useMemo, useRef, useState, X
|
||||
} from "../shared";
|
||||
export function ProfileView({
|
||||
|
|
@ -14,6 +15,7 @@ export function ProfileView({
|
|||
applyError,
|
||||
config,
|
||||
editProfile,
|
||||
openProfile,
|
||||
removeProfile,
|
||||
updateProfileItem
|
||||
}: {
|
||||
|
|
@ -21,6 +23,7 @@ export function ProfileView({
|
|||
applyError: string;
|
||||
config: AppConfig;
|
||||
editProfile: (index: number) => void;
|
||||
openProfile: (index: number) => void;
|
||||
removeProfile: (index: number) => void;
|
||||
updateProfileItem: (index: number, patch: Partial<ProfileConfig>) => void;
|
||||
}) {
|
||||
|
|
@ -30,11 +33,11 @@ export function ProfileView({
|
|||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-auto w-full max-w-4xl"
|
||||
className="flex h-full min-h-0 min-w-0 flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Card className="min-w-0">
|
||||
<Card className="flex h-full min-h-0 min-w-0 flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
|
|
@ -51,7 +54,7 @@ export function ProfileView({
|
|||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="min-h-0 flex-1 space-y-4 overflow-auto">
|
||||
<div className="space-y-2">
|
||||
{profiles.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-md border border-dashed border-border bg-muted/20 text-[12px] text-muted-foreground">
|
||||
|
|
@ -92,6 +95,11 @@ export function ProfileView({
|
|||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Toggle checked={profile.enabled} onChange={(enabled) => updateProfileItem(index, { enabled })} />
|
||||
{profile.enabled && scope === "ccr" ? (
|
||||
<Button aria-label={`${t("Open")} ${profile.name || t("Profile")}`} onClick={() => openProfile(index)} size="iconSm" title={t("Open")} type="button" variant="ghost">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button aria-label={`${t("Edit")} ${profile.name || t("Profile")}`} onClick={() => editProfile(index)} size="iconSm" title={t("Edit")} type="button" variant="ghost">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
|
@ -116,6 +124,132 @@ export function ProfileView({
|
|||
);
|
||||
}
|
||||
|
||||
export function ProfileOpenDialog({
|
||||
busy,
|
||||
command,
|
||||
error,
|
||||
mode,
|
||||
onChooseApp,
|
||||
onClose,
|
||||
profile
|
||||
}: {
|
||||
busy?: ProfileOpenSurface | "";
|
||||
command?: string;
|
||||
error?: string;
|
||||
mode: "choose" | "cli";
|
||||
onChooseApp: () => void;
|
||||
onClose: () => void;
|
||||
profile: ProfileConfig;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const surfaces = profileOpenSurfaces(profile);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
setCopied(false);
|
||||
}, [command]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function copyCommand() {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(command);
|
||||
setCopied(true);
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => setCopied(false), 3000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && onClose()} open>
|
||||
<DialogContent className="max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle>{t("Open Agent")}</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2">
|
||||
<AgentLogo agent={profile.agent} className="h-6 w-6 rounded-[5px]" />
|
||||
<div className="min-w-0 flex-1 truncate text-[13px] font-semibold">{profile.name || profile.id}</div>
|
||||
{mode === "choose" && surfaces.includes("app") ? (
|
||||
<Button className="shrink-0" disabled={Boolean(busy)} onClick={onChooseApp} size="sm" type="button" variant="outline">
|
||||
{busy === "app" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
{t("App")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{mode === "choose" ? (
|
||||
<div className="space-y-3">
|
||||
{surfaces.includes("cli") ? (
|
||||
<ProfileCliCommandBlock
|
||||
command={command}
|
||||
copied={copied}
|
||||
onCopy={() => void copyCommand()}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ProfileCliCommandBlock
|
||||
command={command}
|
||||
copied={copied}
|
||||
onCopy={() => void copyCommand()}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="whitespace-pre-wrap rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">
|
||||
{t(error)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCliCommandBlock({
|
||||
command,
|
||||
copied,
|
||||
onCopy,
|
||||
t
|
||||
}: {
|
||||
command?: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
t: (value: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[12px] font-medium text-muted-foreground">{t("CLI command")}</div>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-[5px] bg-background px-2 py-2 font-mono text-[12px] text-foreground">
|
||||
{command || t("Loading")}
|
||||
</code>
|
||||
<Button aria-label={copied ? t("Copied") : t("Copy")} disabled={!command} onClick={onCopy} size="iconSm" title={copied ? t("Copied") : t("Copy")} type="button" variant={copied ? "default" : "outline"}>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileAgentTabs({
|
||||
activeAgent,
|
||||
profiles,
|
||||
|
|
@ -274,12 +408,14 @@ function ProfileModelSelector({
|
|||
onChange,
|
||||
placeholder,
|
||||
providers,
|
||||
value
|
||||
value,
|
||||
virtualModelProfiles = []
|
||||
}: {
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
providers: GatewayProviderConfig[];
|
||||
value: string;
|
||||
virtualModelProfiles?: VirtualModelProfileConfig[];
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -292,8 +428,8 @@ function ProfileModelSelector({
|
|||
placement: "above" | "below";
|
||||
width: number;
|
||||
}>();
|
||||
const parsedValue = useMemo(() => parseProfileModelValue(value, providers), [providers, value]);
|
||||
const providerOptions = useMemo(() => profileModelProviderOptions(providers), [providers]);
|
||||
const parsedValue = useMemo(() => parseProfileModelValue(value, providers, virtualModelProfiles), [providers, value, virtualModelProfiles]);
|
||||
const providerOptions = useMemo(() => profileModelProviderOptions(providers, virtualModelProfiles), [providers, virtualModelProfiles]);
|
||||
const filteredProviders = useMemo(
|
||||
() => providerOptions.filter((provider) => profileModelProviderMatchesQuery(provider, query)),
|
||||
[providerOptions, query]
|
||||
|
|
@ -307,7 +443,7 @@ function ProfileModelSelector({
|
|||
const filteredModels = activeProvider
|
||||
? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query))
|
||||
: [];
|
||||
const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder);
|
||||
const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder, virtualModelProfiles);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -551,12 +687,14 @@ export function AddProfileForm({
|
|||
draft,
|
||||
error,
|
||||
onChange,
|
||||
providers
|
||||
providers,
|
||||
virtualModelProfiles = []
|
||||
}: {
|
||||
draft: AddProfileDraft;
|
||||
error: string;
|
||||
onChange: (patch: Partial<AddProfileDraft>) => void;
|
||||
providers: GatewayProviderConfig[];
|
||||
virtualModelProfiles?: VirtualModelProfileConfig[];
|
||||
}) {
|
||||
const t = useAppText();
|
||||
|
||||
|
|
@ -590,19 +728,21 @@ export function AddProfileForm({
|
|||
<>
|
||||
<Field label={t("Model override")}>
|
||||
<ProfileModelSelector
|
||||
placeholder={t("Keep Claude Code default")}
|
||||
providers={providers}
|
||||
value={draft.model}
|
||||
onChange={(model) => onChange({ model })}
|
||||
/>
|
||||
placeholder={t("Keep Claude Code default")}
|
||||
providers={providers}
|
||||
value={draft.model}
|
||||
virtualModelProfiles={virtualModelProfiles}
|
||||
onChange={(model) => onChange({ model })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("Small fast model")}>
|
||||
<ProfileModelSelector
|
||||
placeholder={t("Keep Claude Code default")}
|
||||
providers={providers}
|
||||
value={draft.smallFastModel}
|
||||
onChange={(smallFastModel) => onChange({ smallFastModel })}
|
||||
/>
|
||||
placeholder={t("Keep Claude Code default")}
|
||||
providers={providers}
|
||||
value={draft.smallFastModel}
|
||||
virtualModelProfiles={virtualModelProfiles}
|
||||
onChange={(smallFastModel) => onChange({ smallFastModel })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -619,11 +759,12 @@ export function AddProfileForm({
|
|||
</div>
|
||||
<Field className="sm:col-span-2" label={t("Codex model")}>
|
||||
<ProfileModelSelector
|
||||
placeholder={providers[0]?.models[0] && providers[0]?.name ? `${providers[0].name}/${providers[0].models[0]}` : ""}
|
||||
providers={providers}
|
||||
value={draft.model}
|
||||
onChange={(model) => onChange({ model })}
|
||||
/>
|
||||
placeholder={providers[0]?.models[0] && providers[0]?.name ? `${providers[0].name}/${providers[0].models[0]}` : ""}
|
||||
providers={providers}
|
||||
value={draft.model}
|
||||
virtualModelProfiles={virtualModelProfiles}
|
||||
onChange={(model) => onChange({ model })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -652,6 +793,8 @@ export function AddProfileDialog({
|
|||
onChange,
|
||||
onClose,
|
||||
providers,
|
||||
submitting = false,
|
||||
virtualModelProfiles = [],
|
||||
onSubmit
|
||||
}: {
|
||||
canSubmit: boolean;
|
||||
|
|
@ -661,12 +804,14 @@ export function AddProfileDialog({
|
|||
onChange: (patch: Partial<AddProfileDraft>) => void;
|
||||
onClose: () => void;
|
||||
providers: GatewayProviderConfig[];
|
||||
submitting?: boolean;
|
||||
virtualModelProfiles?: VirtualModelProfileConfig[];
|
||||
onSubmit: () => Promise<boolean> | boolean | void;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && onClose()} open>
|
||||
<Dialog onOpenChange={(open) => !open && !submitting && onClose()} open>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div>
|
||||
|
|
@ -674,17 +819,17 @@ export function AddProfileDialog({
|
|||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<AddProfileForm draft={draft} error={error} onChange={onChange} providers={providers} />
|
||||
<AddProfileForm draft={draft} error={error} onChange={onChange} providers={providers} virtualModelProfiles={virtualModelProfiles} />
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit} onClick={() => void onSubmit()} type="button">
|
||||
{mode === "add" ? <Plus className="h-4 w-4" /> : null}
|
||||
{mode === "edit" ? t("Save") : t("Add")}
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={onClose} type="button" variant="outline">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit || submitting} onClick={() => void onSubmit()} type="button">
|
||||
{submitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : mode === "add" ? <Plus className="h-4 w-4" /> : null}
|
||||
{mode === "edit" ? t("Save") : t("Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import {
|
||||
Activity, AppConfig, AppCopy, AppLanguagePreference, ArrowDown, ArrowUp, Boxes, Button,
|
||||
cn, Database, Dialog, DialogBody, DialogContent,
|
||||
Activity, AppConfig, AppCopy, AppLanguagePreference, Boxes, Button,
|
||||
closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
|
||||
DialogFooter, DialogHeader, DialogTitle, Field, formatSystemOption, Gauge,
|
||||
Input, languageDisplayName, Layers3, Palette,
|
||||
DndContext, DragEndEvent, Input, KeyboardSensor, languageDisplayName, Layers3, Palette,
|
||||
PanelLeftOpen, Power, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
|
||||
SettingsPageId, themeDisplayName, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
|
||||
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useMemo, useState,
|
||||
PointerSensor, rectSortingStrategy, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
|
||||
TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
|
||||
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState,
|
||||
X
|
||||
} from "../shared";
|
||||
export function AppSettingsDialog({
|
||||
|
|
@ -228,7 +229,9 @@ function TraySettingsPage({
|
|||
trayProgressTargetTokens: number;
|
||||
trayWidgets: TrayWidgetConfig[];
|
||||
}) {
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedTrayWidgetId, setSelectedTrayWidgetId] = useState<string>();
|
||||
const [pendingScrollTrayWidgetId, setPendingScrollTrayWidgetId] = useState<string>();
|
||||
const widgets = useMemo(() => normalizeTrayWidgets(trayWidgets), [trayWidgets]);
|
||||
const selectedWidget = widgets.find((widget) => widget.id === selectedTrayWidgetId) ?? widgets[0];
|
||||
const selectedWidgetIndex = selectedWidget ? widgets.findIndex((widget) => widget.id === selectedWidget.id) : -1;
|
||||
|
|
@ -243,9 +246,18 @@ function TraySettingsPage({
|
|||
const trayT = (value: string) => copy.text[value] ?? value;
|
||||
const selectedCategory = selectedWidget ? trayComponentCategoryForType(selectedWidget.type) : "provider-tabs";
|
||||
const selectedCategoryOption = paletteItems.find((item) => item.value === selectedCategory) ?? paletteItems[0];
|
||||
const selectedDataOptions = selectedCategoryOption.dataOptions;
|
||||
const selectedStyleOptions = selectedWidget ? trayWidgetVariantOptions(selectedWidget.type) : [];
|
||||
const SelectedTrayCategoryIcon = selectedCategoryOption.icon;
|
||||
const trayPreviewSensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 6
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
|
||||
function commitWidgets(nextWidgets: TrayWidgetConfig[]) {
|
||||
onChangeTrayWidgets(normalizeTrayWidgets(nextWidgets));
|
||||
|
|
@ -266,6 +278,7 @@ function TraySettingsPage({
|
|||
}
|
||||
commitWidgets([...widgets, widget]);
|
||||
setSelectedTrayWidgetId(id);
|
||||
setPendingScrollTrayWidgetId(id);
|
||||
}
|
||||
|
||||
function toggleSingletonTrayWidget(template: TrayWidgetConfig, enabled: boolean) {
|
||||
|
|
@ -288,34 +301,6 @@ function TraySettingsPage({
|
|||
commitWidgets(widgets.map((widget) => widget.id === id ? normalizeTrayWidget({ ...widget, ...patch }) ?? widget : widget));
|
||||
}
|
||||
|
||||
function changeSelectedTrayWidgetType(type: TrayWidgetType) {
|
||||
if (!selectedWidget) {
|
||||
return;
|
||||
}
|
||||
const existingSingleton = isTraySingletonWidgetType(type)
|
||||
? widgets.find((widget) => widget.type === type && widget.id !== selectedWidget.id)
|
||||
: undefined;
|
||||
if (existingSingleton) {
|
||||
const nextWidgets = widgets.filter((widget) => widget.id !== selectedWidget.id);
|
||||
commitWidgets(nextWidgets);
|
||||
setSelectedTrayWidgetId(existingSingleton.id);
|
||||
return;
|
||||
}
|
||||
updateTrayWidget(selectedWidget.id, { type, variant: defaultTrayWidgetVariant(type) });
|
||||
}
|
||||
|
||||
function changeTrayWidgetCategory(category: TrayComponentCategory) {
|
||||
if (!selectedWidget) {
|
||||
return;
|
||||
}
|
||||
const type = trayWidgetTypeForCategory(category, selectedWidget.type);
|
||||
changeSelectedTrayWidgetType(type);
|
||||
}
|
||||
|
||||
function changeTrayWidgetData(type: TrayWidgetType) {
|
||||
changeSelectedTrayWidgetType(type);
|
||||
}
|
||||
|
||||
function changeTrayWidgetVariant(variant: TrayWidgetVariant) {
|
||||
if (!selectedWidget) {
|
||||
return;
|
||||
|
|
@ -323,17 +308,6 @@ function TraySettingsPage({
|
|||
updateTrayWidget(selectedWidget.id, { variant });
|
||||
}
|
||||
|
||||
function moveTrayWidget(direction: -1 | 1) {
|
||||
if (!selectedWidget || selectedWidgetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = selectedWidgetIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= widgets.length) {
|
||||
return;
|
||||
}
|
||||
commitWidgets(arrayMove(widgets, selectedWidgetIndex, nextIndex));
|
||||
}
|
||||
|
||||
function removeSelectedTrayWidget() {
|
||||
if (!selectedWidget || selectedWidgetIndex < 0) {
|
||||
return;
|
||||
|
|
@ -353,8 +327,32 @@ function TraySettingsPage({
|
|||
: currentId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWidget || selectedWidgetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || (event.key !== "Delete" && event.key !== "Backspace")) {
|
||||
return;
|
||||
}
|
||||
const target = event.target instanceof Element ? event.target : undefined;
|
||||
if (isEditableKeyboardTarget(target)) {
|
||||
return;
|
||||
}
|
||||
if (target && target !== document.body && !pageRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
removeTrayWidget(selectedWidget.id);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedWidget, selectedWidgetIndex, widgets]);
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[520px] grid-rows-[auto_auto_auto] gap-4">
|
||||
<div className="grid min-h-[520px] grid-rows-[auto_auto_auto] gap-4" ref={pageRef}>
|
||||
<h3 className="text-[15px] font-semibold text-foreground">{copy.settings.tray}</h3>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-md border border-border bg-background p-3">
|
||||
<Field className="min-w-[220px] flex-1" label={copy.settings.trayIcon}>
|
||||
|
|
@ -454,9 +452,13 @@ function TraySettingsPage({
|
|||
<TrayWindowPreview
|
||||
copy={copy}
|
||||
iconPreference={trayIconPreference}
|
||||
pendingScrollWidgetId={pendingScrollTrayWidgetId}
|
||||
selectedWidgetId={selectedWidget?.id}
|
||||
widgets={widgets}
|
||||
onPendingScrollComplete={() => setPendingScrollTrayWidgetId(undefined)}
|
||||
onSelectWidget={setSelectedTrayWidgetId}
|
||||
onSortWidgets={commitWidgets}
|
||||
sensors={trayPreviewSensors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -479,22 +481,6 @@ function TraySettingsPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Field label={trayT("Component category")}>
|
||||
<SelectControl
|
||||
onChange={(value) => changeTrayWidgetCategory(value as TrayComponentCategory)}
|
||||
options={paletteItems.map((option) => ({ label: option.label, value: option.value }))}
|
||||
value={selectedCategory}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={trayT("Data")}>
|
||||
<SelectControl
|
||||
onChange={(value) => changeTrayWidgetData(value as TrayWidgetType)}
|
||||
options={selectedDataOptions}
|
||||
value={selectedWidget.type}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{selectedStyleOptions.length > 0 ? (
|
||||
<Field label={copy.settings.trayComponentStyle}>
|
||||
<SelectControl
|
||||
|
|
@ -505,17 +491,6 @@ function TraySettingsPage({
|
|||
</Field>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button disabled={selectedWidgetIndex <= 0} onClick={() => moveTrayWidget(-1)} size="sm" type="button" variant="outline">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
{trayT("Move up")}
|
||||
</Button>
|
||||
<Button disabled={selectedWidgetIndex < 0 || selectedWidgetIndex >= widgets.length - 1} onClick={() => moveTrayWidget(1)} size="sm" type="button" variant="outline">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
{trayT("Move down")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button className="w-full justify-center" onClick={removeSelectedTrayWidget} size="sm" type="button" variant="outline">
|
||||
{trayT("Remove widget")}
|
||||
</Button>
|
||||
|
|
@ -618,15 +593,6 @@ function trayComponentCategoryForType(type: TrayWidgetType): TrayComponentCatego
|
|||
return "breakdown";
|
||||
}
|
||||
|
||||
function trayWidgetTypeForCategory(category: TrayComponentCategory, currentType: TrayWidgetType): TrayWidgetType {
|
||||
if (category === "provider-tabs") return "source-tabs";
|
||||
if (category === "header") return "header";
|
||||
if (category === "account") return "account";
|
||||
if (category === "trend") return "token-flow";
|
||||
if (category === "metrics") return "stats";
|
||||
return trayComponentCategoryForType(currentType) === "breakdown" ? currentType : "token-mix";
|
||||
}
|
||||
|
||||
function trayWidgetTypeLabel(type: TrayWidgetType, copy: AppCopy): string {
|
||||
if (type === "account") return copy.settings.trayModuleAccount;
|
||||
if (type === "header") return copy.settings.trayModuleHeader;
|
||||
|
|
@ -732,21 +698,66 @@ function TrayProgressPreview() {
|
|||
);
|
||||
}
|
||||
|
||||
function isEditableKeyboardTarget(target: Element | undefined): boolean {
|
||||
return Boolean(target?.closest("input, textarea, select, [contenteditable='true'], [contenteditable='plaintext-only'], [role='textbox']"));
|
||||
}
|
||||
|
||||
function TrayWindowPreview({
|
||||
copy,
|
||||
iconPreference,
|
||||
pendingScrollWidgetId,
|
||||
selectedWidgetId,
|
||||
widgets,
|
||||
onSelectWidget
|
||||
onPendingScrollComplete,
|
||||
onSelectWidget,
|
||||
onSortWidgets,
|
||||
sensors
|
||||
}: {
|
||||
copy: AppCopy;
|
||||
iconPreference: AppConfig["trayIcon"];
|
||||
pendingScrollWidgetId?: string;
|
||||
selectedWidgetId?: string;
|
||||
widgets: TrayWidgetConfig[];
|
||||
onPendingScrollComplete?: () => void;
|
||||
onSelectWidget?: (id: string) => void;
|
||||
onSortWidgets?: (widgets: TrayWidgetConfig[]) => void;
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
}) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function finishWidgetSort(event: DragEndEvent) {
|
||||
const activeId = String(event.active.id);
|
||||
const overId = event.over ? String(event.over.id) : "";
|
||||
if (!overId || activeId === overId) {
|
||||
return;
|
||||
}
|
||||
const activeIndex = widgets.findIndex((widget) => widget.id === activeId);
|
||||
const overIndex = widgets.findIndex((widget) => widget.id === overId);
|
||||
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) {
|
||||
return;
|
||||
}
|
||||
onSortWidgets?.(arrayMove(widgets, activeIndex, overIndex));
|
||||
onSelectWidget?.(activeId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScrollWidgetId || !widgets.some((widget) => widget.id === pendingScrollWidgetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const element = findTrayWidgetElement(previewRef.current, pendingScrollWidgetId);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
onPendingScrollComplete?.();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [onPendingScrollComplete, pendingScrollWidgetId, widgets]);
|
||||
|
||||
return (
|
||||
<div className="h-[740px] min-w-0 overflow-y-auto overflow-x-hidden rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-50 shadow-[0_18px_42px_rgba(15,23,42,.28)]">
|
||||
<div className="h-[740px] min-w-0 overflow-y-auto overflow-x-hidden rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-50 shadow-[0_18px_42px_rgba(15,23,42,.28)]" ref={previewRef}>
|
||||
<div className="mb-3 flex min-w-0 items-center justify-between gap-3 border-b border-white/10 pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<TrayIconPreview className="h-7 w-7 border-white/15 bg-white/10" preference={iconPreference} />
|
||||
|
|
@ -760,17 +771,21 @@ function TrayWindowPreview({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{widgets.map((widget) => (
|
||||
<TrayPreviewWidget
|
||||
copy={copy}
|
||||
key={widget.id}
|
||||
selected={widget.id === selectedWidgetId}
|
||||
widget={widget}
|
||||
onSelect={onSelectWidget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DndContext collisionDetection={closestCenter} sensors={sensors} onDragEnd={finishWidgetSort}>
|
||||
<SortableContext items={widgets.map((widget) => widget.id)} strategy={rectSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{widgets.map((widget) => (
|
||||
<SortableTrayPreviewWidget
|
||||
copy={copy}
|
||||
key={widget.id}
|
||||
selected={widget.id === selectedWidgetId}
|
||||
widget={widget}
|
||||
onSelect={onSelectWidget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{widgets.length === 0 ? (
|
||||
<div className="flex min-h-[260px] items-center justify-center rounded-[10px] border border-white/10 bg-white/[.03] px-4 text-center text-[12px] font-medium text-slate-400">
|
||||
{copy.settings.trayPreviewEmpty}
|
||||
|
|
@ -780,6 +795,60 @@ function TrayWindowPreview({
|
|||
);
|
||||
}
|
||||
|
||||
function findTrayWidgetElement(root: HTMLElement | null, id: string): HTMLElement | undefined {
|
||||
if (!root) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(root.querySelectorAll<HTMLElement>("[data-tray-widget-id]"))
|
||||
.find((element) => element.dataset.trayWidgetId === id);
|
||||
}
|
||||
|
||||
function SortableTrayPreviewWidget({
|
||||
copy,
|
||||
selected,
|
||||
widget,
|
||||
onSelect
|
||||
}: {
|
||||
copy: AppCopy;
|
||||
selected: boolean;
|
||||
widget: TrayWidgetConfig;
|
||||
onSelect?: (id: string) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition
|
||||
} = useSortable({
|
||||
id: widget.id
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-grab touch-none rounded-[10px]",
|
||||
isDragging && "relative z-20 cursor-grabbing opacity-70"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<TrayPreviewWidget
|
||||
copy={copy}
|
||||
selected={selected}
|
||||
widget={widget}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrayPreviewWidget({
|
||||
copy,
|
||||
selected,
|
||||
|
|
@ -811,7 +880,7 @@ function TrayPreviewWidget({
|
|||
}
|
||||
|
||||
if (!onSelect) {
|
||||
return <div>{content}</div>;
|
||||
return <div data-tray-widget-id={widget.id}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -820,6 +889,7 @@ function TrayPreviewWidget({
|
|||
"block w-full rounded-[10px] text-left transition",
|
||||
selected ? "outline outline-2 outline-teal-300/80 outline-offset-2" : "outline outline-1 outline-transparent hover:outline-white/18"
|
||||
)}
|
||||
data-tray-widget-id={widget.id}
|
||||
onClick={() => onSelect(widget.id)}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -855,7 +925,19 @@ function TrayPreviewHeader({ copy }: { copy: AppCopy }) {
|
|||
{trayPreviewText(copy, "Today", "Today")} - {trayPreviewText(copy, "All providers", "All providers", "全部供应商")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-md border border-white/10 bg-slate-900/70 px-2 py-0.5 text-[10px] font-semibold text-slate-200">{trayPreviewText(copy, "7d", "7d")}</div>
|
||||
<div className="flex shrink-0 rounded-md border border-white/10 bg-slate-900/70 p-0.5">
|
||||
{["24h", "7d", "30d"].map((range) => (
|
||||
<span
|
||||
className={cn(
|
||||
"h-5 rounded-[5px] px-1.5 text-[10px] font-bold",
|
||||
range === "30d" ? "bg-white/14 text-slate-50" : "text-slate-400"
|
||||
)}
|
||||
key={range}
|
||||
>
|
||||
{trayPreviewText(copy, range, range)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import {
|
||||
AnimatedListItem, AnimatePresence, Boxes, BUILTIN_UNIMCP_VISION_TOOL_NAME, BUILTIN_UNIMCP_WEB_SEARCH_TOOL_NAME, Button,
|
||||
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown,
|
||||
cn, createRouteModelOptions, Dialog, DialogBody, DialogContent, DialogFooter,
|
||||
AnimatedListItem, AnimatePresence, Boxes, Button,
|
||||
Card, CardContent, CardHeader, CardTitle, Check, ChevronDown, ChevronRight,
|
||||
cn, createMcpServerDraftFromConfig, createRouteModelOptions, defaultFusionWebSearchProvider, Dialog, DialogBody, DialogContent, DialogFooter,
|
||||
DialogHeader, DialogTitle, ExtensionInstallDraft, Field, FolderOpen, formatPluginDependencies,
|
||||
fusionToolOptions, GatewayProviderConfig, Input, motion, normalizeFusionToolName, Pencil,
|
||||
createFusionWebSearchEnvRows, customFusionToolName, fusionToolExecutionFlags, fusionToolOptions,
|
||||
fusionWebSearchProviderOptions, GatewayMcpServerConfig, GatewayMcpToolInfo, GatewayProviderConfig, Input, KeyValueRowsControl, LoaderCircle,
|
||||
mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions,
|
||||
mcpStdioMessageModeOptions, motion, normalizeFusionToolName, Pencil,
|
||||
PluginMarketplaceEntry, Plus, PopoverContent, RouteTargetControl, Search, selectedFusionToolName,
|
||||
Toggle, Trash2, useAppText, useEffect, useLayoutEffect, useMemo,
|
||||
useRef, useState, virtualModelBaseModelSummary, VirtualModelDraft, virtualModelMatchesQuery, virtualModelMatchSummary,
|
||||
SelectControl, Toggle, Trash2, translateOptions, useAppText, useEffect, useLayoutEffect, useMemo,
|
||||
useRef, useState, validateMcpServerDraft, virtualModelBaseModelSummary, VirtualModelDraft, virtualModelMatchesQuery, virtualModelMatchSummary,
|
||||
VirtualModelProfileConfig, virtualModelToolSummary, X
|
||||
} from "../shared";
|
||||
|
||||
const virtualModelTableGridClass = "grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px]";
|
||||
const virtualModelTableMinWidthClass = "min-w-[1100px]";
|
||||
|
||||
export function VirtualModelsView({
|
||||
addVirtualModel,
|
||||
editVirtualModel,
|
||||
|
|
@ -71,8 +78,8 @@ export function VirtualModelsView({
|
|||
) : null}
|
||||
{visibleProfiles.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[780px]">
|
||||
<div className="sticky top-0 z-10 grid h-10 grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px] items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className={cn("w-full", virtualModelTableMinWidthClass)}>
|
||||
<div className={cn("sticky top-0 z-10 grid h-10 items-center gap-3 border-b border-border/60 bg-muted/95 px-4 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", virtualModelTableGridClass)}>
|
||||
<div className="truncate">{t("Name")}</div>
|
||||
<div className="truncate">{t("New model")}</div>
|
||||
<div className="truncate">{t("Base model")}</div>
|
||||
|
|
@ -84,7 +91,7 @@ export function VirtualModelsView({
|
|||
<AnimatePresence initial={false}>
|
||||
{visibleProfiles.map(({ index, profile }) => (
|
||||
<AnimatedListItem
|
||||
className="grid min-h-[58px] grid-cols-[minmax(180px,0.9fr)_minmax(220px,1.1fr)_minmax(220px,1.1fr)_minmax(170px,0.85fr)_112px_96px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35"
|
||||
className={cn("grid min-h-[58px] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/35", virtualModelTableGridClass)}
|
||||
key={`${profile.id || profile.key}-${index}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -130,6 +137,7 @@ export function VirtualModelDialog({
|
|||
canSubmit,
|
||||
draft,
|
||||
error,
|
||||
mcpServers,
|
||||
mode,
|
||||
onChange,
|
||||
onClose,
|
||||
|
|
@ -139,6 +147,7 @@ export function VirtualModelDialog({
|
|||
canSubmit: boolean;
|
||||
draft: VirtualModelDraft;
|
||||
error: string;
|
||||
mcpServers: GatewayMcpServerConfig[];
|
||||
mode: "add" | "edit";
|
||||
onChange: (patch: Partial<VirtualModelDraft>) => void;
|
||||
onClose: () => void;
|
||||
|
|
@ -148,16 +157,137 @@ export function VirtualModelDialog({
|
|||
const t = useAppText();
|
||||
const modelOptions = useMemo(() => createRouteModelOptions(providers), [providers]);
|
||||
const selectedTool = selectedFusionToolName(draft.toolsText);
|
||||
const selectedToolFlags = fusionToolExecutionFlags(selectedTool);
|
||||
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
||||
const [customMcpDialogDraft, setCustomMcpDialogDraft] = useState(draft.customMcpServer);
|
||||
const [customMcpDialogError, setCustomMcpDialogError] = useState("");
|
||||
const [mcpToolStateByServer, setMcpToolStateByServer] = useState<Record<string, {
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
tools?: GatewayMcpToolInfo[];
|
||||
}>>({});
|
||||
const customMcpServerConfig = useMemo(() => {
|
||||
if (validateMcpServerDraft(draft.customMcpServer)) {
|
||||
return undefined;
|
||||
}
|
||||
const editIndex = mcpServers.findIndex((server) => server.name === draft.customMcpServer.name.trim());
|
||||
return mcpServerConfigFromDraft(draft.customMcpServer, mcpServers, editIndex >= 0 ? editIndex : undefined);
|
||||
}, [draft.customMcpServer, mcpServers]);
|
||||
const availableMcpServers = useMemo(() => {
|
||||
const servers: GatewayMcpServerConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (customMcpServerConfig) {
|
||||
servers.push(customMcpServerConfig);
|
||||
seen.add(customMcpServerConfig.name);
|
||||
}
|
||||
for (const server of mcpServers) {
|
||||
if (seen.has(server.name)) {
|
||||
continue;
|
||||
}
|
||||
servers.push(server);
|
||||
seen.add(server.name);
|
||||
}
|
||||
return servers;
|
||||
}, [customMcpServerConfig, mcpServers]);
|
||||
|
||||
function updateFusionTool(toolName: string) {
|
||||
useEffect(() => {
|
||||
if (!customMcpDialogOpen) {
|
||||
setCustomMcpDialogDraft(draft.customMcpServer);
|
||||
}
|
||||
}, [customMcpDialogOpen, draft.customMcpServer]);
|
||||
|
||||
function updateFusionTool(toolName: string, server?: GatewayMcpServerConfig) {
|
||||
const nextTool = normalizeFusionToolName(toolName);
|
||||
const flags = fusionToolExecutionFlags(nextTool);
|
||||
onChange({
|
||||
...(flags.matchWebSearch && draft.webSearchEnvRows.length === 0 ? { webSearchEnvRows: createFusionWebSearchEnvRows(draft.webSearchProvider) } : {}),
|
||||
...(!flags.matchMultimodal && !flags.matchWebSearch ? {
|
||||
...(server ? { customMcpServer: createMcpServerDraftFromConfig(server) } : {}),
|
||||
customToolName: nextTool || draft.customToolName || customFusionToolName
|
||||
} : {}),
|
||||
toolsText: nextTool,
|
||||
matchMultimodal: nextTool === BUILTIN_UNIMCP_VISION_TOOL_NAME,
|
||||
matchWebSearch: nextTool === BUILTIN_UNIMCP_WEB_SEARCH_TOOL_NAME
|
||||
...flags
|
||||
});
|
||||
}
|
||||
|
||||
function openCustomMcpDialog() {
|
||||
setCustomMcpDialogDraft(draft.customMcpServer);
|
||||
setCustomMcpDialogError("");
|
||||
setCustomMcpDialogOpen(true);
|
||||
}
|
||||
|
||||
async function discoverMcpServerTools(server: GatewayMcpServerConfig, force = false): Promise<GatewayMcpToolInfo[]> {
|
||||
if (!window.ccr?.listMcpServerTools) {
|
||||
const message = "MCP tool discovery is available in the Electron app.";
|
||||
setMcpToolStateByServer((current) => ({
|
||||
...current,
|
||||
[server.name]: { error: message, loading: false, tools: [] }
|
||||
}));
|
||||
return [];
|
||||
}
|
||||
const currentState = mcpToolStateByServer[server.name];
|
||||
if (!force && currentState?.tools) {
|
||||
return currentState.tools;
|
||||
}
|
||||
if (!force && currentState?.loading) {
|
||||
return currentState.tools ?? [];
|
||||
}
|
||||
setMcpToolStateByServer((current) => ({
|
||||
...current,
|
||||
[server.name]: { ...current[server.name], error: "", loading: true }
|
||||
}));
|
||||
try {
|
||||
const tools = await window.ccr.listMcpServerTools(server);
|
||||
setMcpToolStateByServer((current) => ({
|
||||
...current,
|
||||
[server.name]: { loading: false, tools }
|
||||
}));
|
||||
return tools;
|
||||
} catch (discoverError) {
|
||||
const message = discoverError instanceof Error ? discoverError.message : String(discoverError);
|
||||
setMcpToolStateByServer((current) => ({
|
||||
...current,
|
||||
[server.name]: { error: message || "Tool discovery failed.", loading: false, tools: current[server.name]?.tools ?? [] }
|
||||
}));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function discoverVisibleMcpServers() {
|
||||
for (const server of availableMcpServers) {
|
||||
void discoverMcpServerTools(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCustomMcpDialog() {
|
||||
const validationError = validateMcpServerDraft(customMcpDialogDraft);
|
||||
if (validationError) {
|
||||
setCustomMcpDialogError(validationError);
|
||||
return;
|
||||
}
|
||||
const normalizedDraft = customMcpDialogDraft.transport === "stdio"
|
||||
? customMcpDialogDraft
|
||||
: {
|
||||
...customMcpDialogDraft,
|
||||
apiKey: "",
|
||||
apiKeyEnv: ""
|
||||
};
|
||||
const editIndex = mcpServers.findIndex((server) => server.name === normalizedDraft.name.trim());
|
||||
const server = mcpServerConfigFromDraft(normalizedDraft, mcpServers, editIndex >= 0 ? editIndex : undefined);
|
||||
onChange({ customMcpServer: createMcpServerDraftFromConfig(server) });
|
||||
setCustomMcpDialogOpen(false);
|
||||
const tools = await discoverMcpServerTools(server, true);
|
||||
const nextTool = normalizeFusionToolName(tools[0]?.name || "");
|
||||
if (nextTool) {
|
||||
onChange({
|
||||
customMcpServer: createMcpServerDraftFromConfig(server),
|
||||
customToolName: nextTool,
|
||||
toolsText: nextTool,
|
||||
...fusionToolExecutionFlags(nextTool)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
|
|
@ -180,15 +310,215 @@ export function VirtualModelDialog({
|
|||
<Field label={t("Base model")}>
|
||||
<RouteTargetControl modelOptions={modelOptions} onChange={(fixedModel) => onChange({ fixedModel })} value={draft.fixedModel} />
|
||||
</Field>
|
||||
<div className="flex h-5 items-center justify-center font-mono text-[13px] font-semibold text-muted-foreground">+</div>
|
||||
<Field label={t("Tools")}>
|
||||
<FusionToolSelectControl
|
||||
onChange={updateFusionTool}
|
||||
value={selectedTool}
|
||||
<div className="flex h-5 items-center justify-center font-mono text-[13px] font-semibold text-muted-foreground">+</div>
|
||||
<Field label={t("Tools")}>
|
||||
<FusionToolSelectControl
|
||||
mcpServers={availableMcpServers}
|
||||
mcpToolStateByServer={mcpToolStateByServer}
|
||||
onAddCustomMcpTool={openCustomMcpDialog}
|
||||
onChange={updateFusionTool}
|
||||
onDiscoverMcpTools={(server, force) => {
|
||||
if (server) {
|
||||
void discoverMcpServerTools(server, force);
|
||||
return;
|
||||
}
|
||||
discoverVisibleMcpServers();
|
||||
}}
|
||||
selectedMcpServerName={draft.customMcpServer.name}
|
||||
value={selectedTool}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedToolFlags.matchMultimodal ? (
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border border-border/70 bg-muted/25 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t("Vision tool configuration")}</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">{t("Choose a configured gateway model for image understanding.")}</div>
|
||||
</div>
|
||||
<Field label={t("Vision model")}>
|
||||
<RouteTargetControl modelOptions={modelOptions} onChange={(visionModel) => onChange({ visionModel })} value={draft.visionModel} />
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedToolFlags.matchWebSearch ? (
|
||||
<WebSearchToolConfigurationPanel draft={draft} onChange={onChange} />
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">{t(error)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} type="button" variant="outline">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit} onClick={onSubmit} type="button">
|
||||
{mode === "edit" ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{mode === "edit" ? t("Save") : t("Add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<CustomMcpToolDialog
|
||||
draft={customMcpDialogDraft}
|
||||
error={customMcpDialogError}
|
||||
onChange={(patch) => {
|
||||
setCustomMcpDialogDraft((current) => ({
|
||||
...current,
|
||||
...patch
|
||||
}));
|
||||
setCustomMcpDialogError("");
|
||||
}}
|
||||
onClose={() => setCustomMcpDialogOpen(false)}
|
||||
onSubmit={submitCustomMcpDialog}
|
||||
open={customMcpDialogOpen}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchToolConfigurationPanel({
|
||||
draft,
|
||||
onChange
|
||||
}: {
|
||||
draft: VirtualModelDraft;
|
||||
onChange: (patch: Partial<VirtualModelDraft>) => void;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const providerOptions = translateOptions(fusionWebSearchProviderOptions, t);
|
||||
|
||||
function updateProvider(provider: string) {
|
||||
const webSearchProvider = fusionWebSearchProviderOptions.some((option) => option.value === provider)
|
||||
? provider as VirtualModelDraft["webSearchProvider"]
|
||||
: defaultFusionWebSearchProvider;
|
||||
onChange({
|
||||
webSearchEnvRows: createFusionWebSearchEnvRows(webSearchProvider),
|
||||
webSearchProvider
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border border-border/70 bg-muted/25 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t("Web search configuration")}</div>
|
||||
</div>
|
||||
<Field label={t("Search provider")}>
|
||||
<SelectControl onChange={updateProvider} options={providerOptions} value={draft.webSearchProvider} />
|
||||
</Field>
|
||||
<Field label={t("Provider configuration")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
onChange={(webSearchEnvRows) => onChange({ webSearchEnvRows })}
|
||||
rows={draft.webSearchEnvRows}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomMcpToolDialog({
|
||||
draft,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
open
|
||||
}: {
|
||||
draft: VirtualModelDraft["customMcpServer"];
|
||||
error: string;
|
||||
onChange: (patch: Partial<VirtualModelDraft["customMcpServer"]>) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const mcp = draft;
|
||||
const transportOptions = translateOptions(mcpServerTransportOptions, t);
|
||||
const stdioMessageModeOptions = translateOptions(mcpStdioMessageModeOptions, t);
|
||||
|
||||
function updateMcpServer(patch: Partial<VirtualModelDraft["customMcpServer"]>) {
|
||||
onChange(patch);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog className="z-[80]" onOpenChange={(nextOpen) => !nextOpen && onClose()} open={open}>
|
||||
<DialogContent className="max-w-[760px]">
|
||||
<DialogHeader>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle>{t("Add custom MCP")}</DialogTitle>
|
||||
</div>
|
||||
<Button aria-label={t("Close dialog")} onClick={onClose} size="iconSm" title={t("Close")} type="button" variant="ghost">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label={t("MCP server")}>
|
||||
<Input onChange={(event) => updateMcpServer({ name: event.target.value })} value={mcp.name} />
|
||||
</Field>
|
||||
<Field label={t("Transport")}>
|
||||
<SelectControl
|
||||
onChange={(transport) => updateMcpServer({ transport: transport as VirtualModelDraft["customMcpServer"]["transport"] })}
|
||||
options={transportOptions}
|
||||
value={mcp.transport}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{mcp.transport === "stdio" ? (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label={t("Command")}>
|
||||
<Input onChange={(event) => updateMcpServer({ command: event.target.value })} value={mcp.command} />
|
||||
</Field>
|
||||
<Field label={t("Arguments")}>
|
||||
<Input onChange={(event) => updateMcpServer({ argsText: event.target.value })} value={mcp.argsText} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label={t("Working directory")}>
|
||||
<Input onChange={(event) => updateMcpServer({ cwd: event.target.value })} value={mcp.cwd} />
|
||||
</Field>
|
||||
<Field label={t("Stdio message mode")}>
|
||||
<SelectControl
|
||||
onChange={(stdioMessageMode) => updateMcpServer({ stdioMessageMode: stdioMessageMode as VirtualModelDraft["customMcpServer"]["stdioMessageMode"] })}
|
||||
options={stdioMessageModeOptions}
|
||||
value={mcp.stdioMessageMode}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("Environment variables")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
onChange={(envRows) => updateMcpServer({ envRows })}
|
||||
rows={mcp.envRows}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label={t("URL")}>
|
||||
<Input onChange={(event) => updateMcpServer({ url: event.target.value })} value={mcp.url} />
|
||||
</Field>
|
||||
<Field label={t("Headers")}>
|
||||
<KeyValueRowsControl
|
||||
addLabel={t("Add variable")}
|
||||
onChange={(headerRows) => updateMcpServer({ headerRows })}
|
||||
rows={mcp.headerRows}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label={t("Request timeout")}>
|
||||
<Input onChange={(event) => updateMcpServer({ requestTimeoutMs: event.target.value })} type="number" value={mcp.requestTimeoutMs} />
|
||||
</Field>
|
||||
<Field label={t("Startup timeout")}>
|
||||
<Input onChange={(event) => updateMcpServer({ startupTimeoutMs: event.target.value })} type="number" value={mcp.startupTimeoutMs} />
|
||||
</Field>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">{t(error)}</div>
|
||||
) : null}
|
||||
|
|
@ -199,9 +529,9 @@ export function VirtualModelDialog({
|
|||
<Button onClick={onClose} type="button" variant="outline">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button disabled={!canSubmit} onClick={onSubmit} type="button">
|
||||
{mode === "edit" ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{mode === "edit" ? t("Save") : t("Add")}
|
||||
<Button onClick={onSubmit} type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -210,10 +540,24 @@ export function VirtualModelDialog({
|
|||
}
|
||||
|
||||
function FusionToolSelectControl({
|
||||
mcpServers,
|
||||
mcpToolStateByServer,
|
||||
onAddCustomMcpTool,
|
||||
onChange,
|
||||
onDiscoverMcpTools,
|
||||
selectedMcpServerName,
|
||||
value
|
||||
}: {
|
||||
onChange: (value: string) => void;
|
||||
mcpServers: GatewayMcpServerConfig[];
|
||||
mcpToolStateByServer: Record<string, {
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
tools?: GatewayMcpToolInfo[];
|
||||
}>;
|
||||
onAddCustomMcpTool: () => void;
|
||||
onChange: (value: string, server?: GatewayMcpServerConfig) => void;
|
||||
onDiscoverMcpTools: (server?: GatewayMcpServerConfig, force?: boolean) => void;
|
||||
selectedMcpServerName: string;
|
||||
value: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
|
|
@ -226,7 +570,12 @@ function FusionToolSelectControl({
|
|||
width: number;
|
||||
}>();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const selected = fusionToolOptions.find((option) => option.value === normalizeFusionToolName(value)) ?? fusionToolOptions[0];
|
||||
const normalizedValue = normalizeFusionToolName(value);
|
||||
const selected = fusionToolOptions.find((option) => option.value === normalizedValue);
|
||||
const selectedServer = selectedMcpServerName
|
||||
? mcpServers.find((server) => server.name === selectedMcpServerName)
|
||||
: mcpServers.find((server) => mcpToolStateByServer[server.name]?.tools?.some((tool) => tool.name === normalizedValue));
|
||||
const selectedLabel = selected?.label ?? (selectedServer && normalizedValue ? `${selectedServer.name} / ${normalizedValue}` : normalizedValue || t("Select tool"));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -242,7 +591,7 @@ function FusionToolSelectControl({
|
|||
const anchor = root.getBoundingClientRect();
|
||||
const margin = 12;
|
||||
const gap = 6;
|
||||
const desiredHeight = 136;
|
||||
const desiredHeight = 320;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const availableWidth = Math.max(240, viewportWidth - margin * 2);
|
||||
|
|
@ -254,7 +603,7 @@ function FusionToolSelectControl({
|
|||
const availableHeight = Math.max(96, placement === "above" ? above : below);
|
||||
setPopoverLayout({
|
||||
left,
|
||||
maxHeight: Math.min(220, availableHeight),
|
||||
maxHeight: Math.min(360, availableHeight),
|
||||
offset: placement === "above" ? viewportHeight - anchor.top + gap : anchor.bottom + gap,
|
||||
placement,
|
||||
width
|
||||
|
|
@ -270,6 +619,12 @@ function FusionToolSelectControl({
|
|||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
onDiscoverMcpTools();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
|
|
@ -311,11 +666,11 @@ function FusionToolSelectControl({
|
|||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selected?.label}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{selectedLabel}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
|
|
@ -339,22 +694,22 @@ function FusionToolSelectControl({
|
|||
className="w-full overflow-y-auto p-1"
|
||||
id="fusion-tool-select-options"
|
||||
role="listbox"
|
||||
style={{ maxHeight: `${popoverLayout?.maxHeight ?? 220}px` }}
|
||||
>
|
||||
{fusionToolOptions.map((option) => {
|
||||
const selectedOption = option.value === selected?.value;
|
||||
return (
|
||||
<button
|
||||
style={{ maxHeight: `${popoverLayout?.maxHeight ?? 360}px` }}
|
||||
>
|
||||
{fusionToolOptions.map((option) => {
|
||||
const selectedOption = option.value === selected?.value;
|
||||
return (
|
||||
<button
|
||||
aria-selected={selectedOption}
|
||||
className={cn(
|
||||
"flex min-h-[58px] w-full min-w-0 items-start gap-2 rounded-[5px] px-2 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/25",
|
||||
selectedOption ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted"
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -365,11 +720,92 @@ function FusionToolSelectControl({
|
|||
</span>
|
||||
</span>
|
||||
{selectedOption ? <Check className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</motion.div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{mcpServers.length > 0 ? <div className="my-1 border-t border-border/70" /> : null}
|
||||
{mcpServers.map((server) => {
|
||||
const state = mcpToolStateByServer[server.name];
|
||||
const tools = state?.tools ?? [];
|
||||
const serverSelected = selectedMcpServerName === server.name;
|
||||
return (
|
||||
<div className="rounded-[5px] px-1 py-1" key={server.name}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 py-1 text-[11px] font-semibold text-foreground">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={server.name}>{server.name}</span>
|
||||
<button
|
||||
aria-label={`${t("Discover tools")} ${server.name}`}
|
||||
className="rounded-[4px] px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/25"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDiscoverMcpTools(server, true);
|
||||
}}
|
||||
title={mcpServerEndpointSummary(server)}
|
||||
type="button"
|
||||
>
|
||||
{state?.loading ? <LoaderCircle className="h-3 w-3 animate-spin" /> : t("Discover tools")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-3 border-l border-border/70 pl-2">
|
||||
{tools.map((tool) => {
|
||||
const selectedTool = normalizedValue === tool.name && (serverSelected || !selectedMcpServerName);
|
||||
return (
|
||||
<button
|
||||
aria-selected={selectedTool}
|
||||
className={cn(
|
||||
"flex min-h-[44px] w-full min-w-0 items-start gap-2 rounded-[5px] px-2 py-1.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/25",
|
||||
selectedTool ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted"
|
||||
)}
|
||||
key={`${server.name}:${tool.name}`}
|
||||
onClick={() => {
|
||||
onChange(tool.name, server);
|
||||
setOpen(false);
|
||||
}}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] font-semibold">{tool.name}</span>
|
||||
{tool.description ? (
|
||||
<span className={cn("mt-0.5 line-clamp-2 text-[11px] leading-4", selectedTool ? "text-primary/80" : "text-muted-foreground")}>
|
||||
{tool.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{selectedTool ? <Check className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{state?.loading && tools.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-[11px] text-muted-foreground">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
<span>{t("Discover tools")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!state?.loading && state?.tools && tools.length === 0 && !state.error ? (
|
||||
<div className="px-2 py-2 text-[11px] text-muted-foreground">{t("No tools discovered")}</div>
|
||||
) : null}
|
||||
{state?.error ? (
|
||||
<div className="px-2 py-2 text-[11px] text-destructive" title={state.error}>{t("Tool discovery failed")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="my-1 border-t border-border/70" />
|
||||
<button
|
||||
className="flex min-h-[36px] w-full min-w-0 items-center gap-2 rounded-[5px] px-2 py-2 text-left text-[12px] font-semibold text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/25"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddCustomMcpTool();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{t("Add custom MCP")}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
AppConfig, createSourceTabs, DEFAULT_TRAY_WIDGETS, defaultTrayWidgetVariant, emptySnapshots, formatCompactNumber, formatProviderName,
|
||||
formatUpdated, formatUsdCost, normalizeTrayIconPreference, normalizeTrayWidgets, ProviderAccountSnapshot, rangeLabel,
|
||||
SnapshotMap, SourceTab, TrayComponentVariants, TrayWidgetConfig, UsageComparisonRow, UsageStatsFilter, UsageTotals, useCallback, useEffect,
|
||||
formatPercent, formatUpdated, formatUsdCost, normalizeTrayIconPreference, normalizeTrayWidgets, ProviderAccountSnapshot, rangeLabel,
|
||||
SnapshotMap, SourceTab, TrayComponentVariants, TrayWidgetConfig, UsageComparisonRow, UsageStatsFilter, UsageStatsRange, UsageTotals, useCallback, useEffect,
|
||||
useMemo, useState, useTrayText
|
||||
} from "./shared";
|
||||
import {
|
||||
|
|
@ -9,6 +9,10 @@ import {
|
|||
SourceGrid, StatsGrid, TokenMixPanel, TrayStatusStrip
|
||||
} from "./components";
|
||||
|
||||
type TrayHeaderRange = Exclude<UsageStatsRange, "today">;
|
||||
|
||||
const trayHeaderRanges: TrayHeaderRange[] = ["24h", "7d", "30d"];
|
||||
|
||||
export function TrayApp() {
|
||||
const t = useTrayText();
|
||||
const [allSnapshots, setAllSnapshots] = useState<SnapshotMap>(emptySnapshots);
|
||||
|
|
@ -20,6 +24,7 @@ export function TrayApp() {
|
|||
const [snapshots, setSnapshots] = useState<SnapshotMap>(emptySnapshots);
|
||||
const [accountSnapshots, setAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
|
||||
const [trayWidgets, setTrayWidgets] = useState<TrayWidgetConfig[]>(DEFAULT_TRAY_WIDGETS);
|
||||
const [selectedRange, setSelectedRange] = useState<TrayHeaderRange>("30d");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!window.ccr) {
|
||||
|
|
@ -82,11 +87,9 @@ export function TrayApp() {
|
|||
}, [refresh]);
|
||||
|
||||
const tabs = useMemo(() => createSourceTabs(allSnapshots["30d"].models, configuredProviders), [allSnapshots, configuredProviders]);
|
||||
const activeStats = snapshots["30d"];
|
||||
const todayTotals = snapshots.today.totals;
|
||||
const weekTotals = snapshots["7d"].totals;
|
||||
const monthTotals = snapshots["30d"].totals;
|
||||
const topModel = snapshots["30d"].models[0];
|
||||
const activeStats = snapshots[selectedRange];
|
||||
const activeTotals = activeStats.totals;
|
||||
const topModel = activeStats.models[0];
|
||||
const hasProviderSwitcher = trayWidgets.some((widget) => widget.type === "source-tabs");
|
||||
const hasAnyVisibleModule = trayWidgets.length > 0;
|
||||
|
||||
|
|
@ -109,22 +112,22 @@ export function TrayApp() {
|
|||
return (
|
||||
<main className="h-screen w-screen overflow-hidden bg-transparent text-slate-100">
|
||||
<aside className="flex h-full min-h-0 flex-col overflow-y-auto rounded-[14px] border border-slate-950/15 bg-slate-950 p-3 text-slate-50 shadow-[0_18px_42px_rgba(15,23,42,.28)]">
|
||||
<TrayStatusStrip totalTokens={activeStats.totals.totalTokens} trayIconPreference={trayIconPreference} />
|
||||
<TrayStatusStrip totalTokens={activeTotals.totalTokens} trayIconPreference={trayIconPreference} />
|
||||
|
||||
<section className="space-y-2">
|
||||
{trayWidgets.map((widget, index) => (
|
||||
<TrayRuntimeWidget
|
||||
accountSnapshots={accountSnapshots}
|
||||
activeStats={activeStats}
|
||||
activeTotals={activeTotals}
|
||||
index={index}
|
||||
key={`${widget.id}-${index}`}
|
||||
monthTotals={monthTotals}
|
||||
selectedRange={selectedRange}
|
||||
selectedProvider={selectedProvider}
|
||||
tabs={tabs}
|
||||
todayTotals={todayTotals}
|
||||
topModel={topModel}
|
||||
weekTotals={weekTotals}
|
||||
widget={widget}
|
||||
onChangeRange={setSelectedRange}
|
||||
onSelectProvider={setSelectedProvider}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -147,26 +150,26 @@ export function TrayApp() {
|
|||
function TrayRuntimeWidget({
|
||||
accountSnapshots,
|
||||
activeStats,
|
||||
activeTotals,
|
||||
index,
|
||||
monthTotals,
|
||||
selectedRange,
|
||||
selectedProvider,
|
||||
tabs,
|
||||
todayTotals,
|
||||
topModel,
|
||||
weekTotals,
|
||||
widget,
|
||||
onChangeRange,
|
||||
onSelectProvider
|
||||
}: {
|
||||
accountSnapshots: ProviderAccountSnapshot[];
|
||||
activeStats: SnapshotMap["30d"];
|
||||
activeTotals: UsageTotals;
|
||||
index: number;
|
||||
monthTotals: UsageTotals;
|
||||
selectedRange: TrayHeaderRange;
|
||||
selectedProvider?: string;
|
||||
tabs: SourceTab[];
|
||||
todayTotals: UsageTotals;
|
||||
topModel?: UsageComparisonRow;
|
||||
weekTotals: UsageTotals;
|
||||
widget: TrayWidgetConfig;
|
||||
onChangeRange: (range: TrayHeaderRange) => void;
|
||||
onSelectProvider: (provider?: string) => void;
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
|
|
@ -182,7 +185,7 @@ function TrayRuntimeWidget({
|
|||
<h1 className="truncate text-[13px] font-bold text-slate-50">{selectedProvider ? formatProviderName(selectedProvider) : t("Usage Overview")}</h1>
|
||||
<p className="mt-0.5 truncate text-[10px] font-medium text-slate-400">{formatUpdated(activeStats.generatedAt, t)}</p>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-md border border-white/10 bg-slate-900/70 px-2 py-0.5 text-[10px] font-semibold text-slate-200">{rangeLabel("30d", t)}</div>
|
||||
<TrayHeaderRangeSwitch range={selectedRange} onChange={onChangeRange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -193,7 +196,7 @@ function TrayRuntimeWidget({
|
|||
|
||||
if (widget.type === "token-flow") {
|
||||
return (
|
||||
<ChartShell meta={topModel?.label ?? t("No model yet")} title={`${t("30d")} ${t("Token Flow")}`}>
|
||||
<ChartShell meta={topModel?.label ?? t("No model yet")} title={`${rangeLabel(selectedRange, t)} ${t("Token Flow")}`}>
|
||||
<AnimatedUsageChart chartId={`overview-flow-${index}`} series={activeStats.series} variant={(widget.variant ?? defaultTrayWidgetVariant("token-flow")) as TrayComponentVariants["tokenFlow"]} />
|
||||
</ChartShell>
|
||||
);
|
||||
|
|
@ -203,11 +206,10 @@ function TrayRuntimeWidget({
|
|||
return (
|
||||
<StatsGrid
|
||||
items={[
|
||||
{ label: t("Today tokens"), value: formatCompactNumber(todayTotals.totalTokens) },
|
||||
{ label: `${t("7d")} ${t("tokens")}`, value: formatCompactNumber(weekTotals.totalTokens) },
|
||||
{ label: `${t("30d")} ${t("tokens")}`, value: formatCompactNumber(monthTotals.totalTokens) },
|
||||
{ label: t("Today req"), value: formatCompactNumber(todayTotals.requestCount) },
|
||||
{ label: `${t("Today")} ${t("Cost")}`, value: formatUsdCost(todayTotals.costUsd) }
|
||||
{ label: `${rangeLabel(selectedRange, t)} ${t("tokens")}`, value: formatCompactNumber(activeTotals.totalTokens) },
|
||||
{ label: `${rangeLabel(selectedRange, t)} ${t("requests")}`, value: formatCompactNumber(activeTotals.requestCount) },
|
||||
{ label: `${rangeLabel(selectedRange, t)} ${t("Cost")}`, value: formatUsdCost(activeTotals.costUsd) },
|
||||
{ label: t("Success rate"), value: formatPercent(activeTotals.successRate) }
|
||||
]}
|
||||
variant={(widget.variant ?? defaultTrayWidgetVariant("stats")) as TrayComponentVariants["stats"]}
|
||||
/>
|
||||
|
|
@ -215,12 +217,37 @@ function TrayRuntimeWidget({
|
|||
}
|
||||
|
||||
if (widget.type === "token-mix") {
|
||||
return <TokenMixPanel totals={monthTotals} variant={(widget.variant ?? defaultTrayWidgetVariant("token-mix")) as TrayComponentVariants["tokenMix"]} />;
|
||||
return <TokenMixPanel totals={activeTotals} variant={(widget.variant ?? defaultTrayWidgetVariant("token-mix")) as TrayComponentVariants["tokenMix"]} />;
|
||||
}
|
||||
|
||||
if (widget.type === "rings") {
|
||||
return <RingMetrics totals={monthTotals} variant={(widget.variant ?? defaultTrayWidgetVariant("rings")) as TrayComponentVariants["rings"]} />;
|
||||
return <RingMetrics totals={activeTotals} variant={(widget.variant ?? defaultTrayWidgetVariant("rings")) as TrayComponentVariants["rings"]} />;
|
||||
}
|
||||
|
||||
return <ModelShareChart rows={activeStats.models} variant={(widget.variant ?? defaultTrayWidgetVariant("model-share")) as TrayComponentVariants["modelShare"]} />;
|
||||
}
|
||||
|
||||
function TrayHeaderRangeSwitch({
|
||||
range,
|
||||
onChange
|
||||
}: {
|
||||
range: TrayHeaderRange;
|
||||
onChange: (range: TrayHeaderRange) => void;
|
||||
}) {
|
||||
const t = useTrayText();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 rounded-md border border-white/10 bg-slate-900/70 p-0.5">
|
||||
{trayHeaderRanges.map((item) => (
|
||||
<button
|
||||
className={`h-5 rounded-[5px] px-1.5 text-[10px] font-bold transition ${range === item ? "bg-white/14 text-slate-50" : "text-slate-400 hover:text-slate-100"}`}
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => onChange(item)}
|
||||
>
|
||||
{rangeLabel(item, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,9 +226,11 @@ function RingMetric({
|
|||
const clamped = Math.max(0, Math.min(1, value));
|
||||
const stroke = clamped > 0.8 ? "rgb(45,212,191)" : "rgb(129,140,248)";
|
||||
return (
|
||||
<div className="relative aspect-square min-w-0 text-center">
|
||||
<RadialMetric color={stroke} label={formatPercent(clamped)} value={clamped} variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"} />
|
||||
<div className="absolute inset-x-0 bottom-1 truncate px-1 text-[8px] font-medium text-slate-400">{label}</div>
|
||||
<div className="flex min-w-0 flex-col items-center text-center">
|
||||
<div className="aspect-square w-full min-w-0">
|
||||
<RadialMetric color={stroke} label={formatPercent(clamped)} value={clamped} variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"} />
|
||||
</div>
|
||||
<div className="mt-1 w-full truncate px-1 text-[10px] font-semibold leading-none text-slate-300">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Power } from "lucide-react";
|
|||
import trayCyanIconUrl from "../../../../assets/tray-cyan.png";
|
||||
import trayOrangeIconUrl from "../../../../assets/tray-orange.png";
|
||||
import trayVioletIconUrl from "../../../../assets/tray-violet.png";
|
||||
import { DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../../../shared/app";
|
||||
import { DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../../../shared/app";
|
||||
import type {
|
||||
AppConfig,
|
||||
ProviderAccountMeter,
|
||||
|
|
@ -23,7 +23,7 @@ import type {
|
|||
|
||||
export {
|
||||
createContext, useCallback, useContext, useEffect, useMemo, useState, createRoot,
|
||||
Power, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS
|
||||
Power, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS
|
||||
};
|
||||
export type {
|
||||
ReactNode, AppConfig, ProviderAccountMeter, ProviderAccountSnapshot, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow,
|
||||
|
|
@ -238,13 +238,13 @@ export function normalizeTrayComponentVariants(value: unknown): TrayComponentVar
|
|||
export function normalizeTrayWidgets(value: unknown, fallbackModules?: unknown, fallbackVariants?: unknown): TrayWidgetConfig[] {
|
||||
if (!Array.isArray(value)) {
|
||||
if (Array.isArray(fallbackModules)) {
|
||||
return dedupeTraySingletonWidgets(trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules as AppConfig["trayWindowModules"]), normalizeTrayComponentVariants(fallbackVariants)));
|
||||
return orderTrayWidgetsForLayout(dedupeTraySingletonWidgets(trayWidgetsFromModules(normalizeTrayWindowModules(fallbackModules as AppConfig["trayWindowModules"]), normalizeTrayComponentVariants(fallbackVariants))));
|
||||
}
|
||||
return DEFAULT_TRAY_WIDGETS.map((widget) => ({ ...widget }));
|
||||
}
|
||||
return dedupeTraySingletonWidgets(value
|
||||
return orderTrayWidgetsForLayout(dedupeTraySingletonWidgets(value
|
||||
.map(normalizeTrayWidget)
|
||||
.filter((widget): widget is TrayWidgetConfig => Boolean(widget)));
|
||||
.filter((widget): widget is TrayWidgetConfig => Boolean(widget))));
|
||||
}
|
||||
|
||||
export function normalizeTrayWidget(value: unknown): TrayWidgetConfig | undefined {
|
||||
|
|
@ -345,6 +345,17 @@ export function isTraySingletonWidgetType(type: TrayWidgetType): boolean {
|
|||
return (TRAY_SINGLETON_WIDGET_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function isTrayPinnedTopWidgetType(type: TrayWidgetType): boolean {
|
||||
return (TRAY_TOP_WIDGET_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function orderTrayWidgetsForLayout(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
|
||||
return [
|
||||
...widgets.filter((widget) => isTrayPinnedTopWidgetType(widget.type)),
|
||||
...widgets.filter((widget) => !isTrayPinnedTopWidgetType(widget.type))
|
||||
];
|
||||
}
|
||||
|
||||
function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConfig[] {
|
||||
const seenSingletons = new Set<TrayWidgetType>();
|
||||
return widgets.filter((widget) => {
|
||||
|
|
@ -360,7 +371,7 @@ function dedupeTraySingletonWidgets(widgets: TrayWidgetConfig[]): TrayWidgetConf
|
|||
}
|
||||
|
||||
export function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants: TrayComponentVariants): TrayWidgetConfig[] {
|
||||
return modules
|
||||
return orderTrayWidgetsForLayout(modules
|
||||
.filter((moduleId): moduleId is TrayWidgetType => moduleId !== "footer")
|
||||
.map((type) => ({
|
||||
id: trayWidgetId(type),
|
||||
|
|
@ -371,7 +382,7 @@ export function trayWidgetsFromModules(modules: TrayWindowModuleId[], variants:
|
|||
...((type === "stats") ? { variant: variants.stats } : {}),
|
||||
...((type === "token-flow") ? { variant: variants.tokenFlow } : {}),
|
||||
...((type === "token-mix") ? { variant: variants.tokenMix } : {})
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
export function normalizeEnumValue<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
|
|
|
|||
|
|
@ -306,7 +306,10 @@
|
|||
}
|
||||
|
||||
.app-no-drag,
|
||||
.app-sidebar {
|
||||
.app-no-drag *,
|
||||
.app-sidebar,
|
||||
.app-window-controls,
|
||||
.app-window-controls * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
|
|
|||
8
src/renderer/types/electron.d.ts
vendored
8
src/renderer/types/electron.d.ts
vendored
|
|
@ -7,11 +7,16 @@ import type {
|
|||
AppInfo,
|
||||
ApiKeyConfig,
|
||||
ClaudeAppGatewayApplyResult,
|
||||
GatewayMcpServerConfig,
|
||||
GatewayMcpToolInfo,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayStatus,
|
||||
PluginDirectorySelection,
|
||||
PluginMarketplaceEntry,
|
||||
ProfileOpenCommandResult,
|
||||
ProfileOpenRequest,
|
||||
ProfileOpenResult,
|
||||
ProviderAccountTestRequest,
|
||||
ProviderAccountTestResult,
|
||||
ProviderIconDetectionRequest,
|
||||
|
|
@ -47,6 +52,7 @@ declare global {
|
|||
getGatewayStatus: () => Promise<GatewayStatus>;
|
||||
getOnboardingFinished: () => Promise<boolean>;
|
||||
getPendingProviderDeepLinks: () => Promise<ProviderDeepLinkRequest[]>;
|
||||
getProfileOpenCommand: (request: ProfileOpenRequest) => Promise<ProfileOpenCommandResult>;
|
||||
getProviderAccountSnapshots: (provider?: string) => Promise<ProviderAccountSnapshot[]>;
|
||||
getPluginMarketplace: () => Promise<PluginMarketplaceEntry[]>;
|
||||
getProxyCertificateStatus: () => Promise<ProxyCertificateStatus>;
|
||||
|
|
@ -55,8 +61,10 @@ declare global {
|
|||
getRequestLogs: (filter?: RequestLogListFilter) => Promise<RequestLogPage>;
|
||||
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise<UsageStatsSnapshot>;
|
||||
installProxyCertificate: () => Promise<ProxyCertificateInstallResult>;
|
||||
listMcpServerTools: (server: GatewayMcpServerConfig) => Promise<GatewayMcpToolInfo[]>;
|
||||
openBuiltInBrowser: () => Promise<void>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
openProfile: (request: ProfileOpenRequest) => Promise<ProfileOpenResult>;
|
||||
probeProvider: (request: GatewayProviderProbeRequest) => Promise<GatewayProviderProbeResult>;
|
||||
quitApp: () => Promise<void>;
|
||||
revealProxyCertificate: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ export type AppInfo = {
|
|||
version: string;
|
||||
};
|
||||
|
||||
export const BUILTIN_UNIMCP_PACKAGE = "@musistudio/unimcp";
|
||||
export const BUILTIN_UNIMCP_SERVER_NAME = "unimcp";
|
||||
export const BUILTIN_UNIMCP_VISION_TOOL_NAME = "vision_understand";
|
||||
export const BUILTIN_UNIMCP_WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
export const BUILTIN_FUSION_TOOL_SERVER_NAME = "ccr-fusion-builtins";
|
||||
export const BUILTIN_FUSION_VISION_TOOL_NAME = "vision_understand";
|
||||
export const BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
|
||||
export type GatewayProviderProtocol =
|
||||
| "openai_responses"
|
||||
|
|
@ -365,6 +364,12 @@ export type GatewayAgentConfig = {
|
|||
mcpServers: GatewayMcpServerConfig[];
|
||||
};
|
||||
|
||||
export type GatewayMcpToolInfo = {
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VirtualModelMatchConfig = {
|
||||
exactAliases: string[];
|
||||
prefixes: string[];
|
||||
|
|
@ -412,6 +417,37 @@ export type VirtualModelMaterializationConfig = {
|
|||
includeInGatewayModels: boolean;
|
||||
};
|
||||
|
||||
export type VirtualModelFusionVisionConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
modelSelector?: string;
|
||||
timeoutMs?: number;
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
export type VirtualModelFusionWebSearchProvider =
|
||||
| "brave"
|
||||
| "bing"
|
||||
| "google_cse"
|
||||
| "serper"
|
||||
| "serpapi"
|
||||
| "tavily"
|
||||
| "exa";
|
||||
|
||||
export type VirtualModelFusionWebSearchConfig = {
|
||||
env?: Record<string, string>;
|
||||
provider?: VirtualModelFusionWebSearchProvider;
|
||||
resultCount?: number;
|
||||
timeoutMs?: number;
|
||||
toolName?: string;
|
||||
};
|
||||
|
||||
export type VirtualModelFusionCustomToolConfig = {
|
||||
env?: Record<string, string>;
|
||||
mcpServerName?: string;
|
||||
};
|
||||
|
||||
export type VirtualModelProfileConfig = {
|
||||
baseModel?: VirtualModelBaseModelConfig;
|
||||
description?: string;
|
||||
|
|
@ -558,8 +594,11 @@ export type OverviewWidgetVariant =
|
|||
| "composed"
|
||||
| "donut"
|
||||
| "line"
|
||||
| "arc"
|
||||
| "nested-rings"
|
||||
| "pie"
|
||||
| "ring"
|
||||
| "semicircle"
|
||||
| "stacked"
|
||||
| "table"
|
||||
| "timeline";
|
||||
|
|
@ -577,6 +616,7 @@ export type OverviewMetricKind =
|
|||
| "total-tokens";
|
||||
|
||||
export type OverviewWidgetConfig = {
|
||||
accountProvider?: string;
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
metric?: OverviewMetricKind;
|
||||
|
|
@ -615,6 +655,7 @@ export const TRAY_WINDOW_MODULE_IDS = [
|
|||
export type TrayWindowModuleId = (typeof TRAY_WINDOW_MODULE_IDS)[number];
|
||||
export type TrayWidgetType = Exclude<TrayWindowModuleId, "footer">;
|
||||
export const TRAY_SINGLETON_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[];
|
||||
export const TRAY_TOP_WIDGET_TYPES = ["source-tabs", "header"] as const satisfies readonly TrayWidgetType[];
|
||||
|
||||
export type TrayWidgetConfig = {
|
||||
id: string;
|
||||
|
|
@ -639,6 +680,7 @@ export type CodexProfileConfigFormat = "legacy" | "separate_profile_files";
|
|||
export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code";
|
||||
export type ProfileScope = "ccr" | "global" | "custom";
|
||||
export type ProfileSurface = "auto" | "cli" | "app";
|
||||
export type ProfileOpenSurface = "cli" | "app";
|
||||
|
||||
export type ClaudeCodeProfileConfig = {
|
||||
enabled: boolean;
|
||||
|
|
@ -706,6 +748,25 @@ export type ProfileApplyResult = {
|
|||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type ProfileOpenRequest = {
|
||||
profileId: string;
|
||||
surface: ProfileOpenSurface;
|
||||
};
|
||||
|
||||
export type ProfileOpenCommandResult = {
|
||||
command: string;
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
surface: ProfileOpenSurface;
|
||||
};
|
||||
|
||||
export type ProfileOpenResult = {
|
||||
message: string;
|
||||
profileId: string;
|
||||
profileName: string;
|
||||
surface: ProfileOpenSurface;
|
||||
};
|
||||
|
||||
export type ApiKeyLimitConfig = {
|
||||
ipd?: number;
|
||||
iph?: number;
|
||||
|
|
@ -901,6 +962,7 @@ export type RequestLogEntry = {
|
|||
error?: string;
|
||||
id: number;
|
||||
inputTokens: number;
|
||||
isStream: boolean;
|
||||
method: string;
|
||||
model: string;
|
||||
ok: boolean;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const IPC_CHANNELS = {
|
|||
appGetInfo: "ccr:app:get-info",
|
||||
appGetOnboardingFinished: "ccr:app:get-onboarding-finished",
|
||||
appGetPendingProviderDeepLinks: "ccr:app:get-pending-provider-deep-links",
|
||||
appGetProfileOpenCommand: "ccr:app:get-profile-open-command",
|
||||
appGetProviderAccountSnapshots: "ccr:app:get-provider-account-snapshots",
|
||||
appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status",
|
||||
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
|
||||
|
|
@ -16,8 +17,10 @@ export const IPC_CHANNELS = {
|
|||
appGetUsageStats: "ccr:app:get-usage-stats",
|
||||
appFetchProviderManifest: "ccr:app:fetch-provider-manifest",
|
||||
appInstallProxyCertificate: "ccr:app:install-proxy-certificate",
|
||||
appListMcpServerTools: "ccr:app:list-mcp-server-tools",
|
||||
appOpenBuiltInBrowser: "ccr:app:open-built-in-browser",
|
||||
appOpenExternal: "ccr:app:open-external",
|
||||
appOpenProfile: "ccr:app:open-profile",
|
||||
appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway",
|
||||
appApplyProfile: "ccr:app:apply-profile",
|
||||
appProbeProvider: "ccr:app:probe-provider",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue