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 { appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron"; import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client"; import { GatewayClient } from "@qjclaw/gateway-client";
...@@ -21,6 +21,8 @@ import { SecretManager } from "./services/secrets.js"; ...@@ -21,6 +21,8 @@ import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js"; import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js"; import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.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 { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js"; import { SkillStoreService } from "./services/skill-store.js";
import { ProjectStoreService } from "./services/project-store.js"; import { ProjectStoreService } from "./services/project-store.js";
...@@ -31,9 +33,12 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js"; ...@@ -31,9 +33,12 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import { ProjectIntentRouterService } from "./services/project-intent-router.js"; import { ProjectIntentRouterService } from "./services/project-intent-router.js";
import { ProjectSkillRouterService } from "./services/project-skill-router.js"; import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import { StartupLogger } from "./services/startup-logger.js";
interface RendererSmokeState { interface RendererSmokeState {
usingMockApi: boolean; usingMockApi: boolean;
viewMode?: "chat" | "experts" | "skills" | "settings";
skillsPageCatalog?: Array<{ id: string; name?: string; zhName?: string; selectable?: boolean }>;
gatewayStatus: { gatewayStatus: {
state?: string; state?: string;
version?: string; version?: string;
...@@ -444,7 +449,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -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 prompt = process.env.QJCLAW_SMOKE_PROMPT?.trim() || `qjc smoke stream ${new Date().toISOString()}`;
const preferredSkillId = process.env.QJCLAW_SMOKE_SKILL_ID?.trim(); 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() || ""; const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
await trace("runSmokeTest:before-send-script"); await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => { const sendResult = await window.webContents.executeJavaScript(`(async () => {
...@@ -461,6 +471,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -461,6 +471,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeToken = ${JSON.stringify(process.env.QJCLAW_SMOKE_AUTH_TOKEN ?? "")}; 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 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 preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
const smokeViewMode = ${JSON.stringify(smokeViewMode)};
if (smokeBaseUrl) { if (smokeBaseUrl) {
const current = await api.config.load(); const current = await api.config.load();
await api.config.save({ await api.config.save({
...@@ -536,6 +547,34 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -536,6 +547,34 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
runtimeState: workspace.runtimeState 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 runtimeCloudStatus = await api.runtimeCloud.getStatus();
const runtimeCloudFetch = runtimeCloudStatus.apiKeyConfigured ? await api.runtimeCloud.fetchConfig("init") : runtimeCloudStatus; const runtimeCloudFetch = runtimeCloudStatus.apiKeyConfigured ? await api.runtimeCloud.fetchConfig("init") : runtimeCloudStatus;
const runtimeStatus = await api.runtime.getStatus(); const runtimeStatus = await api.runtime.getStatus();
...@@ -550,7 +589,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -550,7 +589,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const profile = session.state === "authenticated" ? await api.profile.getSummary() : null; const profile = session.state === "authenticated" ? await api.profile.getSummary() : null;
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null; const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : []; 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 readyWorkspaceSkills = workspace.skills.filter((skill) => skill.ready);
const readySkills = skills.filter((skill) => skill.ready); const readySkills = skills.filter((skill) => skill.ready);
const selectedSkillId = preferredSkillId const selectedSkillId = preferredSkillId
...@@ -558,11 +598,13 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -558,11 +598,13 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
?? readySkills.find((skill) => skill.id === preferredSkillId)?.id) ?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
: undefined; : undefined;
const system = await api.system.getSummary(); const system = await api.system.getSummary();
const actionResult = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, { const actionResult = smokeViewMode === "skills"
mode: ${JSON.stringify(smokeViewMode)}, ? await actions.navigateToView("skills")
projectId: ${JSON.stringify(smokeProjectId)}, : await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
skillId: selectedSkillId || undefined mode: ${JSON.stringify(smokeViewMode)},
}); projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined
});
return { return {
prompt: ${JSON.stringify(prompt)}, prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)}, smokeViewMode: ${JSON.stringify(smokeViewMode)},
...@@ -589,7 +631,38 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -589,7 +631,38 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})()`); })()`);
await trace("runSmokeTest:send-script-finished"); 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) { if (!streamState?.streamSmoke) {
throw new Error("Renderer stream smoke did not reach a terminal state."); throw new Error("Renderer stream smoke did not reach a terminal state.");
} }
...@@ -677,15 +750,18 @@ async function bootstrap(): Promise<void> { ...@@ -677,15 +750,18 @@ async function bootstrap(): Promise<void> {
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN?.trim(); const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN?.trim();
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key"; const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
const smokeCloudBootstrapEnabled = Boolean(smokeCloudBaseUrl && smokeAuthToken); 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) { if (!smokeOutputPath) {
return; return;
} }
const line = "[" + new Date().toISOString() + "] bootstrap:" + message + "\n"; const line = "[" + new Date().toISOString() + "] bootstrap:" + message + "\n";
await appendFile(smokeOutputPath + ".trace.log", line, "utf8").catch(() => undefined); await appendFile(smokeOutputPath + ".trace.log", line, "utf8").catch(() => undefined);
}; };
await traceBootstrap("when-ready");
const systemSummary = buildSystemSummary(); 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 configService = new AppConfigService(systemSummary.userDataPath);
const config = await configService.load(); const config = await configService.load();
...@@ -719,22 +795,23 @@ async function bootstrap(): Promise<void> { ...@@ -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); const deviceIdentityService = new DeviceIdentityService(systemSummary.userDataPath);
await deviceIdentityService.load(); await deviceIdentityService.load();
await traceBootstrap("device-identity-loaded"); await traceBootstrap("device-identity-loaded");
await traceBootstrap("local-openclaw-config-start"); await traceBootstrap("local-openclaw-config-start");
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig(); const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
await traceBootstrap("local-openclaw-config-done"); 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 traceBootstrap("runtime-cloud-hydrate-start");
await runtimeCloudClient.hydrateCache(); await runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done"); await traceBootstrap("runtime-cloud-hydrate-done");
const skillStore = new SkillStoreService(systemSummary.userDataPath); 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 projectStore.initialize();
await traceBootstrap("project-store-initialized"); await traceBootstrap("project-store-initialized");
const projectBundleService = new ProjectBundleService(configService, projectStore); const projectBundleService = new ProjectBundleService(configService, projectStore, startupLogger);
const syncProjectBundles = async ( const syncProjectBundles = async (
skills: Parameters<ProjectBundleService["syncRemoteBundles"]>[0], skills: Parameters<ProjectBundleService["syncRemoteBundles"]>[0],
configVersion: string | undefined, configVersion: string | undefined,
...@@ -846,6 +923,11 @@ async function bootstrap(): Promise<void> { ...@@ -846,6 +923,11 @@ async function bootstrap(): Promise<void> {
const profileClient = new ProfileClient(configService, secretManager); const profileClient = new ProfileClient(configService, secretManager);
const creditClient = new CreditClient(configService, secretManager); const creditClient = new CreditClient(configService, secretManager);
const skillClient = new SkillClient(skillStore); const skillClient = new SkillClient(skillStore);
const skillCatalogService = new SkillCatalogService({
systemSummary,
projectStore,
qSkillsRoot: genericSkillsRoot
});
const modelConfigClient = new ModelConfigClient(configService, secretManager); const modelConfigClient = new ModelConfigClient(configService, secretManager);
const dailyReportService = new DailyReportService({ const dailyReportService = new DailyReportService({
userDataPath: systemSummary.userDataPath, userDataPath: systemSummary.userDataPath,
...@@ -876,6 +958,7 @@ async function bootstrap(): Promise<void> { ...@@ -876,6 +958,7 @@ async function bootstrap(): Promise<void> {
profileClient, profileClient,
creditClient, creditClient,
skillClient, skillClient,
skillCatalogService,
skillStore, skillStore,
modelConfigClient, modelConfigClient,
runtimeCloudClient, runtimeCloudClient,
...@@ -889,6 +972,7 @@ async function bootstrap(): Promise<void> { ...@@ -889,6 +972,7 @@ async function bootstrap(): Promise<void> {
projectSkillRouter, projectSkillRouter,
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
startupLogger: startupLogger!,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
}); });
...@@ -963,3 +1047,13 @@ void bootstrap().catch(async (error) => { ...@@ -963,3 +1047,13 @@ void bootstrap().catch(async (error) => {
} }
app.quit(); app.quit();
}); });
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ipcMain, shell, type WebContents } from "electron"; import { ipcMain, shell, type WebContents } from "electron";
import { import {
IPC_CHANNELS, IPC_CHANNELS,
...@@ -24,6 +24,7 @@ import type { AppConfigService } from "./services/app-config.js"; ...@@ -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 { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import type { DiagnosticsService } from "./services/diagnostics.js"; import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.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 { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js"; import type { SkillStoreService } from "./services/skill-store.js";
import { import {
...@@ -33,6 +34,7 @@ import { ...@@ -33,6 +34,7 @@ import {
type LocalOpenClawGatewayConfig type LocalOpenClawGatewayConfig
} from "./services/openclaw-local-config.js"; } from "./services/openclaw-local-config.js";
import type { SecretManager } from "./services/secrets.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 { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js"; import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import type { ProjectStoreService } from "./services/project-store.js"; import type { ProjectStoreService } from "./services/project-store.js";
...@@ -72,6 +74,7 @@ interface MainServices { ...@@ -72,6 +74,7 @@ interface MainServices {
profileClient: ProfileClient; profileClient: ProfileClient;
creditClient: CreditClient; creditClient: CreditClient;
skillClient: SkillClient; skillClient: SkillClient;
skillCatalogService: SkillCatalogService;
skillStore: SkillStoreService; skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient; modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient; runtimeCloudClient: OpenClawConfigClient;
...@@ -85,6 +88,7 @@ interface MainServices { ...@@ -85,6 +88,7 @@ interface MainServices {
projectSkillRouter: ProjectSkillRouterService; projectSkillRouter: ProjectSkillRouterService;
projectExecutionRouter: ProjectExecutionRouter; projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService; projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
startupLogger: StartupLogger;
appVersion: string; appVersion: string;
systemSummary: SystemSummary; systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null; localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
...@@ -189,6 +193,16 @@ function buildPluginSummaries(runtimeStatus: RuntimeStatus): PluginSummary[] { ...@@ -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_LIMIT = 2;
const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500; const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500;
const GATEWAY_CONNECT_RETRY_LIMIT = 10; const GATEWAY_CONNECT_RETRY_LIMIT = 10;
...@@ -212,6 +226,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -212,6 +226,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
profileClient, profileClient,
secretManager, secretManager,
skillClient, skillClient,
skillCatalogService,
modelConfigClient, modelConfigClient,
runtimeCloudClient, runtimeCloudClient,
runtimeCloudSupervisor, runtimeCloudSupervisor,
...@@ -224,6 +239,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -224,6 +239,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectSkillRouter, projectSkillRouter,
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
startupLogger,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
} = services; } = services;
...@@ -422,6 +438,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -422,6 +438,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let workspaceWarmupTail: Promise<void> = Promise.resolve(); let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false; let workspaceWarmupInFlight = false;
let bootstrapRecoveryAttempts = 0; let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = "";
const queueWorkspaceWarmup = async ( const queueWorkspaceWarmup = async (
reason: string, reason: string,
...@@ -532,29 +549,35 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -532,29 +549,35 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus, runtimeStatus,
gatewayStatus 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 const chatSummary = projects.length > 0
? baseChatSummary ? baseChatSummary
: bundleSyncFailed : bundleSyncFailed
? { ? {
chatReady: false, chatReady: false,
chatLaunchState: "error" as const, chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。", chatStatusMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry.",
startupPhase: "error" as const, startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。" startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
} }
: { : shouldWaitForProjectSync
chatReady: false, ? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState, : baseChatSummary;
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
};
return { const workspaceSummary: WorkspaceSummary = {
shellReady, shellReady,
apiKeyConfigured: config.apiKeyConfigured, apiKeyConfigured: config.apiKeyConfigured,
bindingRequired: !config.apiKeyConfigured, bindingRequired: !config.apiKeyConfigured,
...@@ -585,12 +608,36 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -585,12 +608,36 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
skillCount: skills.length, skillCount: skills.length,
skills, skills,
plugins: buildPluginSummaries(runtimeStatus), 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 exportDiagnostics = async () => {
const config = await getEffectiveConfig(); const config = await getEffectiveConfig();
const workspaceSummary = await buildWorkspaceSummary();
const [gatewayStatus, gatewayHealth, logs, authSession, runtimeStatus, runtimeLogs, runtimeCloudStatus, runtimeTelemetryStatus] = await Promise.all([ const [gatewayStatus, gatewayHealth, logs, authSession, runtimeStatus, runtimeLogs, runtimeCloudStatus, runtimeTelemetryStatus] = await Promise.all([
gatewayClient.status(), gatewayClient.status(),
gatewayClient.health(), gatewayClient.health(),
...@@ -639,7 +686,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -639,7 +686,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus, runtimeStatus,
runtimeLogs, runtimeLogs,
runtimeCloudStatus, runtimeCloudStatus,
runtimeTelemetryStatus runtimeTelemetryStatus,
workspaceSummary,
bundleSyncStatus: projectBundleService.getSyncStatus(),
startupLogPath: startupLogger.getSessionLogPath()
}); });
}; };
...@@ -1182,6 +1232,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1182,6 +1232,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list()); ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary()); ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary); 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.projectsList, async () => projectStore.listProjects());
ipcMain.handle(IPC_CHANNELS.projectsSetActive, async (_event, projectId: string) => { ipcMain.handle(IPC_CHANNELS.projectsSetActive, async (_event, projectId: string) => {
...@@ -1298,6 +1349,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1298,6 +1349,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return buildWorkspaceSummary(); return buildWorkspaceSummary();
} }
}, },
skillCatalog: {
list: () => skillCatalogService.listForActiveProject()
},
auth: { auth: {
getSessionSummary: () => authClient.getSessionSummary(), getSessionSummary: () => authClient.getSessionSummary(),
signIn: (input: SignInInput) => authClient.signIn(input.accessToken), signIn: (input: SignInInput) => authClient.signIn(input.accessToken),
...@@ -1366,3 +1420,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1366,3 +1420,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} }
}; };
} }
...@@ -23,6 +23,7 @@ import type { ...@@ -23,6 +23,7 @@ import type {
import type { AppConfigService } from "./app-config.js"; import type { AppConfigService } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js"; import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js"; import type { SecretManager } from "./secrets.js";
import type { StartupLogger } from "./startup-logger.js";
interface SessionPayload { interface SessionPayload {
user?: { displayName?: string; email?: string }; user?: { displayName?: string; email?: string };
...@@ -510,6 +511,7 @@ export class OpenClawConfigClient { ...@@ -510,6 +511,7 @@ export class OpenClawConfigClient {
private readonly httpClient = new HttpJsonClient(); private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>(); private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private readonly cachePath: string; private readonly cachePath: string;
private readonly startupLogger?: StartupLogger;
private payloadCache: OpenClawEmployeeConfigPayload | null = null; private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = { private statusCache: RuntimeCloudStatus = {
state: "unconfigured", state: "unconfigured",
...@@ -518,10 +520,11 @@ export class OpenClawConfigClient { ...@@ -518,10 +520,11 @@ export class OpenClawConfigClient {
}; };
private cacheLoaded = false; private cacheLoaded = false;
constructor(configService: AppConfigService, secretManager: SecretManager) { constructor(configService: AppConfigService, secretManager: SecretManager, startupLogger?: StartupLogger) {
this.configService = configService; this.configService = configService;
this.secretManager = secretManager; this.secretManager = secretManager;
this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json"); this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
this.startupLogger = startupLogger;
} }
async hydrateCache(): Promise<void> { async hydrateCache(): Promise<void> {
...@@ -627,6 +630,7 @@ export class OpenClawConfigClient { ...@@ -627,6 +630,7 @@ export class OpenClawConfigClient {
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> { private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache(); await this.hydrateCache();
const config = await this.configService.load(); const config = await this.configService.load();
const startedAt = Date.now();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, ""); const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const apiKey = (await this.secretManager.getApiKey())?.trim(); const apiKey = (await this.secretManager.getApiKey())?.trim();
...@@ -711,6 +715,12 @@ export class OpenClawConfigClient { ...@@ -711,6 +715,12 @@ export class OpenClawConfigClient {
}; };
throw new Error(message); 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); return this.fail(baseUrl, true, message);
} }
} }
...@@ -997,3 +1007,9 @@ export class ModelConfigClient { ...@@ -997,3 +1007,9 @@ export class ModelConfigClient {
...@@ -15,9 +15,12 @@ import type { ...@@ -15,9 +15,12 @@ import type {
RuntimeTelemetryStatus, RuntimeTelemetryStatus,
SkillSummary, SkillSummary,
SystemSummary, SystemSummary,
UserProfileSummary UserProfileSummary,
WorkspaceSummary
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js"; import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js";
import type { ProjectBundleSyncStatus } from "./project-bundle.js";
import type { StartupLogger } from "./startup-logger.js";
interface DiagnosticsSnapshotInput { interface DiagnosticsSnapshotInput {
config: AppConfig; config: AppConfig;
...@@ -38,6 +41,10 @@ interface DiagnosticsSnapshotInput { ...@@ -38,6 +41,10 @@ interface DiagnosticsSnapshotInput {
runtimeLogs?: LogEntry[]; runtimeLogs?: LogEntry[];
runtimeCloudStatus?: RuntimeCloudStatus; runtimeCloudStatus?: RuntimeCloudStatus;
runtimeTelemetryStatus?: RuntimeTelemetryStatus; runtimeTelemetryStatus?: RuntimeTelemetryStatus;
workspaceSummary?: WorkspaceSummary;
bundleSyncStatus?: ProjectBundleSyncStatus;
startupLogPath?: string;
reason?: string;
} }
function toSafeStamp(value: string): string { function toSafeStamp(value: string): string {
...@@ -46,9 +53,11 @@ function toSafeStamp(value: string): string { ...@@ -46,9 +53,11 @@ function toSafeStamp(value: string): string {
export class DiagnosticsService { export class DiagnosticsService {
private readonly userDataPath: string; private readonly userDataPath: string;
private readonly startupLogger?: StartupLogger;
constructor(userDataPath: string) { constructor(userDataPath: string, startupLogger?: StartupLogger) {
this.userDataPath = userDataPath; this.userDataPath = userDataPath;
this.startupLogger = startupLogger;
} }
async exportSnapshot(input: DiagnosticsSnapshotInput): Promise<DiagnosticsExportResult> { async exportSnapshot(input: DiagnosticsSnapshotInput): Promise<DiagnosticsExportResult> {
...@@ -58,6 +67,8 @@ export class DiagnosticsService { ...@@ -58,6 +67,8 @@ export class DiagnosticsService {
const payload = { const payload = {
createdAt, createdAt,
reason: input.reason,
startupLogPath: input.startupLogPath,
app: { app: {
name: input.systemSummary.appName, name: input.systemSummary.appName,
version: input.appVersion, version: input.appVersion,
...@@ -109,6 +120,10 @@ export class DiagnosticsService { ...@@ -109,6 +120,10 @@ export class DiagnosticsService {
config: input.runtimeCloudStatus.config config: input.runtimeCloudStatus.config
} }
: null, : null,
workspace: input.workspaceSummary ?? null,
bundleSync: input.bundleSyncStatus
? { ...input.bundleSyncStatus }
: null,
runtimeTelemetry: input.runtimeTelemetryStatus ?? null, runtimeTelemetry: input.runtimeTelemetryStatus ?? null,
runtime: { runtime: {
status: input.runtimeStatus, status: input.runtimeStatus,
...@@ -133,10 +148,18 @@ export class DiagnosticsService { ...@@ -133,10 +148,18 @@ export class DiagnosticsService {
await mkdir(diagnosticsDir, { recursive: true }); await mkdir(diagnosticsDir, { recursive: true });
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); 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 { return {
filePath, filePath,
createdAt createdAt,
startupLogPath: input.startupLogPath
}; };
} }
} }
...@@ -8,6 +8,7 @@ import extractZip from "extract-zip"; ...@@ -8,6 +8,7 @@ import extractZip from "extract-zip";
import type { AppConfigService } from "./app-config.js"; import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js"; import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js"; import type { RemoteSkillAsset } from "./skill-store.js";
import type { StartupLogger } from "./startup-logger.js";
interface BundleManifestRecord { interface BundleManifestRecord {
sourceUrl: string; sourceUrl: string;
...@@ -180,11 +181,13 @@ function logBundle(event: string, details: Record<string, unknown>): void { ...@@ -180,11 +181,13 @@ function logBundle(event: string, details: Record<string, unknown>): void {
export class ProjectBundleService { export class ProjectBundleService {
private readonly configService: AppConfigService; private readonly configService: AppConfigService;
private readonly projectStore: ProjectStoreService; private readonly projectStore: ProjectStoreService;
private readonly startupLogger?: StartupLogger;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" }; private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
constructor(configService: AppConfigService, projectStore: ProjectStoreService) { constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) {
this.configService = configService; this.configService = configService;
this.projectStore = projectStore; this.projectStore = projectStore;
this.startupLogger = startupLogger;
} }
getSyncStatus(): ProjectBundleSyncStatus { getSyncStatus(): ProjectBundleSyncStatus {
...@@ -214,6 +217,13 @@ export class ProjectBundleService { ...@@ -214,6 +217,13 @@ export class ProjectBundleService {
bundleAssetCount: bundleAssets.length bundleAssetCount: bundleAssets.length
}); });
const workspaceRoot = await this.projectStore.getWorkspaceRoot(); 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 manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {}; const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {}; const nextManifest: Record<string, BundleManifestRecord> = {};
...@@ -934,3 +944,7 @@ export class ProjectBundleService { ...@@ -934,3 +944,7 @@ export class ProjectBundleService {
...@@ -13,6 +13,10 @@ import type { ...@@ -13,6 +13,10 @@ import type {
WorkspaceSkillSummary WorkspaceSkillSummary
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js"; import type { AppConfigService } from "./app-config.js";
import {
CURATED_GENERIC_SKILL_IDS,
getCuratedGenericSkillDefinition
} from "./curated-skills.js";
import type { SkillExecutionTarget } from "./skill-store.js"; import type { SkillExecutionTarget } from "./skill-store.js";
interface StoredProjectRecord { interface StoredProjectRecord {
...@@ -60,6 +64,15 @@ interface ProjectSeed { ...@@ -60,6 +64,15 @@ interface ProjectSeed {
[key: string]: unknown; [key: string]: unknown;
} }
interface ProjectStoreServiceOptions {
qSkillsRoot?: string;
}
interface ResolvedSkillFile {
skillDir: string;
fileName: string;
}
const PROJECTS_DIR = "projects"; const PROJECTS_DIR = "projects";
const SKILLS_DIR = "skills"; const SKILLS_DIR = "skills";
const CRON_DIR = "cron"; const CRON_DIR = "cron";
...@@ -209,7 +222,7 @@ function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[] ...@@ -209,7 +222,7 @@ function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[]
} }
function compareSkills(left: WorkspaceSkillSummary, right: WorkspaceSkillSummary): number { 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> { async function pathExists(targetPath: string): Promise<boolean> {
...@@ -251,6 +264,28 @@ function normalizeStringArray(value: unknown): string[] { ...@@ -251,6 +264,28 @@ function normalizeStringArray(value: unknown): string[] {
return []; 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 { function normalizeEntryType(value: unknown): ProjectPackageEntryType | undefined {
if (value === "workspace-entry" || value === "workspace_entry" || value === "workspace") { if (value === "workspace-entry" || value === "workspace_entry" || value === "workspace") {
return "workspace-entry"; return "workspace-entry";
...@@ -382,9 +417,11 @@ function normalizeProjectPackageConfig(record: StoredProjectRecord | null): Proj ...@@ -382,9 +417,11 @@ function normalizeProjectPackageConfig(record: StoredProjectRecord | null): Proj
export class ProjectStoreService { export class ProjectStoreService {
private readonly configService: AppConfigService; private readonly configService: AppConfigService;
private readonly qSkillsRoot?: string;
constructor(configService: AppConfigService) { constructor(configService: AppConfigService, options?: ProjectStoreServiceOptions) {
this.configService = configService; this.configService = configService;
this.qSkillsRoot = options?.qSkillsRoot?.trim() || undefined;
} }
async initialize(): Promise<void> { async initialize(): Promise<void> {
...@@ -667,37 +704,19 @@ export class ProjectStoreService { ...@@ -667,37 +704,19 @@ export class ProjectStoreService {
const project = await this.getProjectById(projectId); const project = await this.getProjectById(projectId);
const projectRecord = await this.readProjectRecord(project.id); const projectRecord = await this.readProjectRecord(project.id);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []); const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
const workspaceRoot = await this.getWorkspaceRoot(); const merged = new Map<string, WorkspaceSkillSummary>();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
const skills: WorkspaceSkillSummary[] = [];
for (const entry of dirEntries) { for (const skill of await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)) {
if (!entry.isDirectory()) { merged.set(skill.id, skill);
continue; }
}
const skillDir = path.join(skillsRoot, entry.name); for (const skill of await this.listCuratedGenericSkills(project.updatedAt)) {
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []); if (!merged.has(skill.id)) {
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name)); merged.set(skill.id, skill);
const skillId = sanitizeSkillId(entry.name);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
continue;
} }
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> { async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
...@@ -706,9 +725,14 @@ export class ProjectStoreService { ...@@ -706,9 +725,14 @@ export class ProjectStoreService {
} }
async getProjectSkillTarget(projectId: string, skillId: string): Promise<SkillExecutionTarget | undefined> { async getProjectSkillTarget(projectId: string, skillId: string): Promise<SkillExecutionTarget | undefined> {
const normalizedSkillId = sanitizeSkillId(skillId);
const projectRecord = await this.readProjectRecord(projectId); const projectRecord = await this.readProjectRecord(projectId);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []); 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; return undefined;
} }
...@@ -719,20 +743,19 @@ export class ProjectStoreService { ...@@ -719,20 +743,19 @@ export class ProjectStoreService {
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
continue; continue;
} }
if (sanitizeSkillId(entry.name) !== skillId) { if (sanitizeSkillId(entry.name) !== normalizedSkillId) {
continue; continue;
} }
const skillDir = path.join(skillsRoot, entry.name); const skillDir = path.join(skillsRoot, entry.name);
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []); const resolvedFile = await this.resolveSkillFile(skillDir);
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name)); if (!resolvedFile) {
if (!skillFile) {
return undefined; return undefined;
} }
return { return {
skillId, skillId: normalizedSkillId,
name: entry.name, name: entry.name,
fileName: skillFile.name, fileName: resolvedFile.fileName,
localPath: path.join(skillDir, skillFile.name) localPath: path.join(skillDir, resolvedFile.fileName)
}; };
} }
return undefined; return undefined;
...@@ -756,7 +779,7 @@ export class ProjectStoreService { ...@@ -756,7 +779,7 @@ export class ProjectStoreService {
ready: input.ready ?? true ready: input.ready ?? true
}); });
const activeProject = await this.getActiveProject().catch(() => null); const activeProject = await this.getActiveProject().catch(() => null);
if (!activeProject) { if (!activeProject || activeProject.isBuiltinHome) {
await this.setActiveProject(project.id); await this.setActiveProject(project.id);
} }
return project; return project;
...@@ -987,6 +1010,112 @@ export class ProjectStoreService { ...@@ -987,6 +1010,112 @@ export class ProjectStoreService {
return this.resolveWorkspaceChildPath(path.join(await this.getWorkspaceRoot(), PROJECTS_DIR), projectId); 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 { private resolveWorkspaceChildPath(rootPath: string, childName: string): string {
const trimmed = childName.trim(); const trimmed = childName.trim();
if (!trimmed || path.basename(trimmed) !== trimmed) { 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 = { ...@@ -44,6 +44,9 @@ const desktopApi: DesktopApi = {
list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList), list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList),
setActive: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.projectsSetActive, projectId) setActive: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.projectsSetActive, projectId)
}, },
skillCatalog: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.skillCatalogList)
},
auth: { auth: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession), getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input), signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
......
...@@ -307,6 +307,7 @@ const startupCurtainCopy = { ...@@ -307,6 +307,7 @@ const startupCurtainCopy = {
brandTagline: "START YOUR IDEAS", brandTagline: "START YOUR IDEAS",
loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883", loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
syncingConfig: "\u6b63\u5728\u540c\u6b65\u5de5\u4f5c\u914d\u7f6e", 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", startingRuntime: "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u5efa\u7acb\u5bf9\u8bdd\u8fde\u63a5", connectingGateway: "\u6b63\u5728\u5efa\u7acb\u5bf9\u8bdd\u8fde\u63a5",
ready: "\u51c6\u5907\u5b8c\u6210\uff0c\u6b63\u5728\u8fdb\u5165\u5bf9\u8bdd", ready: "\u51c6\u5907\u5b8c\u6210\uff0c\u6b63\u5728\u8fdb\u5165\u5bf9\u8bdd",
...@@ -686,6 +687,8 @@ function getStartupProgress(phase: WorkspaceSummary["startupPhase"] | undefined) ...@@ -686,6 +687,8 @@ function getStartupProgress(phase: WorkspaceSummary["startupPhase"] | undefined)
switch (phase) { switch (phase) {
case "syncing-config": case "syncing-config":
return 0.24; return 0.24;
case "syncing-projects":
return 0.4;
case "starting-runtime": case "starting-runtime":
return 0.56; return 0.56;
case "connecting-gateway": case "connecting-gateway":
...@@ -711,6 +714,8 @@ function getStartupCurtainStatus( ...@@ -711,6 +714,8 @@ function getStartupCurtainStatus(
switch (phase) { switch (phase) {
case "syncing-config": case "syncing-config":
return startupCurtainCopy.syncingConfig; return startupCurtainCopy.syncingConfig;
case "syncing-projects":
return startupCurtainCopy.syncingProjects;
case "starting-runtime": case "starting-runtime":
return startupCurtainCopy.startingRuntime; return startupCurtainCopy.startingRuntime;
case "connecting-gateway": case "connecting-gateway":
...@@ -960,7 +965,7 @@ export default function App() { ...@@ -960,7 +965,7 @@ export default function App() {
return; return;
} }
const nextShouldPoll = Boolean(nextWorkspace) && ( const nextShouldPoll = nextWorkspace != null && (
nextWorkspace.chatLaunchState === "starting" nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired) || (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
); );
...@@ -1905,7 +1910,7 @@ export default function App() { ...@@ -1905,7 +1910,7 @@ export default function App() {
try { try {
const result = await desktopApi.diagnostics.exportSnapshot(); const result = await desktopApi.diagnostics.exportSnapshot();
setInfoText(ui.exported + result.filePath); setInfoText(ui.exported + result.filePath + (result.startupLogPath ? " | startup: " + result.startupLogPath : ""));
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
} }
...@@ -2260,6 +2265,7 @@ export default function App() { ...@@ -2260,6 +2265,7 @@ export default function App() {
<div className="button-row startup-overlay-actions"> <div className="button-row startup-overlay-actions">
<button type="button" disabled={refreshing} onClick={() => void retryStartup()}>{refreshing ? ui.preparing : ui.startupRetry}</button> <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={() => setViewMode("settings")}>{ui.openSettings}</button>
<button type="button" className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div> </div>
) : null} ) : null}
</div> </div>
......
...@@ -935,6 +935,24 @@ strong { font-weight: 600; } ...@@ -935,6 +935,24 @@ strong { font-weight: 600; }
.composer-field { .composer-field {
display: grid; 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 { .composer-field textarea {
...@@ -956,6 +974,24 @@ strong { font-weight: 600; } ...@@ -956,6 +974,24 @@ strong { font-weight: 600; }
min-width: 0; 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-trigger,
.skill-chip { .skill-chip {
min-width: 0; min-width: 0;
...@@ -970,6 +1006,14 @@ strong { font-weight: 600; } ...@@ -970,6 +1006,14 @@ strong { font-weight: 600; }
font-weight: 700; font-weight: 700;
} }
.skill-trigger.text-trigger {
width: auto;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.skill-chip { .skill-chip {
background: rgba(15, 123, 255, 0.08); background: rgba(15, 123, 255, 0.08);
border-color: rgba(15, 123, 255, 0.16); border-color: rgba(15, 123, 255, 0.16);
...@@ -1716,6 +1760,10 @@ strong { font-weight: 600; } ...@@ -1716,6 +1760,10 @@ strong { font-weight: 600; }
border-radius: 14px; border-radius: 14px;
} }
.composer-skill-badge {
max-width: 220px;
}
.composer-field textarea { .composer-field textarea {
min-height: 68px; min-height: 68px;
max-height: 144px; max-height: 144px;
...@@ -1732,6 +1780,13 @@ strong { font-weight: 600; } ...@@ -1732,6 +1780,13 @@ strong { font-weight: 600; }
height: 34px; height: 34px;
} }
.skill-trigger.text-trigger {
width: auto;
height: 34px;
padding: 0 12px;
font-size: 12px;
}
.skill-chip { .skill-chip {
max-width: 220px; max-width: 220px;
overflow: hidden; overflow: hidden;
...@@ -2253,6 +2308,11 @@ button.secondary { ...@@ -2253,6 +2308,11 @@ button.secondary {
box-shadow: inset 0 0 0 1px rgba(216, 225, 237, 0.96); box-shadow: inset 0 0 0 1px rgba(216, 225, 237, 0.96);
} }
.skill-trigger.text-trigger {
background: #f3f7fc;
color: #44617f;
}
.skill-chip { .skill-chip {
background: rgba(240, 246, 255, 0.94); background: rgba(240, 246, 255, 0.94);
color: #45678f; color: #45678f;
...@@ -2513,3 +2573,478 @@ button.secondary { ...@@ -2513,3 +2573,478 @@ button.secondary {
width: 100%; 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') { ...@@ -277,33 +277,47 @@ if (startupOnly === 'true') {
} }
const sendResult = result.sendResult || {}; const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {}; const streamSmoke = sendResult.streamSmoke || {};
const executionPolicySource = String(streamSmoke.executionPolicySource || ''); const smokeViewMode = String(sendResult.smokeViewMode || 'chat');
if (streamSmoke.phase !== 'completed') { const finalState = result.finalState || {};
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase); if (smokeViewMode === 'skills') {
} if (String(finalState.viewMode || '') !== 'skills') {
if (streamSmoke.fallbackUsed) { throw new Error('Skills smoke did not end on skills view: ' + String(finalState.viewMode || ''));
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.'); }
} if (typeof sendResult.skillsPageCatalogCount !== 'number') {
if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) { throw new Error('Skills smoke did not report skillsPageCatalogCount.');
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource); }
} if (typeof sendResult.workspaceSkillCount !== 'number') {
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) { throw new Error('Skills smoke did not report workspaceSkillCount.');
throw new Error('Renderer stream selectedSkillId does not match smoke selection.'); }
} } else {
if (Number(streamSmoke.startedEventCount || 0) < 1) { const executionPolicySource = String(streamSmoke.executionPolicySource || '');
throw new Error('Renderer stream smoke did not observe a started event.'); if (streamSmoke.phase !== 'completed') {
} throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
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 (streamSmoke.fallbackUsed) {
} throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
if (Number(streamSmoke.completedEventCount || 0) < 1) { }
throw new Error('Renderer stream smoke did not observe a completed event.'); if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) {
} throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
if (Number(streamSmoke.errorEventCount || 0) !== 0) { }
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount); if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
} throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) { }
throw new Error('Renderer stream smoke did not render assistant content.'); 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) { if (String(sendResult.system && sendResult.system.userDataPath) !== expectedUserData) {
throw new Error('Smoke ran against an unexpected userData path: ' + (sendResult.system && sendResult.system.userDataPath)); 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) { ...@@ -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)); throw new Error('Smoke ran against an unexpected logs path: ' + (sendResult.system && sendResult.system.logsPath));
} }
const diagnosticsPath = String(sendResult.diagnostics && sendResult.diagnostics.filePath || ''); const diagnosticsPath = String(sendResult.diagnostics && sendResult.diagnostics.filePath || '');
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) { const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath)
throw new Error('Diagnostics snapshot was not produced by smoke.'); ? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'))
} : null;
const diagnostics = JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'));
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {}; const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') { if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.'); throw new Error('Runtime cloud config fetch did not succeed.');
} }
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) { if (smokeViewMode !== 'skills') {
throw new Error('Runtime telemetry did not record a successful heartbeat.'); if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
} throw new Error('Diagnostics snapshot was not produced by smoke.');
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) { }
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount); if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
} throw new Error('Runtime telemetry did not record a successful heartbeat.');
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) { }
throw new Error('Runtime telemetry did not record a successful config sync.'); if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
} throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
if (!diagnostics.runtimeTelemetry) { }
throw new Error('Diagnostics snapshot did not include runtimeTelemetry.'); 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') { if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {}; const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
...@@ -367,7 +385,7 @@ if (expectBundled === 'true') { ...@@ -367,7 +385,7 @@ if (expectBundled === 'true') {
} }
} }
let workspaceEntryValidated = false; let workspaceEntryValidated = false;
if (expectWorkspaceEntry === 'true') { if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const latestStatusLabel = String(streamSmoke.latestStatusLabel || ''); const latestStatusLabel = String(streamSmoke.latestStatusLabel || '');
const statusLabels = Array.isArray(streamSmoke.statusLabels) const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || '')) ? 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", workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup", workspaceWarmup: "workspace:warmup",
gatewayStatus: "gateway:status", gatewayStatus: "gateway:status",
...@@ -20,6 +20,7 @@ export const IPC_CHANNELS = { ...@@ -20,6 +20,7 @@ export const IPC_CHANNELS = {
configSave: "config:save", configSave: "config:save",
projectsList: "projects:list", projectsList: "projects:list",
projectsSetActive: "projects:set-active", projectsSetActive: "projects:set-active",
skillCatalogList: "skill-catalog:list",
chatListSessions: "chat:list-sessions", chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project", chatListSessionsByProject: "chat:list-sessions-by-project",
chatCreateSession: "chat:create-session", chatCreateSession: "chat:create-session",
...@@ -62,7 +63,7 @@ export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "m ...@@ -62,7 +63,7 @@ export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "m
export type PluginStatus = "included" | "extension" | "unavailable"; export type PluginStatus = "included" | "extension" | "unavailable";
export type SetupMode = "employee-key" | "direct-provider"; export type SetupMode = "employee-key" | "direct-provider";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error"; 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 SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type DailyReportDeliveryState = "draft" | "sent" | "failed"; export type DailyReportDeliveryState = "draft" | "sent" | "failed";
...@@ -506,6 +507,7 @@ export interface AppConfig { ...@@ -506,6 +507,7 @@ export interface AppConfig {
export interface DiagnosticsExportResult { export interface DiagnosticsExportResult {
filePath: string; filePath: string;
createdAt: string; createdAt: string;
startupLogPath?: string;
} }
export interface SaveConfigInput { export interface SaveConfigInput {
...@@ -571,6 +573,25 @@ export interface SkillSummary { ...@@ -571,6 +573,25 @@ export interface SkillSummary {
requiresCredits?: number; 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 { export interface ModelCatalogItemSummary {
id: string; id: string;
label: string; label: string;
...@@ -656,6 +677,9 @@ export interface DesktopApi { ...@@ -656,6 +677,9 @@ export interface DesktopApi {
list(): Promise<ProjectSummary[]>; list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>; setActive(projectId: string): Promise<WorkspaceSummary>;
}; };
skillCatalog: {
list(): Promise<SkillCatalogItem[]>;
};
auth: { auth: {
getSessionSummary(): Promise<AuthSessionSummary>; getSessionSummary(): Promise<AuthSessionSummary>;
signIn(input: SignInInput): Promise<AuthSessionSummary>; signIn(input: SignInInput): Promise<AuthSessionSummary>;
...@@ -692,3 +716,4 @@ export interface DesktopApi { ...@@ -692,3 +716,4 @@ export interface DesktopApi {
exportSnapshot(): Promise<DiagnosticsExportResult>; 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