Commit a4d243ae authored by AI-甘富林's avatar AI-甘富林

feat(desktop): unify startup setup flow and runtime cloud prewarm

parent f9e6de26
import path from "node:path";
import path from "node:path";
import { appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
import type { RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types";
import type { AppConfig, RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types";
import { createMainWindow } from "./create-window.js";
import { registerDesktopIpc } from "./ipc.js";
import { AppConfigService } from "./services/app-config.js";
......@@ -138,6 +138,66 @@ function resolveRequestedRuntimeMode(configMode: RuntimeModePreference): Runtime
return override === "bundled-runtime" || override === "external-gateway" ? override : configMode;
}
function buildDirectProviderManagedConfig(defaultConfig: Record<string, unknown>, config: AppConfig, apiKey: string): Record<string, unknown> {
const nextConfig = structuredClone(defaultConfig);
const providerKey = "direct-provider";
const modelId = config.defaultModel || "gpt-5.4-mini";
const modelLabel = modelId;
const apiMode = config.provider === "anthropic" ? "anthropic-messages" : "openai-completions";
const modelsSection = (nextConfig.models && typeof nextConfig.models === "object" ? nextConfig.models : {}) as Record<string, unknown>;
const providers = (modelsSection.providers && typeof modelsSection.providers === "object" ? modelsSection.providers : {}) as Record<string, unknown>;
const existingProvider = (providers[providerKey] && typeof providers[providerKey] === "object" ? providers[providerKey] : {}) as Record<string, unknown>;
const authSection = (nextConfig.auth && typeof nextConfig.auth === "object" ? nextConfig.auth : {}) as Record<string, unknown>;
const authProfiles = (authSection.profiles && typeof authSection.profiles === "object" ? authSection.profiles : {}) as Record<string, unknown>;
const agentsSection = (nextConfig.agents && typeof nextConfig.agents === "object" ? nextConfig.agents : {}) as Record<string, unknown>;
const agentDefaults = (agentsSection.defaults && typeof agentsSection.defaults === "object" ? agentsSection.defaults : {}) as Record<string, unknown>;
const modelDefaults = (agentDefaults.model && typeof agentDefaults.model === "object" ? agentDefaults.model : {}) as Record<string, unknown>;
const modelAliases = (agentDefaults.models && typeof agentDefaults.models === "object" ? agentDefaults.models : {}) as Record<string, unknown>;
authProfiles[providerKey + ":default"] = {
provider: providerKey,
mode: "api_key"
};
authSection.profiles = authProfiles;
nextConfig.auth = authSection;
providers[providerKey] = {
...existingProvider,
baseUrl: config.baseUrl,
apiKey,
api: apiMode,
models: [
{
id: modelId,
name: modelLabel,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0
},
maxTokens: 2048
}
]
};
modelsSection.mode = "merge";
modelsSection.providers = providers;
nextConfig.models = modelsSection;
modelDefaults.primary = providerKey + "/" + modelId;
modelDefaults.fallbacks = [];
modelAliases[providerKey + "/" + modelId] = {
alias: modelLabel
};
agentDefaults.model = modelDefaults;
agentDefaults.models = modelAliases;
agentsSection.defaults = agentDefaults;
nextConfig.agents = agentsSection;
return nextConfig;
}
function resolveVendorRuntimeDir(systemSummary: SystemSummary): string {
if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, "vendor", "openclaw-runtime");
......@@ -391,6 +451,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const sessionId = state?.streamSmoke?.sessionId || state?.activeSessionId || "desktop-main";
const runtimeTelemetryAfterWait = await api.runtimeTelemetry.getStatus();
const messages = await api.chat.listMessages(sessionId);
const chatMessages = messages.filter((message) => message.role === "assistant" || message.role === "user");
const lastAssistantMessage = [...chatMessages].reverse().find((message) => message.role === "assistant") ?? null;
const logs = await api.gateway.tailLogs(20);
const diagnostics = await api.diagnostics.exportSnapshot();
const health = await api.gateway.health();
......@@ -398,8 +460,10 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
return {
runtimeTelemetryAfterWait,
sessionId,
messageCount: messages.length,
lastMessage: messages.at(-1) ?? null,
messageCount: chatMessages.length,
rawMessageCount: messages.length,
lastMessage: chatMessages.at(-1) ?? null,
lastAssistantMessage,
logCount: logs.length,
diagnostics,
health,
......@@ -460,6 +524,7 @@ async function bootstrap(): Promise<void> {
}
if (smokeCloudBaseUrl || smokeAuthToken || smokeRuntimeApiKey) {
await configService.save({
setupMode: config.setupMode,
provider: config.provider,
baseUrl: config.baseUrl,
defaultModel: config.defaultModel,
......@@ -482,19 +547,34 @@ async function bootstrap(): Promise<void> {
const diagnosticsService = new DiagnosticsService(systemSummary.userDataPath);
const deviceIdentityService = new DeviceIdentityService(systemSummary.userDataPath);
await deviceIdentityService.load();
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager);
await runtimeCloudClient.hydrateCache();
const skillStore = new SkillStoreService(systemSummary.userDataPath);
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
await skillStore.reconcile(skills, payloadConfig.configVersion);
});
const cachedRuntimeCloudStatus = await runtimeCloudClient.getStatus();
if (cachedRuntimeCloudStatus.config) {
await skillStore.reconcile(runtimeCloudClient.getRemoteSkillAssets(), cachedRuntimeCloudStatus.config.configVersion).catch(() => undefined);
}
const runtimeManager = new RuntimeManager({
vendorRuntimeDir: resolveVendorRuntimeDir(systemSummary),
runtimeDataDir: path.join(systemSummary.userDataPath, "runtime"),
logFilePath: path.join(systemSummary.logsPath, "runtime-manager.log"),
requestedMode: resolveRequestedRuntimeMode(config.runtimeMode),
managedConfigResolver: async ({ action, defaultConfig }) => runtimeCloudClient.buildManagedConfig(defaultConfig, action),
managedConfigResolver: async ({ action, defaultConfig }) => {
const latestConfig = await configService.load();
const apiKey = await secretManager.getApiKey();
if (latestConfig.setupMode === "direct-provider") {
if (!apiKey) {
throw new Error("Direct provider API Key is not configured.");
}
return buildDirectProviderManagedConfig(defaultConfig, latestConfig, apiKey);
}
return runtimeCloudClient.buildManagedConfig(defaultConfig, action);
},
strictBundledRuntime: systemSummary.isPackaged
});
await runtimeManager.configure();
......@@ -534,13 +614,32 @@ async function bootstrap(): Promise<void> {
runtimeManager,
secretManager
});
runtimeCloudSupervisor.onActivity((event) => {
runtimeCloudSupervisor.onActivity((event) => {
dailyReportService.handleActivity(event);
});
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
const scheduleRuntimeCloudRefresh = (reason: string) => {
void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
try {
const status = await runtimeCloudClient.fetchConfig(previousConfigVersion ? "sync" : "init");
const nextConfigVersion = status.config?.configVersion;
if (previousConfigVersion && nextConfigVersion && previousConfigVersion !== nextConfigVersion) {
await runtimeManager.syncManagedConfig("sync");
}
} catch (error) {
console.warn(`${reason} runtime cloud refresh skipped:`, error instanceof Error ? error.message : String(error));
}
})();
};
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
try {
await runtimeCloudClient.fetchConfig("init");
const shouldUseRuntimeCloud = config.setupMode === "employee-key";
const usingCachedRuntimeCloudConfig = shouldUseRuntimeCloud && runtimeCloudClient.hasCachedPayload();
if (shouldUseRuntimeCloud && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig("init");
}
await runtimeManager.start();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (runtimeGatewayConnection.url) {
......@@ -550,10 +649,20 @@ async function bootstrap(): Promise<void> {
(await secretManager.getDeviceToken()) ?? undefined
);
}
await runtimeCloudSupervisor.start();
await gatewayClient.connect().catch(() => undefined);
if (config.setupMode === "employee-key") {
await runtimeCloudSupervisor.start();
if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh("bootstrap");
}
} else {
await runtimeCloudSupervisor.stop("bootstrap");
}
} catch (error) {
console.error("Bundled runtime bootstrap skipped:", error instanceof Error ? error.message : String(error));
}
} else if (resolveRequestedRuntimeMode(config.runtimeMode) === "external-gateway") {
void gatewayClient.connect().catch(() => undefined);
}
registerDesktopIpc({
......@@ -628,3 +737,6 @@ void bootstrap().catch(async (error) => {
});
......@@ -13,7 +13,8 @@ import {
type SaveConfigInput,
type SignInInput,
type SystemSummary,
type WorkspaceSummary
type WorkspaceSummary,
type WorkspaceWarmupResult
} from "@qjclaw/shared-types";
import type { GatewayClient } from "@qjclaw/gateway-client";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
......@@ -134,66 +135,167 @@ function buildPluginSummaries(runtimeStatus: RuntimeStatus): PluginSummary[] {
});
}
const MANAGED_RUNTIME_START_RETRY_LIMIT = 2;
const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500;
const GATEWAY_CONNECT_RETRY_LIMIT = 2;
const GATEWAY_CONNECT_RETRY_DELAY_MS = 1000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTransientLocalGatewayError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("econnrefused")
|| normalized.includes("failed to connect to ws://127.0.0.1")
|| normalized.includes("failed to connect to ws://localhost")
|| normalized.includes("gateway readiness")
|| normalized.includes("gateway became ready")
|| normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("bundled runtime exited before gateway became ready");
}
function toStartupErrorMessage(message: string | undefined, fallback: string): string {
if (isTransientLocalGatewayError(message)) {
return "\u672c\u5730\u52a9\u624b\u6682\u65f6\u6ca1\u6709\u51c6\u5907\u597d\uff0c\u8bf7\u91cd\u65b0\u51c6\u5907\u3002";
}
return message ?? fallback;
}
function shouldRetryManagedRuntimeStartup(config: AppConfig, status: RuntimeStatus): boolean {
if (config.runtimeMode === "external-gateway" || status.processState !== "error") {
return false;
}
const bundledRuntimeSelected = status.selectedMode === "bundled-runtime" || status.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
return isTransientLocalGatewayError(status.lastError ?? status.message);
}
function buildChatSummary(
config: AppConfig,
runtimeStatus: RuntimeStatus,
runtimeCloudStatus: RuntimeCloudStatus,
gatewayStatus: GatewayStatus | null
): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage"> {
if (!runtimeCloudStatus.apiKeyConfigured) {
gatewayStatus: GatewayStatus | null,
warmupInFlight: boolean
): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
if (!config.apiKeyConfigured) {
const setupMessage = config.setupMode === "direct-provider"
? "\u8bf7\u5148\u5b8c\u6210\u5382\u5546\u4e0e API Key \u914d\u7f6e\u3002"
: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u3002";
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: "闂備浇宕垫慨鏉懨洪妶澶婂簥闁哄被鍎遍崒銊︾箾閹寸偞鐨戠痪鎯с偢閺岀喓鈧稒顭囩粻姗€鏌¢崱鏇炲祮闁哄本绋戦埥澶娾枍椤撗傜凹閻庨潧銈搁獮鍥敊閻熼澹曢梻鍌氱墛缁嬫帡藟閵忋倖鐓欓柛娑橈功閻帒鈹?"
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
};
}
if (runtimeCloudStatus.state === "error") {
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "\u5458\u5de5\u914d\u7f6e\u540c\u6b65\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u5bc6\u94a5\u6216\u7f51\u7edc\u8fde\u63a5\u3002";
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeCloudStatus.lastError ?? "OpenClaw 闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬬厸闁割偁鍨洪弳鈺呮⒒閸涱噯鑰挎慨濠冩そ瀵墎鎹勯妸鎰╁€濋弻锝夊Χ閸涱噮妫﹂悗瑙勬礃缁诲牓骞冮埡鍛€绘俊顖滎儠閸嬫ê鈹戦悩顔肩仾闁稿氦鍋愰崚鎺楀礈瑜庨崰鍡涙煥閺囩偛鈧瓕绻?"
chatStatusMessage: runtimeCloudError,
startupPhase: "error",
startupMessage: runtimeCloudError
};
}
const runtimeCanServeChat = runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
if (runtimeCanServeChat && gatewayStatus?.state === "connected") {
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
if (warmupInFlight && runtimeStatus.processState === "error" && isTransientLocalGatewayError(runtimeError)) {
return {
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬪仯闁告繂瀚幆鍫ユ煕閵堝棗绗х紒杈ㄦ尰閹峰懘宕妷褜鍞舵繝娈垮枟鑿ч柛鏂挎捣濡叉劙骞掑Δ濠冩櫆闂佺鏈〃鍛?"
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u91cd\u65b0\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "starting-runtime",
startupMessage: "\u6b63\u5728\u91cd\u65b0\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (warmupInFlight && gatewayStatus?.state === "error" && isTransientLocalGatewayError(gatewayError)) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u91cd\u65b0\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "connecting-gateway",
startupMessage: "\u6b63\u5728\u91cd\u65b0\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
if (runtimeStatus.processState === "error") {
const runtimeErrorMessage = toStartupErrorMessage(runtimeError, "\u672c\u5730\u52a9\u624b\u542f\u52a8\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeStatus.lastError ?? runtimeStatus.message ?? "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬪仯闁惧繒鎳撻崝瀣煕鎼淬垻鎳囬柡灞剧洴瀵剛鎷犻幓鎺濈€抽梻渚€娼уú锕傚垂瑜版帒绠憸鐗堝笒鍞銈嗙墬缁酣藝椤曗偓閺岋綁鎮╅崣澶婃灎濡炪們鍎查幑鍥春閿濆顫呴柕鍫濇嚀琚濋梺鐟板悑閻n亪宕濈仦瑙f瀺闁靛繈鍊栭崑锝夋煕閵夛絽濡界痪鐐倐閺?"
chatStatusMessage: runtimeErrorMessage,
startupPhase: "error",
startupMessage: runtimeErrorMessage
};
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "\u804a\u5929\u670d\u52a1\u8fde\u63a5\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: gatewayStatus.lastError ?? gatewayStatus.message ?? "缂傚倸鍊搁崐鎼佸疮椤栫偛鍨傞柣銏㈩焾閻鎲告惔鈽嗙劷濠电姵纰嶉崐鐑芥煛婢跺鐏ユい锕€寮剁换婵嬪閳ュ啿濮哥紓渚囧枛婢т粙骞夐幘顔芥櫇闁稿本绋掑▍鏍倵閸忓浜鹃梺鍛婂姈閸庡啿鈻撻懠顒傜=濞达絽澹婇崕蹇曠磼婢跺灏︽鐐插暙铻栭柛娑卞枤閸樻帡鎮楅獮鍨姎闁绘绻愬嵄闁归棿鐒﹂悡?"
chatStatusMessage: gatewayErrorMessage,
startupPhase: "error",
startupMessage: gatewayErrorMessage
};
}
const runtimeCanServeChat = runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
if (runtimeCanServeChat && gatewayStatus?.state === "connected") {
return {
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002",
startupPhase: "ready",
startupMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002"
};
}
if (config.setupMode === "employee-key" && (runtimeCloudStatus.state === "loading" || runtimeCloudStatus.state === "unconfigured")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u540c\u6b65\u5458\u5de5\u914d\u7f6e\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "syncing-config",
startupMessage: "\u6b63\u5728\u540c\u6b65\u5458\u5de5\u914d\u7f6e\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
if (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running") {
if (runtimeStatus.processState === "starting" || (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: runtimeStatus.message || "闂備礁鎼ˇ顐﹀疾濠婂牆绀夋慨妞诲亾闁靛棔绶氶獮瀣晝閳ь剛鐚惧澶嬬厾闁告稑顭崯蹇涙煕閺傚搫浜鹃梻鍌欑窔濞艰崵鎷归悢鐓庣鐎光偓閸曨偆鐣鹃柟鍏肩暘閸斿瞼绮堟径鎰厪濠电偛鐏濋埀顒佺洴瀹曘垽顢楅崟顒傚帾?"
chatStatusMessage: runtimeStatus.message || "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "starting-runtime",
startupMessage: runtimeStatus.message || "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "濠电姵顔栭崰妤冩崲閹邦喖绶ら柦妯侯檧閼版寧銇勮箛鎾村櫤濞存嚎鍊濋弻锝夊箛椤撶喓绋囨繝銏f硾缁夊墎妲愰幘璇茬闁宠桨鑳舵禒鎾⒑閸涘浼曢柛銉仜?"
chatStatusMessage: gatewayStatus?.message ?? "\u6b63\u5728\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "connecting-gateway",
startupMessage: gatewayStatus?.message ?? "\u6b63\u5728\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
export function registerDesktopIpc(services: MainServices): DesktopApi {
const {
appVersion,
......@@ -251,7 +353,51 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
);
};
const syncRuntimeCloudSupervisor = async (reason: string): Promise<void> => {
const connectGatewayClient = async (): Promise<void> => {
const status = await gatewayClient.status().catch(() => null);
if (status?.state === "connected") {
return;
}
await gatewayClient.reconnect().catch(() => gatewayClient.connect());
};
const connectGatewayClientWithRetry = async (): Promise<void> => {
let lastError: unknown;
for (let attempt = 1; attempt <= GATEWAY_CONNECT_RETRY_LIMIT; attempt += 1) {
try {
await connectGatewayClient();
return;
} catch (error) {
lastError = error;
if (attempt >= GATEWAY_CONNECT_RETRY_LIMIT) {
break;
}
await delay(GATEWAY_CONNECT_RETRY_DELAY_MS);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "Failed to connect Gateway client."));
};
const shouldRefreshGatewayClient = async (config?: AppConfig, inputToken?: string): Promise<boolean> => {
if (inputToken) {
return true;
}
const nextConfig = config ?? await configService.load();
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
const useBundledRuntime = runtimeStatus.activeMode === "bundled-runtime" && typeof runtimeGatewayConnection.url === "string";
const targetGatewayUrl = useBundledRuntime
? runtimeGatewayConnection.url ?? nextConfig.gatewayUrl
: resolveEffectiveGatewayUrl(nextConfig.gatewayUrl, localOpenClawConfig?.gatewayUrl);
const currentStatus = await gatewayClient.status().catch(() => null);
return currentStatus?.state !== "connected" || currentStatus.url !== targetGatewayUrl;
};
const syncRuntimeCloudSupervisor = async (reason: string): Promise<void> => {
const runtimeStatus = await runtimeManager.status();
if (runtimeStatus.activeMode === "bundled-runtime" && runtimeStatus.processState === "running") {
await runtimeCloudSupervisor.start();
......@@ -261,6 +407,22 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await runtimeCloudSupervisor.stop(reason);
};
const scheduleRuntimeCloudRefresh = (reason: string) => {
void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
try {
const status = await runtimeCloudClient.fetchConfig(previousConfigVersion ? "sync" : "init");
const nextConfigVersion = status.config?.configVersion;
if (previousConfigVersion && nextConfigVersion && previousConfigVersion !== nextConfigVersion) {
await runtimeManager.syncManagedConfig("sync");
}
await syncRuntimeCloudSupervisor(`${reason}-runtime-cloud-refresh`);
} catch {
// Keep cached startup available even if the immediate cloud refresh fails.
}
})();
};
const startManagedRuntime = async (
reason: string,
options: {
......@@ -274,47 +436,124 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const apiKey = await secretManager.getApiKey();
if (nextConfig.runtimeMode === "external-gateway" || !apiKey) {
await runtimeCloudSupervisor.stop(reason);
await reconfigureGatewayClient(nextConfig, options.inputToken);
if (await shouldRefreshGatewayClient(nextConfig, options.inputToken)) {
await reconfigureGatewayClient(nextConfig, options.inputToken);
await connectGatewayClientWithRetry().catch(() => undefined);
}
return runtimeManager.status();
}
await runtimeCloudClient.fetchConfig(options.action ?? "init");
const status = options.restart ? await runtimeManager.restart() : await runtimeManager.start();
await reconfigureGatewayClient(nextConfig, options.inputToken);
await syncRuntimeCloudSupervisor(reason);
const shouldUseRuntimeCloud = nextConfig.setupMode === "employee-key";
const usingCachedRuntimeCloudConfig = shouldUseRuntimeCloud && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload();
if (shouldUseRuntimeCloud && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig(options.action ?? "init");
}
let status = options.restart ? await runtimeManager.restart() : await runtimeManager.start();
for (let attempt = 1; attempt < MANAGED_RUNTIME_START_RETRY_LIMIT && shouldRetryManagedRuntimeStartup(nextConfig, status); attempt += 1) {
await runtimeManager.stop().catch(() => undefined);
await delay(MANAGED_RUNTIME_START_RETRY_DELAY_MS);
status = await runtimeManager.start();
}
if (status.processState !== "error" && await shouldRefreshGatewayClient(nextConfig, options.inputToken)) {
await reconfigureGatewayClient(nextConfig, options.inputToken);
await connectGatewayClientWithRetry().catch(() => undefined);
}
if (shouldUseRuntimeCloud) {
await syncRuntimeCloudSupervisor(reason);
if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh(reason);
}
} else {
await runtimeCloudSupervisor.stop(reason);
}
return status;
};
const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => {
const runtimeStatus = await runtimeManager.status();
let runtimeCloudStatus = await runtimeCloudClient.getStatus();
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
if (runtimeCloudStatus.apiKeyConfigured && runtimeCloudStatus.state === "unconfigured") {
try {
runtimeCloudStatus = await runtimeCloudClient.fetchConfig("init");
} catch {
runtimeCloudStatus = await runtimeCloudClient.getStatus();
}
const queueWorkspaceWarmup = async (
reason: string,
options: {
action?: RuntimeCloudFetchAction;
restart?: boolean;
config?: AppConfig;
inputToken?: string;
} = {}
): Promise<WorkspaceWarmupResult> => {
const nextConfig = options.config ?? await configService.load();
const apiKey = await secretManager.getApiKey();
if (!apiKey) {
return {
accepted: false,
state: "skipped",
message: nextConfig.setupMode === "direct-provider"
? "\u5c1a\u672a\u5b8c\u6210\u5382\u5546\u4e0e API Key \u914d\u7f6e\uff0c\u5df2\u8df3\u8fc7\u540e\u53f0\u9884\u70ed\u3002"
: "\u5c1a\u672a\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\uff0c\u5df2\u8df3\u8fc7\u540e\u53f0\u9884\u70ed\u3002"
};
}
const gatewayStatus = runtimeCloudStatus.apiKeyConfigured
const alreadyBusy = workspaceWarmupInFlight;
workspaceWarmupTail = workspaceWarmupTail
.catch(() => undefined)
.then(async () => {
workspaceWarmupInFlight = true;
try {
await startManagedRuntime(reason, {
...options,
config: nextConfig
});
} catch {
// Workspace summary and runtime status retain the latest failure details.
} finally {
workspaceWarmupInFlight = false;
}
});
return {
accepted: true,
state: "scheduled",
message: alreadyBusy ? "\u540e\u53f0\u9884\u70ed\u5df2\u6392\u961f\u3002" : "\u540e\u53f0\u9884\u70ed\u5df2\u5f00\u59cb\u3002"
};
};
const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => {
const config = await getEffectiveConfig();
const runtimeStatus = await runtimeManager.status();
const runtimeCloudStatus: RuntimeCloudStatus = config.setupMode === "employee-key"
? await runtimeCloudClient.getStatus()
: {
state: config.apiKeyConfigured ? "ready" : "unconfigured",
baseUrl: config.baseUrl,
apiKeyConfigured: config.apiKeyConfigured,
lastFetchedAt: undefined,
lastError: undefined,
config: undefined
};
const gatewayStatus = config.apiKeyConfigured
? await gatewayClient.status().catch(() => null)
: null;
const chatSummary = buildChatSummary(runtimeStatus, runtimeCloudStatus, gatewayStatus);
const chatSummary = buildChatSummary(config, runtimeStatus, runtimeCloudStatus, gatewayStatus, workspaceWarmupInFlight);
const skills = await skillStore.listWorkspaceSkills();
return {
apiKeyConfigured: runtimeCloudStatus.apiKeyConfigured,
bindingRequired: !runtimeCloudStatus.apiKeyConfigured,
apiKeyConfigured: config.apiKeyConfigured,
bindingRequired: !config.apiKeyConfigured,
setupRequired: !config.apiKeyConfigured,
setupMode: config.setupMode,
chatReady: chatSummary.chatReady,
chatLaunchState: chatSummary.chatLaunchState,
chatStatusMessage: chatSummary.chatStatusMessage,
employeeId: runtimeCloudStatus.config?.employeeId,
employeeName: runtimeCloudStatus.config?.employeeName,
welcomeMessage: runtimeCloudStatus.config?.welcomeMessage,
modelId: runtimeCloudStatus.config?.modelId,
modelDisplayName: runtimeCloudStatus.config?.modelDisplayName,
configVersion: runtimeCloudStatus.config?.configVersion,
startupPhase: chatSummary.startupPhase,
startupMessage: chatSummary.startupMessage,
employeeId: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeId : undefined,
employeeName: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeName : undefined,
welcomeMessage: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.welcomeMessage : undefined,
modelId: runtimeCloudStatus.config?.modelId ?? config.defaultModel,
modelDisplayName: runtimeCloudStatus.config?.modelDisplayName ?? config.defaultModel,
configVersion: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.configVersion : undefined,
lastFetchedAt: runtimeCloudStatus.lastFetchedAt ?? runtimeCloudStatus.config?.fetchedAt,
runtimeCloudState: runtimeCloudStatus.state,
runtimeState: runtimeStatus.processState,
......@@ -437,6 +676,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect());
......@@ -461,6 +701,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
ipcMain.handle(IPC_CHANNELS.configLoad, async () => getEffectiveConfig());
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => {
const previousConfig = await configService.load();
const config = await configService.save(input);
if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined);
......@@ -471,19 +712,23 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (config.setupMode === "direct-provider" || previousConfig.setupMode !== config.setupMode) {
await runtimeCloudClient.clearCache().catch(() => undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await startManagedRuntime("config-save", {
void queueWorkspaceWarmup("config-save", {
action: "init",
restart: true,
config,
inputToken: input.gatewayToken
});
} else {
await runtimeCloudSupervisor.stop("config-save");
await reconfigureGatewayClient(config, input.gatewayToken);
await syncRuntimeCloudSupervisor("config-save");
if (config.setupMode === "employee-key") {
await syncRuntimeCloudSupervisor("config-save");
}
}
void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig();
......@@ -530,9 +775,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
}
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const requestId = randomUUID();
let executionPolicy: Awaited<ReturnType<typeof resolveExecutionPolicy>> | null = null;
let settled = false;
let ready = false;
let startedEvent: ChatStreamEvent | null = null;
......@@ -551,6 +795,23 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try {
queueOrSend({
type: "status",
requestId,
sessionId,
stage: "prepare-request",
label: skillId ? "\u6b63\u5728\u8c03\u53d6\u76f8\u5173\u6280\u80fd" : "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898"
});
executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
queueOrSend({
type: "status",
requestId,
sessionId,
stage: "await-model",
label: "\u5df2\u6536\u5230\u95ee\u9898\uff0c\u6b63\u5728\u7ec4\u7ec7\u56de\u7b54"
});
const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({
......@@ -558,7 +819,18 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
requestId,
sessionId: nextSessionId,
runId,
executionPolicy
executionPolicy: executionPolicy ?? undefined
});
},
onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => {
queueOrSend({
type: "status",
requestId,
sessionId: nextSessionId,
runId,
stage,
label,
detail
});
},
onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => {
......@@ -573,20 +845,20 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
},
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
settled = true;
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy.modelId, skillId);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, skillId);
queueOrSend({
type: "completed",
requestId,
sessionId: nextSessionId,
runId,
reply,
executionPolicy
executionPolicy: executionPolicy ?? undefined
});
},
onError: ({ sessionId: nextSessionId, runId, error }) => {
settled = true;
runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, {
modelId: executionPolicy.modelId,
modelId: executionPolicy?.modelId,
sessionId: nextSessionId
});
queueOrSend({
......@@ -606,19 +878,19 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
requestId,
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy
executionPolicy: executionPolicy ?? undefined
});
for (const queuedEvent of queuedEvents) {
emitChatStreamEvent(event.sender, queuedEvent);
}
}, 0);
return { requestId, sessionId: stream.sessionId, runId: stream.runId, executionPolicy };
return { requestId, sessionId: stream.sessionId, runId: stream.runId, executionPolicy: executionPolicy ?? undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!settled) {
runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy.modelId,
modelId: executionPolicy?.modelId,
sessionId
});
}
......@@ -633,7 +905,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
return {
workspace: {
getSummary: () => buildWorkspaceSummary()
getSummary: () => buildWorkspaceSummary(),
warmup: () => queueWorkspaceWarmup("workspace-warmup", { action: "init" })
},
gateway: {
status: () => gatewayClient.status(),
......@@ -679,9 +952,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await startManagedRuntime("config-save", {
void queueWorkspaceWarmup("config-save", {
action: "init",
restart: true,
config,
inputToken: input.gatewayToken
});
......@@ -763,3 +1035,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppConfig, RuntimeModePreference, SaveConfigInput } from "@qjclaw/shared-types";
import type { AppConfig, RuntimeModePreference, SaveConfigInput, SetupMode } from "@qjclaw/shared-types";
const CONFIG_DIR = "config";
const CONFIG_FILE = "app-config.json";
......@@ -23,6 +23,7 @@ const UI_ROUTE_NAMES = new Set([
]);
interface LegacyConfig {
setupMode?: SetupMode;
provider?: string;
baseUrl?: string;
apiKeyConfigured?: boolean;
......@@ -79,6 +80,10 @@ function normalizeRuntimeMode(raw?: string): RuntimeModePreference {
return raw === "bundled-runtime" || raw === "external-gateway" ? raw : "bundled-runtime";
}
function normalizeSetupMode(raw?: string): SetupMode {
return raw === "direct-provider" ? raw : "employee-key";
}
function resolveRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeCloudApiBaseUrl(raw ?? "");
if (normalized) {
......@@ -91,48 +96,41 @@ function resolveRuntimeCloudApiBaseUrl(raw?: string): string {
export class AppConfigService {
private readonly userDataPath: string;
private ioChain: Promise<void> = Promise.resolve();
constructor(userDataPath: string) {
this.userDataPath = userDataPath;
}
async load(): Promise<AppConfig> {
const filePath = this.getConfigPath();
await mkdir(path.dirname(filePath), { recursive: true });
try {
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as LegacyConfig;
const config = this.normalizeConfig(parsed);
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
return config;
} catch {
const defaults = this.createDefaultConfig();
await writeFile(filePath, JSON.stringify(defaults, null, 2), "utf8");
return defaults;
}
return this.runExclusive(() => this.loadUnlocked());
}
async save(input: SaveConfigInput): Promise<AppConfig> {
const current = await this.load();
const config: AppConfig = {
provider: input.provider,
baseUrl: input.baseUrl,
apiKeyConfigured: Boolean(input.apiKey) || current.apiKeyConfigured,
gatewayTokenConfigured: Boolean(input.gatewayToken) || current.gatewayTokenConfigured,
authTokenConfigured: typeof input.authToken === "string" ? Boolean(input.authToken) : current.authTokenConfigured,
defaultModel: input.defaultModel,
workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
};
return this.runExclusive(async () => {
const current = await this.loadUnlocked();
const config: AppConfig = {
setupMode: normalizeSetupMode(input.setupMode),
provider: input.provider,
baseUrl: input.baseUrl,
apiKeyConfigured: Boolean(input.apiKey) || current.apiKeyConfigured,
gatewayTokenConfigured: Boolean(input.gatewayToken) || current.gatewayTokenConfigured,
authTokenConfigured: typeof input.authToken === "string" ? Boolean(input.authToken) : current.authTokenConfigured,
defaultModel: input.defaultModel,
workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
};
await this.writeConfig(config);
return config;
});
}
const filePath = this.getConfigPath();
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
return config;
getDataPath(...segments: string[]): string {
return path.join(this.userDataPath, ...segments);
}
private getConfigPath(): string {
......@@ -141,6 +139,7 @@ export class AppConfigService {
private createDefaultConfig(): AppConfig {
return {
setupMode: "employee-key",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyConfigured: false,
......@@ -157,6 +156,7 @@ export class AppConfigService {
private normalizeConfig(config: LegacyConfig): AppConfig {
return {
setupMode: normalizeSetupMode(config.setupMode),
provider: config.provider ?? "openai",
baseUrl: config.baseUrl ?? "https://api.openai.com/v1",
apiKeyConfigured: Boolean(config.apiKeyConfigured),
......@@ -170,4 +170,53 @@ export class AppConfigService {
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
};
}
private async runExclusive<T>(operation: () => Promise<T>): Promise<T> {
const next = this.ioChain.then(operation, operation);
this.ioChain = next.then(() => undefined, () => undefined);
return next;
}
private async loadUnlocked(): Promise<AppConfig> {
const filePath = this.getConfigPath();
await mkdir(path.dirname(filePath), { recursive: true });
try {
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as LegacyConfig;
const config = this.normalizeConfig(parsed);
await this.writeConfig(config);
return config;
} catch (error) {
if (!this.shouldResetToDefaults(error)) {
throw error;
}
const defaults = this.createDefaultConfig();
await this.writeConfig(defaults);
return defaults;
}
}
private shouldResetToDefaults(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as NodeJS.ErrnoException;
if (candidate.code === "ENOENT") {
return true;
}
return error instanceof SyntaxError;
}
private async writeConfig(config: AppConfig): Promise<void> {
const filePath = this.getConfigPath();
const tempPath = `${filePath}.tmp`;
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(tempPath, JSON.stringify(config, null, 2), "utf8");
await rm(filePath, { force: true });
await rename(tempPath, filePath);
}
}
import http from "node:http";
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import https from "node:https";
import path from "node:path";
import type {
AuthSessionSummary,
CreditSummary,
......@@ -203,6 +206,10 @@ function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function buildApiKeyFingerprint(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex");
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
}
......@@ -227,11 +234,44 @@ type RuntimeCloudPayloadListener = (payload: {
skills: RemoteSkillAsset[];
}) => Promise<void> | void;
const DEFAULT_HTTP_REQUEST_TIMEOUT_MS = 15_000;
interface OpenClawConfigCacheRecord {
keyFingerprint: string;
payload: OpenClawEmployeeConfigPayload;
summary: RuntimeCloudConfigSummary;
}
class HttpJsonClient {
request(url: URL, options: { method: "GET" | "POST"; headers?: Record<string, string>; body?: unknown }): Promise<string> {
request(
url: URL,
options: {
method: "GET" | "POST";
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
}
): Promise<string> {
const client = url.protocol === "https:" ? https : http;
const timeoutMs = options.timeoutMs ?? DEFAULT_HTTP_REQUEST_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
const finishReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
reject(error);
};
const finishResolve = (body: string) => {
if (settled) {
return;
}
settled = true;
resolve(body);
};
const request = client.request(url, {
method: options.method,
headers: {
......@@ -248,15 +288,19 @@ class HttpJsonClient {
response.on("end", () => {
const status = response.statusCode ?? 500;
if (status < 200 || status >= 300) {
reject(new CloudApiError(this.extractErrorMessage(body, status), status));
finishReject(new CloudApiError(this.extractErrorMessage(body, status), status));
return;
}
resolve(body);
finishResolve(body);
});
});
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`Cloud API request timed out after ${Math.round(timeoutMs / 1000)}s: ${url.origin}`));
});
request.on("error", (error) => {
reject(new Error(`Cloud API request failed: ${error.message}`));
finishReject(new Error(`Cloud API request failed: ${error.message}`));
});
if (options.body !== undefined) {
......@@ -465,19 +509,77 @@ export class OpenClawConfigClient {
private readonly secretManager: SecretManager;
private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private readonly cachePath: string;
private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
baseUrl: "",
apiKeyConfigured: false
};
private cacheLoaded = false;
constructor(configService: AppConfigService, secretManager: SecretManager) {
this.configService = configService;
this.secretManager = secretManager;
this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
}
async hydrateCache(): Promise<void> {
if (this.cacheLoaded) {
return;
}
this.cacheLoaded = true;
try {
const raw = await readFile(this.cachePath, "utf8");
const parsed = JSON.parse(raw) as OpenClawConfigCacheRecord;
if (!parsed || typeof parsed !== "object" || !parsed.payload || !parsed.summary) {
throw new Error("Runtime cloud cache is malformed.");
}
if (!parsed.summary.configVersion || !parsed.summary.employeeId) {
throw new Error("Runtime cloud cache is missing required summary fields.");
}
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!apiKey || parsed.keyFingerprint !== buildApiKeyFingerprint(apiKey)) {
return;
}
this.payloadCache = cloneJson(parsed.payload);
this.statusCache = {
...this.statusCache,
state: "ready",
lastFetchedAt: parsed.summary.fetchedAt,
config: cloneJson(parsed.summary),
lastError: undefined
};
} catch (error) {
if (!this.shouldIgnoreCacheError(error)) {
this.statusCache = {
...this.statusCache,
lastError: error instanceof Error ? error.message : String(error)
};
}
}
}
async clearCache(): Promise<void> {
this.payloadCache = null;
this.statusCache = {
state: "unconfigured",
baseUrl: this.statusCache.baseUrl,
apiKeyConfigured: this.statusCache.apiKeyConfigured
};
this.cacheLoaded = true;
await rm(this.cachePath, { force: true }).catch(() => undefined);
}
hasCachedPayload(): boolean {
return Boolean(this.payloadCache && this.statusCache.config);
}
async getStatus(): Promise<RuntimeCloudStatus> {
await this.hydrateCache();
const config = await this.configService.load();
const apiKey = await this.secretManager.getApiKey();
return {
......@@ -493,7 +595,10 @@ export class OpenClawConfigClient {
}
async buildManagedConfig(defaultConfig: Record<string, unknown>, action: RuntimeCloudFetchAction = "init"): Promise<Record<string, unknown>> {
const payload = await this.fetchPayload(action);
await this.hydrateCache();
const payload = action === "init" && this.payloadCache
? this.payloadCache
: await this.fetchPayload(action);
return this.mergeConfig(defaultConfig, payload);
}
......@@ -508,17 +613,27 @@ export class OpenClawConfigClient {
};
}
private shouldIgnoreCacheError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as NodeJS.ErrnoException;
return candidate.code === "ENOENT" || error instanceof SyntaxError;
}
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache();
const config = await this.configService.load();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!baseUrl) {
return this.fail(baseUrl, Boolean(apiKey), "OpenClaw 运行时云端地址未配置。");
return this.fail(baseUrl, Boolean(apiKey), "\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u8fd0\u884c\u65f6\u4e91\u7aef\u5730\u5740\u672a\u914d\u7f6e\u3002");
}
if (!apiKey) {
return this.fail(baseUrl, false, "请先绑定 OpenClaw employee API Key。");
return this.fail(baseUrl, false, "\u8bf7\u5148\u7ed1\u5b9a\u0020\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u0065\u006d\u0070\u006c\u006f\u0079\u0065\u0065\u0020\u0041\u0050\u0049\u0020\u004b\u0065\u0079\u3002");
}
this.statusCache = {
......@@ -544,22 +659,27 @@ export class OpenClawConfigClient {
try {
payload = JSON.parse(body) as OpenClawEmployeeConfigPayload;
} catch {
throw new Error("OpenClaw 配置接口返回了无效 JSON。");
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u8fd4\u56de\u4e86\u65e0\u6548\u0020\u004a\u0053\u004f\u004e\u3002");
}
const fetchedAt = new Date().toISOString();
if (action === "sync" && payload.changed === false && this.statusCache.config) {
const summary = {
...this.statusCache.config,
configVersion: payload.config_version ?? this.statusCache.config.configVersion,
fetchedAt
};
this.statusCache = {
state: "ready",
baseUrl,
apiKeyConfigured: true,
lastFetchedAt: fetchedAt,
config: {
...this.statusCache.config,
configVersion: payload.config_version ?? this.statusCache.config.configVersion,
fetchedAt
}
config: summary,
lastError: undefined
};
if (this.payloadCache) {
await this.persistCache(summary, this.payloadCache, apiKey);
}
return payload;
}
......@@ -574,14 +694,35 @@ export class OpenClawConfigClient {
config: summary,
lastError: undefined
};
await this.persistCache(summary, this.payloadCache, apiKey);
await this.notifyPayloadUpdated(action, summary);
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (this.payloadCache && this.statusCache.config) {
this.statusCache = {
...this.statusCache,
state: "ready",
baseUrl,
apiKeyConfigured: true,
lastError: message
};
throw new Error(message);
}
return this.fail(baseUrl, true, message);
}
}
private async persistCache(summary: RuntimeCloudConfigSummary, payload: OpenClawEmployeeConfigPayload, apiKey: string): Promise<void> {
const record: OpenClawConfigCacheRecord = {
keyFingerprint: buildApiKeyFingerprint(apiKey),
summary,
payload
};
await mkdir(path.dirname(this.cachePath), { recursive: true });
await writeFile(this.cachePath, JSON.stringify(record, null, 2), "utf8");
}
private async notifyPayloadUpdated(action: RuntimeCloudFetchAction, config: RuntimeCloudConfigSummary): Promise<void> {
const skills = this.getRemoteSkillAssets();
for (const listener of this.payloadListeners) {
......@@ -606,13 +747,13 @@ export class OpenClawConfigClient {
private validatePayload(payload: OpenClawEmployeeConfigPayload): void {
if (!payload.employee_id || !payload.name) {
throw new Error("OpenClaw 配置接口缺少员工标识或名称。");
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u5458\u5de5\u6807\u8bc6\u6216\u540d\u79f0\u3002");
}
if (!payload.config_version) {
throw new Error("OpenClaw 配置接口缺少 config_version。");
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u0020\u0063\u006f\u006e\u0066\u0069\u0067\u005f\u0076\u0065\u0072\u0073\u0069\u006f\u006e\u3002");
}
if (!payload.llm?.provider?.base_url || !payload.llm?.provider?.api_key || !payload.llm?.model_id) {
throw new Error("OpenClaw 配置接口缺少 llm.provider.base_url / api_key / model_id。");
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u0020\u006c\u006c\u006d\u002e\u0070\u0072\u006f\u0076\u0069\u0064\u0065\u0072\u002e\u0062\u0061\u0073\u0065\u005f\u0075\u0072\u006c\u0020\u002f\u0020\u0061\u0070\u0069\u005f\u006b\u0065\u0079\u0020\u002f\u0020\u006d\u006f\u0064\u0065\u006c\u005f\u0069\u0064\u3002");
}
}
......@@ -735,7 +876,6 @@ export class OpenClawConfigClient {
return nextConfig;
}
}
export class AuthClient {
private readonly api: ProductCloudApiClient;
......@@ -853,3 +993,4 @@ export class ModelConfigClient {
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer } from "electron";
import {
IPC_CHANNELS,
type ChatStreamListener,
......@@ -10,7 +10,8 @@ import {
const desktopApi: DesktopApi = {
workspace: {
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.workspaceGetSummary)
getSummary: () => ipcRenderer.invoke(IPC_CHANNELS.workspaceGetSummary),
warmup: () => ipcRenderer.invoke(IPC_CHANNELS.workspaceWarmup)
},
gateway: {
status: () => ipcRenderer.invoke(IPC_CHANNELS.gatewayStatus),
......@@ -83,4 +84,4 @@ const desktopApi: DesktopApi = {
const smokeEnabled = process.argv.includes("--qjc-smoke");
contextBridge.exposeInMainWorld("qjcDesktop", desktopApi);
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
\ No newline at end of file
# 启动页预热方案(适配当前代码)
## 背景
当前代码已经把一部分冷启动前移到了主进程,但聊天页仍然会在服务未 ready 时提前打开,并通过发送区禁发来暴露启动过程。用户感知上会变成“聊天窗口已经打开,但发送按钮是灰的,还要等待准备环境”。
这次方案的目标是把初始化完整收拢到启动页,聊天页只在 `chatReady === true` 后进入;同时对云配置增加“缓存优先 + 后台增量同步”,缩短二次启动时间。
## 当前代码问题定位
- 主进程已经有预热链路:`apps/desktop/src/main/index.ts`
- 启动时会拉取员工配置、启动 bundled runtime、连接 gateway。
- 状态聚合已经存在:`apps/desktop/src/main/ipc.ts`
- `WorkspaceSummary` 已包含 `chatReady``chatLaunchState``startupPhase``startupMessage`
- 问题主要出在渲染层:`apps/ui/src/App.tsx`
- 聊天页在未 ready 时提前进入。
- 发送按钮和提示文案绑定到运行时 ready 状态,导致界面像“卡住”。
## 本次适配
### 1. 主进程预热保留,但改成缓存优先
-`OpenClawConfigClient` 中加入运行时云配置缓存。
- 缓存内容包含:
- 上次成功的员工配置 payload
- 配置摘要
- 当前员工密钥指纹
- 启动时优先尝试读取缓存:
- 如果缓存命中且密钥未变,bundled runtime 直接基于缓存配置启动。
- 启动完成后再后台执行一次云端刷新。
- 如果缓存不存在或密钥变更:
- 仍按原始链路阻塞拉取 `fetchConfig("init")`
### 2. 缓存失效策略
- 当用户更换或清空员工密钥时,立即清理旧缓存。
- 缓存只在密钥指纹一致时复用,避免把上一位员工的配置拿来启动。
### 3. 启动页成为聊天入口
- 聊天视图新增启动页门禁:
- 服务未 ready 时,只展示启动页,不渲染聊天消息区和发送区。
- 服务 ready 后,自动进入聊天页。
- 启动页展示:
- 主状态文案
- 进度条
- 四段步骤:读取本地配置、准备本地助手、连接聊天服务、进入对话
- 失败时保留“重新准备”和“打开设置”入口
### 4. 聊天页恢复正常可发状态
- 聊天页不再把发送按钮绑定到启动期状态。
- 进入聊天页后:
- 发送按钮只受“已绑定、输入非空、未发送中、未保存中”控制。
- `ensureChatAvailable()` 保留,但只用于异常恢复:
- 理论上不再承担首开冷启动主路径。
### 5. 现有优化继续保留
- 只展示 `user` / `assistant` 主消息。
- 保留可折叠“思考过程”面板。
- 保留 `completed``delta` 的兜底显示逻辑。
## 涉及文件
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/ipc.ts`
- `apps/desktop/src/main/services/cloud-api.ts`
- `apps/desktop/src/main/services/app-config.ts`
- `apps/ui/src/App.tsx`
- `apps/ui/src/styles.css`
## 验收标准
- 已绑定情况下,打开应用先看到启动页,而不是灰按钮聊天页。
- 二次启动且缓存可用时,启动页等待明显短于首次启动。
- 启动完成后进入聊天页,发送按钮默认可用。
- 更换员工密钥后,不复用旧员工缓存。
- runtime 或 gateway 异常掉线时,仍能通过恢复逻辑重新可用。
## 测试建议
1. 首次启动,无缓存
- 应显示启动页并完成完整预热。
2. 再次启动,有缓存
- 应明显更快进入聊天页。
3. 更换员工密钥
- 旧缓存应失效,不应沿用旧员工配置。
4. 断网启动
- 有缓存时可继续进入;无缓存时停留在启动失败页。
5. 聊天页回归
- 进入聊天页后首条消息不应再承担完整冷启动链路。
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup",
gatewayStatus: "gateway:status",
gatewayConnect: "gateway:connect",
gatewayDisconnect: "gateway:disconnect",
......@@ -36,7 +37,7 @@
export type GatewayState = "unknown" | "connecting" | "connected" | "disconnected" | "error";
export type LogLevel = "info" | "warn" | "error";
export type MessageRole = "system" | "user" | "assistant";
export type MessageRole = "system" | "user" | "assistant" | "tool" | "toolResult";
export type AuthSessionState = "authenticated" | "anonymous" | "expired" | "error";
export type CreditStatus = "ok" | "low" | "empty";
export type RuntimeMode = "external-gateway" | "bundled-runtime";
......@@ -53,10 +54,18 @@ export type RuntimeCloudState = "unconfigured" | "loading" | "ready" | "error";
export type RuntimeTelemetryState = "idle" | "running" | "stopped" | "error";
export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "message_received" | "error" | "config_updated";
export type PluginStatus = "included" | "extension" | "unavailable";
export type SetupMode = "employee-key" | "direct-provider";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type WorkspaceStartupPhase = "idle" | "syncing-config" | "starting-runtime" | "connecting-gateway" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export interface WorkspaceWarmupResult {
accepted: boolean;
state: "scheduled" | "skipped";
message: string;
}
export interface GatewayStatus {
state: GatewayState;
url: string;
......@@ -246,9 +255,13 @@ export interface PluginSummary {
export interface WorkspaceSummary {
apiKeyConfigured: boolean;
bindingRequired: boolean;
setupRequired: boolean;
setupMode: SetupMode;
chatReady: boolean;
chatLaunchState: ChatLaunchState;
chatStatusMessage?: string;
startupPhase: WorkspaceStartupPhase;
startupMessage?: string;
employeeId?: string;
employeeName?: string;
welcomeMessage?: string;
......@@ -322,6 +335,16 @@ export interface ChatStreamDeltaEvent {
fullText?: string;
}
export interface ChatStreamStatusEvent {
type: "status";
requestId: string;
sessionId: string;
runId?: string;
stage: string;
label: string;
detail?: string;
}
export interface ChatStreamCompletedEvent {
type: "completed";
requestId: string;
......@@ -339,7 +362,7 @@ export interface ChatStreamErrorEvent {
message: string;
}
export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent;
export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamStatusEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent;
export type ChatStreamListener = (event: ChatStreamEvent) => void;
......@@ -350,6 +373,7 @@ export interface PromptResult {
}
export interface AppConfig {
setupMode: SetupMode;
provider: string;
baseUrl: string;
apiKeyConfigured: boolean;
......@@ -369,6 +393,7 @@ export interface DiagnosticsExportResult {
}
export interface SaveConfigInput {
setupMode: SetupMode;
provider: string;
baseUrl: string;
apiKey?: string;
......@@ -482,6 +507,7 @@ export interface SystemSummary {
export interface DesktopApi {
workspace: {
getSummary(): Promise<WorkspaceSummary>;
warmup(): Promise<WorkspaceWarmupResult>;
};
gateway: {
status(): Promise<GatewayStatus>;
......@@ -545,3 +571,6 @@ export interface DesktopApi {
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment