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

fix(desktop): clarify first-launch startup states and diagnostics

Prevent first-launch startup from being masked by the generic employee-config syncing state.
Differentiate config sync, project sync, runtime startup, and gateway connection states, and improve diagnostics and smoke coverage.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 5c3ae433
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";
......@@ -21,6 +21,8 @@ import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import { resolveGenericSkillsRoot } from "./services/generic-skills-root.js";
import { SkillCatalogService } from "./services/skill-catalog.js";
import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js";
import { ProjectStoreService } from "./services/project-store.js";
......@@ -31,9 +33,12 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import { ProjectIntentRouterService } from "./services/project-intent-router.js";
import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import { StartupLogger } from "./services/startup-logger.js";
interface RendererSmokeState {
usingMockApi: boolean;
viewMode?: "chat" | "experts" | "skills" | "settings";
skillsPageCatalog?: Array<{ id: string; name?: string; zhName?: string; selectable?: boolean }>;
gatewayStatus: {
state?: string;
version?: string;
......@@ -444,7 +449,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const prompt = process.env.QJCLAW_SMOKE_PROMPT?.trim() || `qjc smoke stream ${new Date().toISOString()}`;
const preferredSkillId = process.env.QJCLAW_SMOKE_SKILL_ID?.trim();
const smokeViewMode = process.env.QJCLAW_SMOKE_VIEW_MODE?.trim() === "experts" ? "experts" : "chat";
const requestedSmokeViewMode = process.env.QJCLAW_SMOKE_VIEW_MODE?.trim();
const smokeViewMode = requestedSmokeViewMode === "experts"
? "experts"
: requestedSmokeViewMode === "skills"
? "skills"
: "chat";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => {
......@@ -461,6 +471,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeToken = ${JSON.stringify(process.env.QJCLAW_SMOKE_AUTH_TOKEN ?? "")};
const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")};
const preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
const smokeViewMode = ${JSON.stringify(smokeViewMode)};
if (smokeBaseUrl) {
const current = await api.config.load();
await api.config.save({
......@@ -536,6 +547,34 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
runtimeState: workspace.runtimeState
}));
};
const waitForSkillsPageReady = async () => {
let workspace = await api.workspace.getSummary();
let catalog = await api.skillCatalog.list().catch(() => []);
const deadline = Date.now() + 45000;
let warmupQueued = false;
while (Date.now() < deadline) {
if (Array.isArray(catalog) && catalog.length > 0) {
return { workspace, catalog };
}
if (!warmupQueued) {
await api.workspace.warmup().catch(() => undefined);
warmupQueued = true;
}
await sleep(1000);
workspace = await api.workspace.getSummary();
catalog = await api.skillCatalog.list().catch(() => []);
}
throw new Error("Skills page did not become ready for smoke validation. lastState=" + JSON.stringify({
skillCatalogCount: Array.isArray(catalog) ? catalog.length : 0,
workspaceSkillCount: Array.isArray(workspace.skills) ? workspace.skills.length : 0,
currentProjectId: workspace.currentProjectId,
projectCount: workspace.projectCount,
startupPhase: workspace.startupPhase,
startupMessage: workspace.startupMessage,
runtimeCloudState: workspace.runtimeCloudState,
runtimeState: workspace.runtimeState
}));
};
const runtimeCloudStatus = await api.runtimeCloud.getStatus();
const runtimeCloudFetch = runtimeCloudStatus.apiKeyConfigured ? await api.runtimeCloud.fetchConfig("init") : runtimeCloudStatus;
const runtimeStatus = await api.runtime.getStatus();
......@@ -550,7 +589,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const profile = session.state === "authenticated" ? await api.profile.getSummary() : null;
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : [];
const workspace = await waitForWorkspaceReady();
const skillsPageState = smokeViewMode === "skills" ? await waitForSkillsPageReady() : null;
const workspace = skillsPageState?.workspace ?? await waitForWorkspaceReady();
const readyWorkspaceSkills = workspace.skills.filter((skill) => skill.ready);
const readySkills = skills.filter((skill) => skill.ready);
const selectedSkillId = preferredSkillId
......@@ -558,11 +598,13 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
: undefined;
const system = await api.system.getSummary();
const actionResult = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined
});
const actionResult = smokeViewMode === "skills"
? await actions.navigateToView("skills")
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined
});
return {
prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)},
......@@ -589,7 +631,38 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})()`);
await trace("runSmokeTest:send-script-finished");
const streamState = await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
const streamState = smokeViewMode === "skills"
? await waitForRendererSmokeState(window, 5000)
: await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
if (smokeViewMode === "skills") {
const finalState = await waitForRendererSmokeState(window, 5000);
const skillsPageCatalog = finalState?.skillsPageCatalog ?? streamState?.skillsPageCatalog ?? [];
const workspaceSkills = finalState?.skills ?? streamState?.skills ?? [];
if (!finalState || finalState.viewMode !== "skills") {
throw new Error("Renderer smoke did not switch to skills view.");
}
if (!Array.isArray(skillsPageCatalog)) {
throw new Error("Renderer smoke did not publish skillsPageCatalog.");
}
if (!Array.isArray(workspaceSkills)) {
throw new Error("Renderer smoke did not publish workspace skills.");
}
result.sendResult = {
...sendResult,
skillsPageCatalogCount: skillsPageCatalog.length,
workspaceSkillCount: workspaceSkills.length,
firstSkillsPageCatalogId: skillsPageCatalog[0]?.id ?? null
};
result.finalState = finalState;
result.ok = true;
await trace("runSmokeTest:skills-view-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
if (!streamState?.streamSmoke) {
throw new Error("Renderer stream smoke did not reach a terminal state.");
}
......@@ -677,15 +750,18 @@ async function bootstrap(): Promise<void> {
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN?.trim();
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
const smokeCloudBootstrapEnabled = Boolean(smokeCloudBaseUrl && smokeAuthToken);
const traceBootstrap = async (message: string) => {
let startupLogger: StartupLogger | undefined;
const traceBootstrap = async (message: string, context?: Record<string, unknown>) => {
await startupLogger?.info("bootstrap", "trace", message, context);
if (!smokeOutputPath) {
return;
}
const line = "[" + new Date().toISOString() + "] bootstrap:" + message + "\n";
await appendFile(smokeOutputPath + ".trace.log", line, "utf8").catch(() => undefined);
};
await traceBootstrap("when-ready");
const systemSummary = buildSystemSummary();
startupLogger = new StartupLogger(systemSummary.logsPath);
await traceBootstrap("when-ready", { isPackaged: systemSummary.isPackaged, userDataPath: systemSummary.userDataPath, logsPath: systemSummary.logsPath, smokeEnabled, smokeCloudBootstrapEnabled });
const configService = new AppConfigService(systemSummary.userDataPath);
const config = await configService.load();
......@@ -719,22 +795,23 @@ async function bootstrap(): Promise<void> {
}
}
const diagnosticsService = new DiagnosticsService(systemSummary.userDataPath);
const diagnosticsService = new DiagnosticsService(systemSummary.userDataPath, startupLogger);
const deviceIdentityService = new DeviceIdentityService(systemSummary.userDataPath);
await deviceIdentityService.load();
await traceBootstrap("device-identity-loaded");
await traceBootstrap("local-openclaw-config-start");
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
await traceBootstrap("local-openclaw-config-done");
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager);
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager, startupLogger);
await traceBootstrap("runtime-cloud-hydrate-start");
await runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done");
const skillStore = new SkillStoreService(systemSummary.userDataPath);
const projectStore = new ProjectStoreService(configService);
const genericSkillsRoot = resolveGenericSkillsRoot(systemSummary);
const projectStore = new ProjectStoreService(configService, { qSkillsRoot: genericSkillsRoot });
await projectStore.initialize();
await traceBootstrap("project-store-initialized");
const projectBundleService = new ProjectBundleService(configService, projectStore);
const projectBundleService = new ProjectBundleService(configService, projectStore, startupLogger);
const syncProjectBundles = async (
skills: Parameters<ProjectBundleService["syncRemoteBundles"]>[0],
configVersion: string | undefined,
......@@ -846,6 +923,11 @@ async function bootstrap(): Promise<void> {
const profileClient = new ProfileClient(configService, secretManager);
const creditClient = new CreditClient(configService, secretManager);
const skillClient = new SkillClient(skillStore);
const skillCatalogService = new SkillCatalogService({
systemSummary,
projectStore,
qSkillsRoot: genericSkillsRoot
});
const modelConfigClient = new ModelConfigClient(configService, secretManager);
const dailyReportService = new DailyReportService({
userDataPath: systemSummary.userDataPath,
......@@ -876,6 +958,7 @@ async function bootstrap(): Promise<void> {
profileClient,
creditClient,
skillClient,
skillCatalogService,
skillStore,
modelConfigClient,
runtimeCloudClient,
......@@ -889,6 +972,7 @@ async function bootstrap(): Promise<void> {
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
startupLogger: startupLogger!,
systemSummary,
localOpenClawConfig
});
......@@ -963,3 +1047,13 @@ void bootstrap().catch(async (error) => {
}
app.quit();
});
import { randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { ipcMain, shell, type WebContents } from "electron";
import {
IPC_CHANNELS,
......@@ -24,6 +24,7 @@ import type { AppConfigService } from "./services/app-config.js";
import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillCatalogService } from "./services/skill-catalog.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import {
......@@ -33,6 +34,7 @@ import {
type LocalOpenClawGatewayConfig
} from "./services/openclaw-local-config.js";
import type { SecretManager } from "./services/secrets.js";
import type { StartupLogger } from "./services/startup-logger.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import type { ProjectStoreService } from "./services/project-store.js";
......@@ -72,6 +74,7 @@ interface MainServices {
profileClient: ProfileClient;
creditClient: CreditClient;
skillClient: SkillClient;
skillCatalogService: SkillCatalogService;
skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient;
......@@ -85,6 +88,7 @@ interface MainServices {
projectSkillRouter: ProjectSkillRouterService;
projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
startupLogger: StartupLogger;
appVersion: string;
systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
......@@ -189,6 +193,16 @@ function buildPluginSummaries(runtimeStatus: RuntimeStatus): PluginSummary[] {
});
}
function buildProjectSyncSummary(message: string): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: message,
startupPhase: "syncing-projects",
startupMessage: message
};
}
const MANAGED_RUNTIME_START_RETRY_LIMIT = 2;
const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500;
const GATEWAY_CONNECT_RETRY_LIMIT = 10;
......@@ -212,6 +226,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
profileClient,
secretManager,
skillClient,
skillCatalogService,
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
......@@ -224,6 +239,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
startupLogger,
systemSummary,
localOpenClawConfig
} = services;
......@@ -422,6 +438,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = "";
const queueWorkspaceWarmup = async (
reason: string,
......@@ -532,29 +549,35 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus,
gatewayStatus
});
const shouldWaitForProjectSync = projects.length === 0
&& config.apiKeyConfigured
&& config.setupMode === "employee-key"
&& runtimeCloudStatus.state === "ready"
&& baseChatSummary.chatLaunchState !== "error";
if (shouldWaitForProjectSync && baseChatSummary.startupPhase !== "syncing-projects") {
void startupLogger.warn("workspace-summary", "phase.override", "Project sync phase is overriding the base startup phase because no project inventory is available yet.", {
baseLaunchState: baseChatSummary.chatLaunchState,
basePhase: baseChatSummary.startupPhase,
runtimeCloudState: runtimeCloudStatus.state,
projectCount: projects.length,
bundleSyncState: bundleSyncStatus.state
});
}
const chatSummary = projects.length > 0
? baseChatSummary
: bundleSyncFailed
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。",
chatStatusMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry.",
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。"
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
}
: {
chatReady: false,
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState,
chatStatusMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.chatStatusMessage,
startupPhase: config.apiKeyConfigured ? "syncing-config" as const : baseChatSummary.startupPhase,
startupMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.startupMessage
};
: shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary;
return {
const workspaceSummary: WorkspaceSummary = {
shellReady,
apiKeyConfigured: config.apiKeyConfigured,
bindingRequired: !config.apiKeyConfigured,
......@@ -585,12 +608,36 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
skillCount: skills.length,
skills,
plugins: buildPluginSummaries(runtimeStatus),
lastError: runtimeCloudStatus.lastError ?? runtimeStatus.lastError ?? gatewayStatus?.lastError
lastError: bundleSyncStatus.lastError ?? runtimeCloudStatus.lastError ?? runtimeStatus.lastError ?? gatewayStatus?.lastError
};
const workspaceSummaryLogKey = JSON.stringify({
phase: workspaceSummary.startupPhase,
launchState: workspaceSummary.chatLaunchState,
projectCount: workspaceSummary.projectCount,
runtimeCloudState: workspaceSummary.runtimeCloudState,
runtimeState: workspaceSummary.runtimeState,
lastError: workspaceSummary.lastError ?? ""
});
if (workspaceSummaryLogKey !== lastWorkspaceSummaryLogKey) {
lastWorkspaceSummaryLogKey = workspaceSummaryLogKey;
void startupLogger.info("workspace-summary", "transition", "Workspace startup summary changed.", {
phase: workspaceSummary.startupPhase,
launchState: workspaceSummary.chatLaunchState,
projectCount: workspaceSummary.projectCount,
runtimeCloudState: workspaceSummary.runtimeCloudState,
runtimeState: workspaceSummary.runtimeState,
bundleSyncState: bundleSyncStatus.state,
lastError: workspaceSummary.lastError
});
}
return workspaceSummary;
};
const exportDiagnostics = async () => {
const config = await getEffectiveConfig();
const workspaceSummary = await buildWorkspaceSummary();
const [gatewayStatus, gatewayHealth, logs, authSession, runtimeStatus, runtimeLogs, runtimeCloudStatus, runtimeTelemetryStatus] = await Promise.all([
gatewayClient.status(),
gatewayClient.health(),
......@@ -639,7 +686,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus,
runtimeLogs,
runtimeCloudStatus,
runtimeTelemetryStatus
runtimeTelemetryStatus,
workspaceSummary,
bundleSyncStatus: projectBundleService.getSyncStatus(),
startupLogPath: startupLogger.getSessionLogPath()
});
};
......@@ -1182,6 +1232,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
ipcMain.handle(IPC_CHANNELS.projectsSetActive, async (_event, projectId: string) => {
......@@ -1298,6 +1349,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return buildWorkspaceSummary();
}
},
skillCatalog: {
list: () => skillCatalogService.listForActiveProject()
},
auth: {
getSessionSummary: () => authClient.getSessionSummary(),
signIn: (input: SignInInput) => authClient.signIn(input.accessToken),
......@@ -1366,3 +1420,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
};
}
......@@ -23,6 +23,7 @@ import type {
import type { AppConfigService } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js";
import type { StartupLogger } from "./startup-logger.js";
interface SessionPayload {
user?: { displayName?: string; email?: string };
......@@ -510,6 +511,7 @@ export class OpenClawConfigClient {
private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private readonly cachePath: string;
private readonly startupLogger?: StartupLogger;
private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
......@@ -518,10 +520,11 @@ export class OpenClawConfigClient {
};
private cacheLoaded = false;
constructor(configService: AppConfigService, secretManager: SecretManager) {
constructor(configService: AppConfigService, secretManager: SecretManager, startupLogger?: StartupLogger) {
this.configService = configService;
this.secretManager = secretManager;
this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
this.startupLogger = startupLogger;
}
async hydrateCache(): Promise<void> {
......@@ -627,6 +630,7 @@ export class OpenClawConfigClient {
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache();
const config = await this.configService.load();
const startedAt = Date.now();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const apiKey = (await this.secretManager.getApiKey())?.trim();
......@@ -711,6 +715,12 @@ export class OpenClawConfigClient {
};
throw new Error(message);
}
await this.startupLogger?.error("runtime-cloud", "fetch.error", "Runtime cloud fetch failed without cache fallback.", {
action,
baseUrl,
elapsedMs: Date.now() - startedAt,
error: message
});
return this.fail(baseUrl, true, message);
}
}
......@@ -997,3 +1007,9 @@ export class ModelConfigClient {
......@@ -15,9 +15,12 @@ import type {
RuntimeTelemetryStatus,
SkillSummary,
SystemSummary,
UserProfileSummary
UserProfileSummary,
WorkspaceSummary
} from "@qjclaw/shared-types";
import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js";
import type { ProjectBundleSyncStatus } from "./project-bundle.js";
import type { StartupLogger } from "./startup-logger.js";
interface DiagnosticsSnapshotInput {
config: AppConfig;
......@@ -38,6 +41,10 @@ interface DiagnosticsSnapshotInput {
runtimeLogs?: LogEntry[];
runtimeCloudStatus?: RuntimeCloudStatus;
runtimeTelemetryStatus?: RuntimeTelemetryStatus;
workspaceSummary?: WorkspaceSummary;
bundleSyncStatus?: ProjectBundleSyncStatus;
startupLogPath?: string;
reason?: string;
}
function toSafeStamp(value: string): string {
......@@ -46,9 +53,11 @@ function toSafeStamp(value: string): string {
export class DiagnosticsService {
private readonly userDataPath: string;
private readonly startupLogger?: StartupLogger;
constructor(userDataPath: string) {
constructor(userDataPath: string, startupLogger?: StartupLogger) {
this.userDataPath = userDataPath;
this.startupLogger = startupLogger;
}
async exportSnapshot(input: DiagnosticsSnapshotInput): Promise<DiagnosticsExportResult> {
......@@ -58,6 +67,8 @@ export class DiagnosticsService {
const payload = {
createdAt,
reason: input.reason,
startupLogPath: input.startupLogPath,
app: {
name: input.systemSummary.appName,
version: input.appVersion,
......@@ -109,6 +120,10 @@ export class DiagnosticsService {
config: input.runtimeCloudStatus.config
}
: null,
workspace: input.workspaceSummary ?? null,
bundleSync: input.bundleSyncStatus
? { ...input.bundleSyncStatus }
: null,
runtimeTelemetry: input.runtimeTelemetryStatus ?? null,
runtime: {
status: input.runtimeStatus,
......@@ -133,10 +148,18 @@ export class DiagnosticsService {
await mkdir(diagnosticsDir, { recursive: true });
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
await this.startupLogger?.info("diagnostics", "snapshot.exported", "Diagnostics snapshot exported.", {
diagnosticsPath: filePath,
startupLogPath: input.startupLogPath,
reason: input.reason,
workspacePhase: input.workspaceSummary?.startupPhase,
workspaceLaunchState: input.workspaceSummary?.chatLaunchState
});
return {
filePath,
createdAt
createdAt,
startupLogPath: input.startupLogPath
};
}
}
......@@ -8,6 +8,7 @@ import extractZip from "extract-zip";
import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { StartupLogger } from "./startup-logger.js";
interface BundleManifestRecord {
sourceUrl: string;
......@@ -180,11 +181,13 @@ function logBundle(event: string, details: Record<string, unknown>): void {
export class ProjectBundleService {
private readonly configService: AppConfigService;
private readonly projectStore: ProjectStoreService;
private readonly startupLogger?: StartupLogger;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
constructor(configService: AppConfigService, projectStore: ProjectStoreService) {
constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) {
this.configService = configService;
this.projectStore = projectStore;
this.startupLogger = startupLogger;
}
getSyncStatus(): ProjectBundleSyncStatus {
......@@ -214,6 +217,13 @@ export class ProjectBundleService {
bundleAssetCount: bundleAssets.length
});
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", {
action: _action ?? "unknown",
configVersion,
workspaceRoot,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
......@@ -934,3 +944,7 @@ export class ProjectBundleService {
......@@ -13,6 +13,10 @@ import type {
WorkspaceSkillSummary
} from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import {
CURATED_GENERIC_SKILL_IDS,
getCuratedGenericSkillDefinition
} from "./curated-skills.js";
import type { SkillExecutionTarget } from "./skill-store.js";
interface StoredProjectRecord {
......@@ -60,6 +64,15 @@ interface ProjectSeed {
[key: string]: unknown;
}
interface ProjectStoreServiceOptions {
qSkillsRoot?: string;
}
interface ResolvedSkillFile {
skillDir: string;
fileName: string;
}
const PROJECTS_DIR = "projects";
const SKILLS_DIR = "skills";
const CRON_DIR = "cron";
......@@ -209,7 +222,7 @@ function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[]
}
function compareSkills(left: WorkspaceSkillSummary, right: WorkspaceSkillSummary): number {
return left.name.localeCompare(right.name, "zh-CN");
return (left.description || left.name).localeCompare(right.description || right.name, "zh-CN");
}
async function pathExists(targetPath: string): Promise<boolean> {
......@@ -251,6 +264,28 @@ function normalizeStringArray(value: unknown): string[] {
return [];
}
function buildSkillSummary(input: {
skillId: string;
name: string;
description: string;
category: string;
projectUpdatedAt: string;
resolvedFile?: ResolvedSkillFile | null;
}): WorkspaceSkillSummary {
return {
id: input.skillId,
name: input.name,
description: input.description,
category: input.category,
enabled: true,
ready: Boolean(input.resolvedFile),
downloadState: input.resolvedFile ? "ready" : "failed",
fileName: input.resolvedFile?.fileName,
lastSyncedAt: input.projectUpdatedAt,
lastError: input.resolvedFile ? undefined : "Skill directory does not contain a runnable description file."
};
}
function normalizeEntryType(value: unknown): ProjectPackageEntryType | undefined {
if (value === "workspace-entry" || value === "workspace_entry" || value === "workspace") {
return "workspace-entry";
......@@ -382,9 +417,11 @@ function normalizeProjectPackageConfig(record: StoredProjectRecord | null): Proj
export class ProjectStoreService {
private readonly configService: AppConfigService;
private readonly qSkillsRoot?: string;
constructor(configService: AppConfigService) {
constructor(configService: AppConfigService, options?: ProjectStoreServiceOptions) {
this.configService = configService;
this.qSkillsRoot = options?.qSkillsRoot?.trim() || undefined;
}
async initialize(): Promise<void> {
......@@ -667,37 +704,19 @@ export class ProjectStoreService {
const project = await this.getProjectById(projectId);
const projectRecord = await this.readProjectRecord(project.id);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
const skills: WorkspaceSkillSummary[] = [];
const merged = new Map<string, WorkspaceSkillSummary>();
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
continue;
}
const skillDir = path.join(skillsRoot, entry.name);
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
const skillId = sanitizeSkillId(entry.name);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
continue;
for (const skill of await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)) {
merged.set(skill.id, skill);
}
for (const skill of await this.listCuratedGenericSkills(project.updatedAt)) {
if (!merged.has(skill.id)) {
merged.set(skill.id, skill);
}
skills.push({
id: skillId,
name: entry.name,
description: `Local skill from project ${project.name}.`,
category: "project",
enabled: true,
ready: Boolean(skillFile),
downloadState: skillFile ? "ready" : "failed",
fileName: skillFile?.name,
lastSyncedAt: project.updatedAt,
lastError: skillFile ? undefined : "Skill directory does not contain a runnable description file."
});
}
return skills.sort(compareSkills);
return [...merged.values()].sort(compareSkills);
}
async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
......@@ -706,9 +725,14 @@ export class ProjectStoreService {
}
async getProjectSkillTarget(projectId: string, skillId: string): Promise<SkillExecutionTarget | undefined> {
const normalizedSkillId = sanitizeSkillId(skillId);
const projectRecord = await this.readProjectRecord(projectId);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
if (CURATED_GENERIC_SKILL_IDS.has(normalizedSkillId)) {
return this.resolveCuratedGenericSkillTarget(normalizedSkillId);
}
if (boundSkillIds.size > 0 && !boundSkillIds.has(normalizedSkillId)) {
return undefined;
}
......@@ -719,20 +743,19 @@ export class ProjectStoreService {
if (!entry.isDirectory()) {
continue;
}
if (sanitizeSkillId(entry.name) !== skillId) {
if (sanitizeSkillId(entry.name) !== normalizedSkillId) {
continue;
}
const skillDir = path.join(skillsRoot, entry.name);
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
if (!skillFile) {
const resolvedFile = await this.resolveSkillFile(skillDir);
if (!resolvedFile) {
return undefined;
}
return {
skillId,
skillId: normalizedSkillId,
name: entry.name,
fileName: skillFile.name,
localPath: path.join(skillDir, skillFile.name)
fileName: resolvedFile.fileName,
localPath: path.join(skillDir, resolvedFile.fileName)
};
}
return undefined;
......@@ -756,7 +779,7 @@ export class ProjectStoreService {
ready: input.ready ?? true
});
const activeProject = await this.getActiveProject().catch(() => null);
if (!activeProject) {
if (!activeProject || activeProject.isBuiltinHome) {
await this.setActiveProject(project.id);
}
return project;
......@@ -987,6 +1010,112 @@ export class ProjectStoreService {
return this.resolveWorkspaceChildPath(path.join(await this.getWorkspaceRoot(), PROJECTS_DIR), projectId);
}
private async listWorkspaceSkills(
projectName: string,
projectUpdatedAt: string,
boundSkillIds: Set<string>
): Promise<WorkspaceSkillSummary[]> {
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
const skills: WorkspaceSkillSummary[] = [];
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
continue;
}
const skillId = sanitizeSkillId(entry.name);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
continue;
}
const skillDir = path.join(skillsRoot, entry.name);
const resolvedFile = await this.resolveSkillFile(skillDir);
skills.push(buildSkillSummary({
skillId,
name: entry.name,
description: `Local skill from project ${projectName}.`,
category: "project",
projectUpdatedAt,
resolvedFile
}));
}
return skills;
}
private async listCuratedGenericSkills(projectUpdatedAt: string): Promise<WorkspaceSkillSummary[]> {
const genericRoot = this.resolveCuratedSkillsRoot();
if (!genericRoot) {
return [];
}
const skills: WorkspaceSkillSummary[] = [];
for (const skillId of CURATED_GENERIC_SKILL_IDS) {
const definition = getCuratedGenericSkillDefinition(skillId);
if (!definition) {
continue;
}
const skillDir = path.join(genericRoot, skillId);
const resolvedFile = await this.resolveSkillFile(skillDir);
if (!resolvedFile) {
continue;
}
skills.push(buildSkillSummary({
skillId,
name: definition.zhName,
description: definition.zhDescription,
category: "generic",
projectUpdatedAt,
resolvedFile
}));
}
return skills;
}
private resolveCuratedSkillsRoot(): string | null {
return this.qSkillsRoot ?? process.env.QJCLAW_SKILL_CATALOG_ROOT?.trim() ?? null;
}
private async resolveCuratedGenericSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
const definition = getCuratedGenericSkillDefinition(skillId);
const genericRoot = this.resolveCuratedSkillsRoot();
if (!definition || !genericRoot) {
return undefined;
}
const skillDir = path.join(genericRoot, skillId);
const resolvedFile = await this.resolveSkillFile(skillDir);
if (!resolvedFile) {
return undefined;
}
return {
skillId,
name: definition.zhName,
fileName: resolvedFile.fileName,
localPath: path.join(skillDir, resolvedFile.fileName)
};
}
private async resolveSkillFile(skillDir: string): Promise<ResolvedSkillFile | null> {
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const preferred = files.find((file) => file.isFile() && file.name === "SKILL.md");
const fallback = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
const resolved = preferred ?? fallback;
if (!resolved) {
return null;
}
return {
skillDir,
fileName: resolved.name
};
}
private resolveWorkspaceChildPath(rootPath: string, childName: string): string {
const trimmed = childName.trim();
if (!trimmed || path.basename(trimmed) !== trimmed) {
......
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
export type StartupLogLevel = "info" | "warn" | "error";
export type StartupLogPhase = "bootstrap" | "runtime-cloud" | "project-bundle" | "workspace-summary" | "diagnostics";
interface StartupLogEntry {
ts: string;
level: StartupLogLevel;
phase: StartupLogPhase;
event: string;
message: string;
context?: Record<string, unknown>;
}
function toSafeStamp(value: string): string {
return value.replace(/[:.]/g, "-");
}
function sanitizeUrlLikeString(value: string): string {
if (!/^https?:\/\//i.test(value)) {
return value;
}
try {
const parsed = new URL(value);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
return value;
}
}
function sanitizeValue(value: unknown, key?: string): unknown {
if (value === null || value === undefined) {
return value;
}
if (typeof value === "string") {
if (key && /(token|secret|api[_-]?key|password)/i.test(key)) {
return "<redacted>";
}
return sanitizeUrlLikeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item));
}
if (typeof value === "object") {
const next: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>)) {
next[entryKey] = sanitizeValue(entryValue, entryKey);
}
return next;
}
return value;
}
export class StartupLogger {
private readonly sessionLogPath: string;
private readonly latestLogPath: string;
private readonly initPromise: Promise<void>;
private writeChain: Promise<void> = Promise.resolve();
constructor(logsPath: string) {
const startupLogsDir = path.join(logsPath, "startup");
const stamp = toSafeStamp(new Date().toISOString());
this.sessionLogPath = path.join(startupLogsDir, `startup-${stamp}.log`);
this.latestLogPath = path.join(startupLogsDir, "startup-latest.log");
this.initPromise = mkdir(startupLogsDir, { recursive: true })
.then(() => Promise.all([
writeFile(this.sessionLogPath, "", "utf8"),
writeFile(this.latestLogPath, "", "utf8")
]))
.then(() => undefined);
}
getSessionLogPath(): string {
return this.sessionLogPath;
}
info(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("info", phase, event, message, context);
}
warn(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("warn", phase, event, message, context);
}
error(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("error", phase, event, message, context);
}
async log(level: StartupLogLevel, phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
const entry: StartupLogEntry = {
ts: new Date().toISOString(),
level,
phase,
event,
message,
...(context ? { context: sanitizeValue(context) as Record<string, unknown> } : {})
};
const line = JSON.stringify(entry) + "`n";
this.writeChain = this.writeChain.then(async () => {
await this.initPromise;
await appendFile(this.sessionLogPath, line, "utf8");
await appendFile(this.latestLogPath, line, "utf8");
}).catch(() => undefined);
await this.writeChain;
}
}
......@@ -44,6 +44,9 @@ const desktopApi: DesktopApi = {
list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList),
setActive: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.projectsSetActive, projectId)
},
skillCatalog: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.skillCatalogList)
},
auth: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
......
......@@ -307,6 +307,7 @@ const startupCurtainCopy = {
brandTagline: "START YOUR IDEAS",
loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
syncingConfig: "\u6b63\u5728\u540c\u6b65\u5de5\u4f5c\u914d\u7f6e",
syncingProjects: "\u6b63\u5728\u540c\u6b65\u9879\u76ee\u914d\u7f6e",
startingRuntime: "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u5efa\u7acb\u5bf9\u8bdd\u8fde\u63a5",
ready: "\u51c6\u5907\u5b8c\u6210\uff0c\u6b63\u5728\u8fdb\u5165\u5bf9\u8bdd",
......@@ -686,6 +687,8 @@ function getStartupProgress(phase: WorkspaceSummary["startupPhase"] | undefined)
switch (phase) {
case "syncing-config":
return 0.24;
case "syncing-projects":
return 0.4;
case "starting-runtime":
return 0.56;
case "connecting-gateway":
......@@ -711,6 +714,8 @@ function getStartupCurtainStatus(
switch (phase) {
case "syncing-config":
return startupCurtainCopy.syncingConfig;
case "syncing-projects":
return startupCurtainCopy.syncingProjects;
case "starting-runtime":
return startupCurtainCopy.startingRuntime;
case "connecting-gateway":
......@@ -960,7 +965,7 @@ export default function App() {
return;
}
const nextShouldPoll = Boolean(nextWorkspace) && (
const nextShouldPoll = nextWorkspace != null && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
);
......@@ -1905,7 +1910,7 @@ export default function App() {
try {
const result = await desktopApi.diagnostics.exportSnapshot();
setInfoText(ui.exported + result.filePath);
setInfoText(ui.exported + result.filePath + (result.startupLogPath ? " | startup: " + result.startupLogPath : ""));
} catch (error) {
setErrorText(err(error));
}
......@@ -2260,6 +2265,7 @@ export default function App() {
<div className="button-row startup-overlay-actions">
<button type="button" disabled={refreshing} onClick={() => void retryStartup()}>{refreshing ? ui.preparing : ui.startupRetry}</button>
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
<button type="button" className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div>
) : null}
</div>
......
......@@ -935,6 +935,24 @@ strong { font-weight: 600; }
.composer-field {
display: grid;
gap: 10px;
}
.composer-skill-badge {
justify-self: start;
min-width: 0;
max-width: 100%;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid #d7e4f2;
background: #f3f8ff;
color: #1f5f9c;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-field textarea {
......@@ -956,6 +974,24 @@ strong { font-weight: 600; }
min-width: 0;
}
.composer-left-tools-expert {
min-height: 38px;
}
.composer-mode-label {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: #f2f6fb;
color: #5f7188;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.skill-trigger,
.skill-chip {
min-width: 0;
......@@ -970,6 +1006,14 @@ strong { font-weight: 600; }
font-weight: 700;
}
.skill-trigger.text-trigger {
width: auto;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.skill-chip {
background: rgba(15, 123, 255, 0.08);
border-color: rgba(15, 123, 255, 0.16);
......@@ -1716,6 +1760,10 @@ strong { font-weight: 600; }
border-radius: 14px;
}
.composer-skill-badge {
max-width: 220px;
}
.composer-field textarea {
min-height: 68px;
max-height: 144px;
......@@ -1732,6 +1780,13 @@ strong { font-weight: 600; }
height: 34px;
}
.skill-trigger.text-trigger {
width: auto;
height: 34px;
padding: 0 12px;
font-size: 12px;
}
.skill-chip {
max-width: 220px;
overflow: hidden;
......@@ -2253,6 +2308,11 @@ button.secondary {
box-shadow: inset 0 0 0 1px rgba(216, 225, 237, 0.96);
}
.skill-trigger.text-trigger {
background: #f3f7fc;
color: #44617f;
}
.skill-chip {
background: rgba(240, 246, 255, 0.94);
color: #45678f;
......@@ -2513,3 +2573,478 @@ button.secondary {
width: 100%;
}
}
.skill-menu {
width: min(720px, calc(100vw - 72px));
gap: 12px;
padding: 14px;
border-radius: 22px;
border: 1px solid #d9e5eb;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 20px 44px rgba(34, 58, 87, 0.12);
}
.skill-menu-search {
position: relative;
display: flex;
align-items: center;
}
.skill-menu-search-icon {
position: absolute;
left: 14px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #7690a2;
pointer-events: none;
}
.skill-menu-search-icon svg {
width: 16px;
height: 16px;
}
.skill-menu-search input {
min-height: 42px;
padding: 10px 14px 10px 40px;
border-radius: 14px;
border: 1px solid #d5e4e7;
background: #f7fbfb;
}
.skill-menu-list {
max-height: min(392px, 58vh);
overflow: auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(84px, auto);
gap: 10px;
padding-right: 4px;
align-content: start;
}
.skill-menu-item {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid transparent;
background: #fbfdfd;
box-shadow: inset 0 0 0 1px rgba(230, 236, 240, 0.72);
}
.skill-menu-skill-item {
min-height: 84px;
}
.skill-menu-item.highlighted {
border-color: rgba(20, 184, 166, 0.28);
background: #f0fbfa;
}
.skill-menu-item.active {
border-color: rgba(13, 148, 136, 0.22);
background: #edf9f8;
}
.skill-menu-item:disabled {
opacity: 1;
cursor: default;
}
.skill-menu-item-main {
min-width: 0;
display: grid;
gap: 3px;
}
.skill-menu-item-main strong {
font-size: 13px;
line-height: 1.35;
color: #153b46;
}
.skill-menu-item-main small {
color: #6b7d8d;
font-size: 11px;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.skill-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
}
.skill-tag.source {
background: #eef7fb;
color: #47697c;
}
.skill-tag.availability.usable {
background: rgba(13, 148, 136, 0.14);
color: #0f766e;
}
.skill-tag.availability.limited {
background: rgba(249, 115, 22, 0.12);
color: #c25a12;
}
.skill-tag.availability.compact {
align-self: flex-start;
margin-top: 1px;
}
.skill-menu-empty {
padding: 18px 12px;
border-radius: 16px;
border: 1px dashed #d7e3e7;
color: #728392;
text-align: center;
font-size: 13px;
line-height: 1.6;
}
.skill-page-stack {
display: grid;
gap: 14px;
}
.skill-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.skill-page-header h3 {
font-size: 28px;
line-height: 1.2;
color: #173945;
}
.skill-page-header p {
margin-top: 8px;
max-width: 720px;
color: #667794;
font-size: 14px;
line-height: 1.7;
}
.skill-page-count {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 0 0 1px rgba(214, 225, 235, 0.92);
color: #355264;
font-size: 13px;
line-height: 1;
}
.skill-page-panel {
padding: 16px;
}
.skill-catalog-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.skill-card {
gap: 12px;
min-height: 160px;
padding: 18px;
border-radius: 20px;
border: 1px solid #dfe8ec;
background: linear-gradient(180deg, #ffffff, #fbfdfd);
box-shadow: 0 12px 28px rgba(34, 58, 87, 0.06);
}
.skill-card.selectable {
border-color: rgba(13, 148, 136, 0.18);
background: linear-gradient(180deg, #ffffff, #f4fbfa);
}
.skill-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.skill-card-copy {
min-width: 0;
}
.skill-card-head strong {
display: block;
color: #173945;
font-size: 16px;
line-height: 1.35;
}
.skill-card-copy span {
display: block;
margin-top: 4px;
color: #7a8998;
font-size: 11px;
line-height: 1.4;
}
.skill-card p {
margin: 0;
color: #607282;
font-size: 12px;
line-height: 1.75;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 1100px) {
.skill-menu {
width: min(100vw - 48px, 620px);
}
}
@media (max-width: 960px) {
.skill-page-header {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 720px) {
.skill-menu {
left: 0;
right: auto;
width: min(100vw - 32px, 100%);
}
.skill-menu-list {
grid-template-columns: 1fr;
max-height: min(420px, 62vh);
}
.skill-menu-item {
grid-template-columns: 1fr;
}
.skill-page-count {
justify-content: center;
}
.skill-catalog-grid {
grid-template-columns: 1fr;
}
}
.skill-page-hero {
padding: 18px 4px 2px;
border: 0;
background: transparent;
box-shadow: none;
}
.skill-page-header {
align-items: center;
}
.skill-page-header p {
margin: 0;
max-width: 720px;
}
.skill-page-panel {
padding: 18px;
}
.skill-catalog-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.skill-card.simple {
min-height: 128px;
padding: 18px 20px;
border-radius: 18px;
border: 1px solid #dfe7ee;
background: #ffffff;
box-shadow: none;
align-content: start;
gap: 8px;
}
.skill-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.skill-card.simple strong {
color: #173945;
font-size: 16px;
line-height: 1.4;
font-weight: 600;
}
.skill-card-action {
flex: 0 0 auto;
min-height: 32px;
padding: 0 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.skill-card.simple .skill-card-use {
margin: 0;
color: #607282;
font-size: 12px;
line-height: 1.75;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.skill-card.simple .skill-card-hint {
color: #7a8998;
font-size: 11px;
line-height: 1.6;
}
.skill-menu-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(104px, auto);
}
.skill-menu-item.skill-menu-skill-item {
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 12px;
padding: 14px;
border-radius: 18px;
min-height: 104px;
}
.skill-menu-item-icon {
width: 56px;
height: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: linear-gradient(135deg, #edf5fb, #dcebf6);
color: #173d5c;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.02em;
}
.skill-menu-item-main {
gap: 4px;
padding-top: 2px;
}
.skill-menu-item-main strong {
font-size: 14px;
}
.skill-menu-item-main small {
font-size: 12px;
line-height: 1.65;
-webkit-line-clamp: 3;
}
.skill-menu-item-action {
align-self: start;
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #eef4fb;
color: #165fc7;
font-size: 11px;
font-weight: 700;
box-shadow: inset 0 0 0 1px #d4e0ee;
}
.skill-menu-item-action.active {
background: linear-gradient(135deg, #1c7cf2, #1967dc);
color: #ffffff;
box-shadow: 0 8px 18px rgba(39, 95, 166, 0.16);
}
@media (max-width: 960px) {
.skill-page-header {
align-items: stretch;
}
}
@media (max-width: 720px) {
.skill-page-hero {
padding: 8px 0 0;
}
.skill-card {
min-height: 0;
}
.skill-card-head {
grid-template-columns: 56px minmax(0, 1fr);
}
.skill-card-top {
align-items: stretch;
flex-direction: column;
}
.skill-card-action {
align-self: flex-start;
}
.skill-card-use,
.skill-tag.availability {
grid-column: 2;
justify-self: start;
}
.skill-menu-item.skill-menu-skill-item {
grid-template-columns: 48px minmax(0, 1fr);
}
.skill-menu-item-icon {
width: 48px;
height: 48px;
border-radius: 16px;
font-size: 18px;
}
.skill-menu-item-action {
grid-column: 2;
justify-self: start;
}
}
......@@ -277,33 +277,47 @@ if (startupOnly === 'true') {
}
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
if (streamSmoke.phase !== 'completed') {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
}
if (streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
}
if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) {
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
}
if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.');
}
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.');
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
}
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.');
const smokeViewMode = String(sendResult.smokeViewMode || 'chat');
const finalState = result.finalState || {};
if (smokeViewMode === 'skills') {
if (String(finalState.viewMode || '') !== 'skills') {
throw new Error('Skills smoke did not end on skills view: ' + String(finalState.viewMode || ''));
}
if (typeof sendResult.skillsPageCatalogCount !== 'number') {
throw new Error('Skills smoke did not report skillsPageCatalogCount.');
}
if (typeof sendResult.workspaceSkillCount !== 'number') {
throw new Error('Skills smoke did not report workspaceSkillCount.');
}
} else {
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
if (streamSmoke.phase !== 'completed') {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
}
if (streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
}
if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) {
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
}
if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.');
}
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.');
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
}
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.');
}
}
if (String(sendResult.system && sendResult.system.userDataPath) !== expectedUserData) {
throw new Error('Smoke ran against an unexpected userData path: ' + (sendResult.system && sendResult.system.userDataPath));
......@@ -312,25 +326,29 @@ if (String(sendResult.system && sendResult.system.logsPath) !== expectedLogs) {
throw new Error('Smoke ran against an unexpected logs path: ' + (sendResult.system && sendResult.system.logsPath));
}
const diagnosticsPath = String(sendResult.diagnostics && sendResult.diagnostics.filePath || '');
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
const diagnostics = JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'));
const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath)
? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'))
: null;
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.');
}
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.');
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.');
}
if (!diagnostics.runtimeTelemetry) {
throw new Error('Diagnostics snapshot did not include runtimeTelemetry.');
if (smokeViewMode !== 'skills') {
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.');
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.');
}
if (!diagnostics || !diagnostics.runtimeTelemetry) {
throw new Error('Diagnostics snapshot did not include runtimeTelemetry.');
}
}
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
......@@ -367,7 +385,7 @@ if (expectBundled === 'true') {
}
}
let workspaceEntryValidated = false;
if (expectWorkspaceEntry === 'true') {
if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const latestStatusLabel = String(streamSmoke.latestStatusLabel || '');
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
......
# Desktop Startup Handoff (2026-04-07)
## Goal
排查并优化 Windows 安装包首启卡在“正在同步员工配置”的问题,重点处理两件事:
1. 修正启动状态汇总,避免把真实错误或后续阶段统一显示成“正在同步员工配置”
2. 增加首启结构化日志和更完整的 diagnostics 信息,便于现场机器定位卡点
## Current Status
当前不是完成态,但主体改动已经做了大半。
已完成:
- 主进程接入 startup logger 基础能力
- diagnostics 导出内容增强
- workspace summary 逻辑已改,新增 `syncing-projects` 阶段,避免“无项目”覆盖真实错误
- UI 已做最小配套修改:
- 支持 `syncing-projects`
- 导出 diagnostics 后显示 `startupLogPath`
- 启动错误遮罩上增加“导出诊断”按钮
未完成:
- 当前还没跑通整仓 typecheck
- 还没跑桌面端/安装包首启验证
- 日志点已接入到部分关键服务,但还没有做完整的 installer 现场回归
## Current Blocker
最新一次整仓类型检查:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
结果:
- `packages/shared-types` 通过
- `packages/gateway-client` 通过
- `apps/ui` 失败
当前唯一已确认报错:
- `apps/ui/src/App.tsx:969`
- `nextWorkspace` is possibly `null`
对应代码附近逻辑是启动轮询里的:
```ts
const nextWorkspace = await refresh(false);
const nextShouldPoll = Boolean(nextWorkspace) && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
);
```
这里需要把 `nextWorkspace` 收窄后再访问字段。
## Files Changed For This Task
确认与本任务直接相关的文件:
- `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/diagnostics.ts`
- `apps/desktop/src/main/services/project-bundle.ts`
- `apps/desktop/src/main/services/startup-logger.ts`
- `packages/shared-types/src/index.ts`
- `apps/ui/src/App.tsx`
## Important Functional Changes
### 1. New startup logger
新增:
- `apps/desktop/src/main/services/startup-logger.ts`
作用:
-`<logsPath>/startup/` 写 JSONL 风格首启日志
- 生成会话日志和 latest 日志
- 对 token / api key / password / URL 做基本脱敏
### 2. Shared types updated
`packages/shared-types/src/index.ts` 已添加:
- `WorkspaceStartupPhase` 新值:`syncing-projects`
- `DiagnosticsExportResult.startupLogPath?: string`
### 3. Diagnostics export enhanced
`apps/desktop/src/main/services/diagnostics.ts` 当前已支持导出更多现场信息:
- `workspaceSummary`
- `bundleSyncStatus`
- `startupLogPath`
- `reason`
并通过 `startupLogger` 写 diagnostics 导出记录。
### 4. Workspace summary fix
`apps/desktop/src/main/ipc.ts` 是本次最关键的修复点。
当前逻辑已经改成:
- `error` 不再被“无项目”覆盖
- 当 runtime cloud ready 但还没有项目时,进入 `syncing-projects`
- 如果 bundle sync 失败,返回明确错误 summary
- summary 变化会写 startup logger
- diagnostics export 时会附带 startup log path / workspace summary / bundle sync status
这部分是本次修复“首页闪一下又被‘正在同步员工配置’盖回去”的核心。
### 5. Index / service wiring
`apps/desktop/src/main/index.ts` 已做的事情:
- 创建 `startupLogger`
- 注入 `DiagnosticsService`
- 注入 `OpenClawConfigClient`
- 注入 `ProjectBundleService`
- 注入 `registerDesktopIpc`
`cloud-api.ts` / `project-bundle.ts` 已开始接入 logger,但后续仍建议在类型检查通过后再补一次日志点覆盖率检查。
### 6. UI minimal support
`apps/ui/src/App.tsx` 当前的目标性修改只有这些:
- `startupCurtainCopy.syncingProjects`
- `getStartupProgress()` 支持 `syncing-projects`
- `getStartupCurtainStatus()` 支持 `syncing-projects`
- diagnostics 成功提示里包含 `startupLogPath`
- 启动错误遮罩新增 diagnostics 导出按钮
## Other Dirty Files In Worktree
当前 `git diff --name-only` 里还有这些未提交改动:
- `apps/desktop/src/main/services/project-store.ts`
- `apps/desktop/src/preload/index.ts`
- `apps/ui/src/styles.css`
- `build/scripts/electron-smoke.ps1`
这些不在我本次“启动日志/状态机”主线修改的核心列表里,可能是已有改动或其他任务改动。
如果另一个 AI 要继续本任务,不要默认回退这些文件。
## Whether This Affects Another Task
会不会影响,取决于另一个任务改哪些文件。
影响较小的情况:
- 另一个任务不改下列文件:
- `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/diagnostics.ts`
- `apps/desktop/src/main/services/project-bundle.ts`
- `apps/ui/src/App.tsx`
- `packages/shared-types/src/index.ts`
容易冲突的情况:
- 另一个任务要改 `App.tsx`
- 另一个任务要改 desktop main 启动链路
- 另一个任务要改 diagnostics / preload / shared-types IPC 类型
所以如果你只是临时切去改完全无关模块,通常问题不大;如果另一个任务也碰启动、UI 主壳、共享类型或 preload,冲突概率就高。
## Recommended Pause Strategy
如果要先切走做别的任务,建议:
1. 不要继续在当前改动基础上随手混改启动相关文件
2. 让另一个任务尽量避开本任务相关文件
3. 如果另一个 AI 要继续本任务,先读本文件,再跑一次 typecheck,从 UI 的 nullability 报错开始收尾
## Recommended Next Steps For The Next AI
1. 修复 `apps/ui/src/App.tsx:969` 的空值收窄问题
2. 重新运行:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
3. 如果 typecheck 继续报主进程相关错误,再逐个修:
- `index.ts`
- `cloud-api.ts`
- `project-bundle.ts`
- `ipc.ts`
4. typecheck 通过后,验证桌面端:
- 启动阶段文案是否出现 `syncing-projects`
- diagnostics 导出是否返回 `startupLogPath`
- 启动错误遮罩是否能导出日志
5. 再做安装包/首启验证,重点看是否能区分:
- 员工配置请求失败
- 项目 bundle 同步失败
- runtime 启动失败
- gateway 连接失败
- summary 被覆盖
## Useful Commands
查看工作区变更:
```powershell
git -c safe.directory=D:/qjclaw diff --name-only
```
重新看 UI 变更:
```powershell
git -c safe.directory=D:/qjclaw diff -- apps/ui/src/App.tsx
```
重新跑类型检查:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
## Notes
- 这个仓库当前在 Windows 环境下,且 `D:\qjclaw` 需要按 safe.directory 方式使用 git
- 我这轮没有做 destructive revert
- 当前最重要的是先保持工作区稳定,不要在 `App.tsx` 上再叠加大改
\ No newline at end of file
export const IPC_CHANNELS = {
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup",
gatewayStatus: "gateway:status",
......@@ -20,6 +20,7 @@ export const IPC_CHANNELS = {
configSave: "config:save",
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
skillCatalogList: "skill-catalog:list",
chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project",
chatCreateSession: "chat:create-session",
......@@ -62,7 +63,7 @@ export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "m
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 WorkspaceStartupPhase = "idle" | "syncing-config" | "syncing-projects" | "starting-runtime" | "connecting-gateway" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
......@@ -506,6 +507,7 @@ export interface AppConfig {
export interface DiagnosticsExportResult {
filePath: string;
createdAt: string;
startupLogPath?: string;
}
export interface SaveConfigInput {
......@@ -571,6 +573,25 @@ export interface SkillSummary {
requiresCredits?: number;
}
export type SkillCatalogAvailability = "usable" | "info-only";
export type SkillCatalogSource = "project" | "q-skills" | "hybrid";
export interface SkillCatalogItem {
id: string;
name: string;
zhName: string;
description: string;
zhDescription: string;
category: string;
source: SkillCatalogSource;
availability: SkillCatalogAvailability;
selectable: boolean;
isProjectSkill: boolean;
showInSkillsPage: boolean;
searchText: string;
selectionHint?: string;
}
export interface ModelCatalogItemSummary {
id: string;
label: string;
......@@ -656,6 +677,9 @@ export interface DesktopApi {
list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>;
};
skillCatalog: {
list(): Promise<SkillCatalogItem[]>;
};
auth: {
getSessionSummary(): Promise<AuthSessionSummary>;
signIn(input: SignInInput): Promise<AuthSessionSummary>;
......@@ -692,3 +716,4 @@ export interface DesktopApi {
exportSnapshot(): Promise<DiagnosticsExportResult>;
};
}
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