Refactor router config and request handling

This commit is contained in:
musi 2026-06-17 13:32:07 +08:00
parent 9eec586e60
commit ff1a58b6dc
11 changed files with 1138 additions and 453 deletions

4
.idea/shelf/_2025_7_27_00_07____.xml generated Normal file
View file

@ -0,0 +1,4 @@
<changelist name="在进行更新之前于_2025_7_27_00_07_取消提交了更改_[更改]" date="1753546032560" recycled="true" deleted="true">
<option name="PATH" value="$PROJECT_DIR$/.idea/shelf/在进行更新之前于_2025_7_27_00_07_取消提交了更改_[更改]/shelved.patch" />
<option name="DESCRIPTION" value="在进行更新之前于 2025/7/27 00:07 取消提交了更改 [更改]" />
</changelist>

File diff suppressed because one or more lines are too long

79
.idea/workspace.xml generated Normal file
View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="d8f55048-965f-4d85-9edd-2df060b36bda" name="更改" comment="">
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/cli.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/cli.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/utils/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/utils/index.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/utils/processCheck.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/utils/processCheck.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="GitHubPullRequestSearchHistory"><![CDATA[{
"lastFilter": {
"state": "OPEN",
"assignee": "musistudio"
}
}]]></component>
<component name="GithubPullRequestsUISettings"><![CDATA[{
"selectedUrlAndAccountId": {
"url": "git@github.com:musistudio/claude-code-router.git",
"accountId": "37e12a60-eff0-427f-98f9-9bd34a2cf265"
}
}]]></component>
<component name="ProjectColorInfo"><![CDATA[{
"customColor": "",
"associatedIndex": 4
}]]></component>
<component name="ProjectId" id="30Q3ZGTbLgOyQYD51sqypPN33wM" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "main",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-09060db00ec0-JavaScript-WS-251.26927.40" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="d8f55048-965f-4d85-9edd-2df060b36bda" name="更改" comment="" />
<created>1753546022239</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1753546022239</updated>
<workItem from="1753546023362" duration="485000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

1052
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
"@the-next-ai/ai-gateway": "file:../next-ai/ai-gateway",
"node-forge": "^1.4.0",
"sql.js": "^1.14.1",
"undici": "^7.27.2"

View file

@ -5,6 +5,7 @@ export { IPC_CHANNELS } from "../shared/ipc-channels";
export const APP_NAME = "Claude Code Router";
export const CONFIGDIR = path.join(app.getPath("home"), ".claude-code-router");
export const CONFIG_FILE = path.join(CONFIGDIR, "config.json");
export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished");
export const DATADIR = app.getPath("userData");
export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite");
export const CERTDIR = path.join(DATADIR, "certs");

View file

@ -1,10 +1,10 @@
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from "electron";
import { existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { builtInBrowserService } from "./built-in-browser";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config";
import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
import { deepLinkService } from "./deep-link";
import { gatewayService } from "./gateway/service";
import { probeGatewayProvider } from "./provider-probe";
@ -60,6 +60,7 @@ 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.appGetAgentAnalysis, (_event, filter?: AgentAnalysisFilter) => getAgentAnalysis(filter));
ipcMain.handle(IPC_CHANNELS.appGetGatewayStatus, () => gatewayService.getStatus());
@ -142,6 +143,11 @@ ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig
await applyProfileIfServiceRunning(savedConfig, gatewayService.getStatus());
return savedConfig;
});
ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, () => {
mkdirSync(CONFIGDIR, { recursive: true });
writeFileSync(ONBOARDING_FINISHED_FILE, `${new Date().toISOString()}\n`, "utf8");
return true;
});
ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
const config = await loadAppConfig();
const status = await gatewayService.start(config);

View file

@ -32,6 +32,7 @@ contextBridge.exposeInMainWorld("ccr", {
getAppInfo: () => ipcRenderer.invoke(IPC_CHANNELS.appGetInfo) as Promise<AppInfo>,
getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.appGetConfig) as Promise<AppConfig>,
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[]>,
getPluginMarketplace: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPluginMarketplace) as Promise<PluginMarketplaceEntry[]>,
getProxyCertificateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise<ProxyCertificateStatus>,
@ -50,6 +51,7 @@ contextBridge.exposeInMainWorld("ccr", {
saveApiKeys: (apiKeys: ApiKeyConfig[]) => ipcRenderer.invoke(IPC_CHANNELS.appSaveApiKeys, apiKeys) as Promise<AppConfig>,
saveConfig: (config: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appSaveConfig, config) as Promise<AppConfig>,
selectPluginDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.appSelectPluginDirectory) as Promise<PluginDirectorySelection | undefined>,
setOnboardingFinished: () => ipcRenderer.invoke(IPC_CHANNELS.appSetOnboardingFinished) as Promise<boolean>,
setProxyNetworkCaptureEnabled: (enabled: boolean) => ipcRenderer.invoke(IPC_CHANNELS.appSetProxyNetworkCaptureEnabled, enabled) as Promise<ProxyNetworkSnapshot>,
setTrayDetailOpen: (open: boolean, provider?: string) => ipcRenderer.invoke(IPC_CHANNELS.appSetTrayDetailOpen, open, provider) as Promise<void>,
showMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.appShowMainWindow) as Promise<void>,

View file

@ -188,7 +188,6 @@ type AppCopy = {
};
const languagePreferenceStorageKey = "ccr.ui.language";
const onboardingCompletionStorageKey = "ccr.onboarding.completed";
const appCopy: Record<ResolvedLanguage, AppCopy> = {
en: {
@ -839,7 +838,7 @@ const appCopy: Record<ResolvedLanguage, AppCopy> = {
"System Proxy": "系统代理",
"This action is applied immediately to the draft config and will auto-save with other changes.": "此操作会立即应用到草稿配置,并随其他变更自动保存。",
"This provider link came from an external website. Review details before importing.": "这个供应商链接来自外部网站。导入前请确认下面的内容。",
"Three steps to connect a provider to your agent.": "三步把供应商接入你的 Agent。",
"Welcome to CCR": "欢迎使用CCR",
"Trusted": "已信任",
"Small fast model": "小模型",
"Unknown": "未知",
@ -1014,6 +1013,34 @@ const navigation: Array<{ icon: LucideIcon; id: NavigationId }> = [
const onboardingStepOrder: OnboardingStepId[] = ["provider", "profile", "enter"];
function isOnboardingProviderReady(config: AppConfig): boolean {
return config.Providers.length > 0;
}
function isOnboardingProfileReady(config: AppConfig): boolean {
return config.profile.enabled && config.profile.profiles.some((profile) => profile.enabled);
}
function getDefaultOnboardingStep(config: AppConfig): OnboardingStepId {
if (!isOnboardingProviderReady(config)) {
return "provider";
}
if (!isOnboardingProfileReady(config)) {
return "profile";
}
return "enter";
}
function getNextOnboardingStep(activeStep: OnboardingStepId, config: AppConfig): OnboardingStepId | undefined {
const activeIndex = onboardingStepOrder.indexOf(activeStep);
for (const step of onboardingStepOrder.slice(activeIndex + 1)) {
if (step === "enter" || step === getDefaultOnboardingStep(config)) {
return step;
}
}
return undefined;
}
const motionEase = [0.22, 1, 0.36, 1] as const;
const reducedMotionTransition = { duration: 0.12, ease: "easeOut" } as const;
const pageSpringTransition = { damping: 34, mass: 0.78, stiffness: 420, type: "spring" } as const;
@ -1536,10 +1563,12 @@ type AppToast = {
};
function App() {
const [activeView, setActiveView] = useState<ViewId>(() => readOnboardingCompleted() ? "overview" : "onboarding");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>("provider");
const [activeView, setActiveView] = useState<ViewId>("onboarding");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(() => getDefaultOnboardingStep(fallbackConfig));
const [appInfo, setAppInfo] = useState<AppInfo>(fallbackInfo);
const [draftConfig, setDraftConfig] = useState<AppConfig>(fallbackConfig);
const [configLoaded, setConfigLoaded] = useState(() => !window.ccr);
const [onboardingStatusLoaded, setOnboardingStatusLoaded] = useState(() => !window.ccr);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus>(fallbackGatewayStatus);
const [proxyCertificateStatus, setProxyCertificateStatus] = useState<ProxyCertificateStatus>(fallbackProxyCertificateStatus);
const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState<ProxyNetworkSnapshot>(fallbackProxyNetworkSnapshot);
@ -1661,7 +1690,16 @@ function App() {
}
void window.ccr.getAppInfo().then(setAppInfo);
void window.ccr.getConfig().then(syncConfigState);
void window.ccr.getConfig()
.then(syncConfigState)
.catch(() => {
// Fall back to the bundled defaults; the rest of the UI can still render.
})
.finally(() => setConfigLoaded(true));
void window.ccr.getOnboardingFinished()
.then((finished) => setActiveView(finished ? "overview" : "onboarding"))
.catch(() => setActiveView("onboarding"))
.finally(() => setOnboardingStatusLoaded(true));
void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([]));
void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus);
const refreshRuntimeStatus = () => {
@ -1907,6 +1945,18 @@ function App() {
}
}, [activeView, networkCaptureEnabled]);
useEffect(() => {
if (activeView !== "onboarding" || !configLoaded || !onboardingStatusLoaded) {
return;
}
const defaultStep = getDefaultOnboardingStep(draftConfig);
const defaultIndex = onboardingStepOrder.indexOf(defaultStep);
setOnboardingStep((current) => {
const currentIndex = onboardingStepOrder.indexOf(current);
return defaultIndex > currentIndex ? defaultStep : current;
});
}, [activeView, configLoaded, onboardingStatusLoaded, draftConfig]);
useEffect(() => () => {
if (toastTimer.current !== undefined) {
window.clearTimeout(toastTimer.current);
@ -2237,7 +2287,7 @@ function App() {
setProviderEditIndex(undefined);
setProviderAddOpen(false);
if (activeView === "onboarding") {
setOnboardingStep("profile");
setOnboardingStep(getDefaultOnboardingStep(next));
}
}
}
@ -2277,7 +2327,7 @@ function App() {
setProviderDeepLinkRequest(undefined);
showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${importedProviderName}`.trim());
if (activeView === "onboarding") {
setOnboardingStep("profile");
setOnboardingStep(getDefaultOnboardingStep(next));
}
}
} catch (error) {
@ -2909,8 +2959,15 @@ function App() {
}
}
function completeOnboarding() {
persistOnboardingCompleted();
async function completeOnboarding() {
if (window.ccr) {
try {
await window.ccr.setOnboardingFinished();
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error));
return;
}
}
setActiveView("overview");
}
@ -3183,18 +3240,20 @@ function App() {
{activeView === "onboarding" ? (
<main className="relative flex min-h-0 min-w-0 flex-1 overflow-auto bg-background px-5 pb-5 pt-12 max-[720px]:px-3 max-[720px]:pb-3">
<div className="app-drag absolute inset-x-0 top-0 z-10 h-10" />
<OnboardingView
activeStep={onboardingStep}
config={draftConfig}
endpoint={gatewayEndpoint}
gatewayStatus={gatewayStatus}
onAddProfile={openAddProfileDialog}
onAddProvider={openAddProviderDialog}
onComplete={completeOnboarding}
onOpenProfiles={() => setActiveView("profile")}
onOpenProviders={() => setActiveView("providers")}
onSelectStep={setOnboardingStep}
/>
{configLoaded && onboardingStatusLoaded ? (
<OnboardingView
activeStep={onboardingStep}
config={draftConfig}
endpoint={gatewayEndpoint}
gatewayStatus={gatewayStatus}
onAddProfile={openAddProfileDialog}
onAddProvider={openAddProviderDialog}
onComplete={completeOnboarding}
onOpenProfiles={() => setActiveView("profile")}
onOpenProviders={() => setActiveView("providers")}
onSelectStep={setOnboardingStep}
/>
) : null}
</main>
) : (
<>
@ -3748,13 +3807,13 @@ function OnboardingView({
const t = useAppText();
const shouldReduceMotion = useReducedMotion();
const [selectedAgent, setSelectedAgent] = useState<ProfileConfig["agent"]>("claude-code");
const providerReady = config.Providers.length > 0;
const profileReady = config.profile.enabled && config.profile.profiles.some((profile) => profile.enabled);
const providerReady = isOnboardingProviderReady(config);
const profileReady = isOnboardingProfileReady(config);
const routeReady = providerReady && profileReady;
const activeIndex = Math.max(0, onboardingStepOrder.indexOf(activeStep));
const activeDetails = onboardingStepDetails[activeStep];
const previousStep = onboardingStepOrder[activeIndex - 1];
const nextStep = onboardingStepOrder[activeIndex + 1];
const nextStep = getNextOnboardingStep(activeStep, config);
const firstProvider = config.Providers[0];
const providerNames = config.Providers.map((provider) => provider.name).filter(Boolean).slice(0, 3).join(", ");
const selectedAgentProfiles = config.profile.profiles.filter((profile) => profile.agent === selectedAgent);
@ -3791,7 +3850,7 @@ function OnboardingView({
transition={{ duration: 0.16 }}
>
<div className="flex min-w-0 flex-col items-center gap-3 text-center">
<h1 className="max-w-[720px] text-[24px] font-semibold tracking-normal text-foreground">{t("Three steps to connect a provider to your agent.")}</h1>
<h1 className="max-w-[720px] text-[24px] font-semibold tracking-normal text-foreground">{t("Welcome to CCR")}</h1>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
@ -10590,22 +10649,6 @@ function readLanguagePreference(): AppLanguagePreference {
}
}
function readOnboardingCompleted(): boolean {
try {
return window.localStorage.getItem(onboardingCompletionStorageKey) === "true";
} catch {
return false;
}
}
function persistOnboardingCompleted() {
try {
window.localStorage.setItem(onboardingCompletionStorageKey, "true");
} catch {
// If storage is unavailable, onboarding can reappear on the next launch.
}
}
function persistLanguagePreference(language: AppLanguagePreference) {
try {
if (language === "system") {

View file

@ -34,6 +34,7 @@ declare global {
getAppInfo: () => Promise<AppInfo>;
getConfig: () => Promise<AppConfig>;
getGatewayStatus: () => Promise<GatewayStatus>;
getOnboardingFinished: () => Promise<boolean>;
getPendingProviderDeepLinks: () => Promise<ProviderDeepLinkRequest[]>;
getPluginMarketplace: () => Promise<PluginMarketplaceEntry[]>;
getProxyCertificateStatus: () => Promise<ProxyCertificateStatus>;
@ -52,6 +53,7 @@ declare global {
saveApiKeys: (apiKeys: ApiKeyConfig[]) => Promise<AppConfig>;
saveConfig: (config: AppConfig) => Promise<AppConfig>;
selectPluginDirectory: () => Promise<PluginDirectorySelection | undefined>;
setOnboardingFinished: () => Promise<boolean>;
setProxyNetworkCaptureEnabled: (enabled: boolean) => Promise<ProxyNetworkSnapshot>;
setTrayDetailOpen: (open: boolean, provider?: string) => Promise<void>;
showMainWindow: () => Promise<void>;

View file

@ -5,6 +5,7 @@ export const IPC_CHANNELS = {
appGetAgentAnalysis: "ccr:app:get-agent-analysis",
appGetGatewayStatus: "ccr:app:get-gateway-status",
appGetInfo: "ccr:app:get-info",
appGetOnboardingFinished: "ccr:app:get-onboarding-finished",
appGetPendingProviderDeepLinks: "ccr:app:get-pending-provider-deep-links",
appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status",
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
@ -27,6 +28,7 @@ export const IPC_CHANNELS = {
appStartGateway: "ccr:app:start-gateway",
appStopGateway: "ccr:app:stop-gateway",
appSaveConfig: "ccr:app:save-config",
appSetOnboardingFinished: "ccr:app:set-onboarding-finished",
appSetTrayDetailOpen: "ccr:app:set-tray-detail-open",
appSetProxyNetworkCaptureEnabled: "ccr:app:set-proxy-network-capture-enabled",
appSelectPluginDirectory: "ccr:app:select-plugin-directory",