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

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

parent f9e6de26
This diff is collapsed.
This diff is collapsed.
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);
}
}
This diff is collapsed.
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