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

fix(desktop): stabilize expert startup and routing flow

parent 71353fc5
import path from "node:path"; import path from "node:path";
import { appendFile, readFile, writeFile } from "node:fs/promises"; import { access, 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";
import { RuntimeManager } from "@qjclaw/runtime-manager"; import { RuntimeManager } from "@qjclaw/runtime-manager";
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
resolveEffectiveGatewayUrl, resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway shouldUseLocalOpenClawGateway
} from "./services/openclaw-local-config.js"; } from "./services/openclaw-local-config.js";
import { PackagedBootstrapService } from "./services/packaged-bootstrap.js";
import { SecretManager } from "./services/secrets.js"; 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";
...@@ -113,6 +114,8 @@ interface RendererSmokeState { ...@@ -113,6 +114,8 @@ interface RendererSmokeState {
finalContent?: string; finalContent?: string;
executionPolicySource?: string; executionPolicySource?: string;
executionPolicyModel?: string; executionPolicyModel?: string;
latestStatusLabel?: string;
statusLabels?: string[];
lastError?: string; lastError?: string;
} | null; } | null;
} }
...@@ -120,6 +123,7 @@ interface RendererSmokeState { ...@@ -120,6 +123,7 @@ interface RendererSmokeState {
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim(); const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim(); const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim();
const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000; const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000;
let smokeTestInFlight = false;
if (forcedUserDataPath) { if (forcedUserDataPath) {
app.setPath("userData", forcedUserDataPath); app.setPath("userData", forcedUserDataPath);
...@@ -243,11 +247,19 @@ function resolveVendorRuntimeDir(systemSummary: SystemSummary): string { ...@@ -243,11 +247,19 @@ function resolveVendorRuntimeDir(systemSummary: SystemSummary): string {
return path.resolve(systemSummary.appPath, "..", "..", "vendor", "openclaw-runtime"); return path.resolve(systemSummary.appPath, "..", "..", "vendor", "openclaw-runtime");
} }
function resolvePackagedBootstrapRoot(systemSummary: SystemSummary): string {
if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, "bootstrap");
}
return path.resolve(systemSummary.appPath, "bootstrap");
}
async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState | null> { async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState | null> {
const started = Date.now(); const started = Date.now();
while (Date.now() - started < timeoutMs) { while (Date.now() - started < timeoutMs) {
if (window.isDestroyed()) { if (window.isDestroyed() || window.webContents.isDestroyed()) {
throw new Error("Smoke test window was destroyed before renderer state became available."); throw new Error("Smoke test window was destroyed before renderer state became available.");
} }
...@@ -268,8 +280,74 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000 ...@@ -268,8 +280,74 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
return null; return null;
} }
async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState> {
return await new Promise<RendererSmokeState>((resolve, reject) => {
let settled = false;
const cleanup = () => {
window.removeListener("closed", onClosed);
if (!window.webContents.isDestroyed()) {
window.webContents.removeListener("did-fail-load", onFailLoad);
window.webContents.removeListener("render-process-gone", onRenderProcessGone);
window.webContents.removeListener("destroyed", onWebContentsDestroyed);
}
};
const finish = (state: RendererSmokeState) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(state);
};
const fail = (message: string) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(new Error(message));
};
const onFailLoad = (_event: Electron.Event, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => {
if (!isMainFrame) {
return;
}
fail("Renderer main frame failed to load: " + errorDescription + " (" + errorCode + ") " + validatedURL);
};
const onRenderProcessGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => {
fail("Renderer process exited before smoke state became available: " + details.reason);
};
const onWebContentsDestroyed = () => {
fail("Renderer webContents was destroyed before smoke state became available.");
};
const onClosed = () => {
fail("Smoke test window was closed before renderer state became available.");
};
window.once("closed", onClosed);
if (!window.webContents.isDestroyed()) {
window.webContents.on("did-fail-load", onFailLoad);
window.webContents.on("render-process-gone", onRenderProcessGone);
window.webContents.on("destroyed", onWebContentsDestroyed);
}
void (async () => {
try {
const state = await waitForRendererSmokeState(window, timeoutMs);
if (!state) {
fail("Renderer smoke state was not published.");
return;
}
finish(state);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
})();
});
}
async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 40000): Promise<RendererSmokeState | null> { async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 40000): Promise<RendererSmokeState | null> {
const started = Date.now(); const started = Date.now();
const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === "1";
while (Date.now() - started < timeoutMs) { while (Date.now() - started < timeoutMs) {
const state = await waitForRendererSmokeState(window, 2000); const state = await waitForRendererSmokeState(window, 2000);
...@@ -277,6 +355,17 @@ async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 400 ...@@ -277,6 +355,17 @@ async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 400
if (streamSmoke && ["completed", "fallback", "error"].includes(String(streamSmoke.phase ?? ""))) { if (streamSmoke && ["completed", "fallback", "error"].includes(String(streamSmoke.phase ?? ""))) {
return state; return state;
} }
const statusLabels = Array.isArray(streamSmoke?.statusLabels)
? streamSmoke.statusLabels.map((value: string) => String(value || ""))
: [];
if (
acceptWorkspaceLaunch
&& streamSmoke
&& String(streamSmoke.phase ?? "") !== "error"
&& statusLabels.some((label: string) => label.includes("Launching project workspace"))
) {
return state;
}
await delay(250); await delay(250);
} }
...@@ -292,6 +381,54 @@ function resolveSmokeStreamTimeoutMs(): number { ...@@ -292,6 +381,54 @@ function resolveSmokeStreamTimeoutMs(): number {
return 40_000; return 40_000;
} }
function resolveSmokeWaitForPaths(): string[] {
const raw = process.env.QJCLAW_SMOKE_WAIT_FOR_PATHS ?? "";
return raw
.split(path.delimiter)
.map((value) => value.trim())
.filter((value) => value.length > 0);
}
function resolveSmokeWaitForPathsTimeoutMs(): number {
const raw = Number.parseInt(process.env.QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS ?? "", 10);
if (Number.isFinite(raw) && raw >= 1_000) {
return raw;
}
return 120_000;
}
async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Promise<void> {
if (pathsToCheck.length === 0) {
return;
}
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const results = await Promise.all(pathsToCheck.map(async (targetPath) => {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}));
if (results.every(Boolean)) {
return;
}
await delay(1000);
}
const missingPaths = await Promise.all(pathsToCheck.map(async (targetPath) => {
try {
await access(targetPath);
return null;
} catch {
return targetPath;
}
}));
throw new Error("Workspace launch was accepted, but expected artifacts were not created in time: " + missingPaths.filter(Boolean).join(", "));
}
async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> { async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> {
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
startedAt: new Date().toISOString() startedAt: new Date().toISOString()
...@@ -301,61 +438,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -301,61 +438,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const line = "[" + new Date().toISOString() + "] " + message + "\n"; const line = "[" + new Date().toISOString() + "] " + message + "\n";
await appendFile(tracePath, line, "utf8").catch(() => undefined); await appendFile(tracePath, line, "utf8").catch(() => undefined);
}; };
const smokeWaitForPaths = resolveSmokeWaitForPaths();
const smokeWaitForPathsTimeoutMs = resolveSmokeWaitForPathsTimeoutMs();
try { try {
await trace("runSmokeTest:start"); await trace("runSmokeTest:start");
if (window.webContents.isLoadingMainFrame()) { if (window.webContents.isLoadingMainFrame()) {
await trace("runSmokeTest:renderer-loading"); await trace("runSmokeTest:renderer-loading");
await new Promise<void>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timer) {
clearTimeout(timer);
}
window.webContents.removeListener("did-fail-load", onFailLoad);
window.webContents.removeListener("render-process-gone", onRenderProcessGone);
};
const finish = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve();
};
const fail = (message: string) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(new Error(message));
};
const onFailLoad = (_event: Electron.Event, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => {
if (!isMainFrame) {
return;
}
fail("Renderer main frame failed to load: " + errorDescription + " (" + errorCode + ") " + validatedURL);
};
const onRenderProcessGone = (_event: Electron.Event, details: Electron.RenderProcessGoneDetails) => {
fail("Renderer process exited during smoke load: " + details.reason);
};
timer = setTimeout(() => {
fail("Renderer DOM did not become ready in time.");
}, 15000);
window.webContents.once("dom-ready", finish);
window.webContents.on("did-fail-load", onFailLoad);
window.webContents.on("render-process-gone", onRenderProcessGone);
});
await trace("runSmokeTest:dom-ready");
} }
await trace("runSmokeTest:loading-renderer-state"); await trace("runSmokeTest:loading-renderer-state");
let initialState = await waitForRendererSmokeState(window); let initialState = await waitForRendererSmokeBootstrap(window);
if (!initialState) {
throw new Error("Renderer smoke state was not published.");
}
const readyDeadline = Date.now() + 30000; const readyDeadline = Date.now() + 30000;
while ( while (
...@@ -576,7 +669,18 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -576,7 +669,18 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})); }));
}; };
const runtimeCloudStatus = await api.runtimeCloud.getStatus(); const runtimeCloudStatus = await api.runtimeCloud.getStatus();
const runtimeCloudFetch = runtimeCloudStatus.apiKeyConfigured ? await api.runtimeCloud.fetchConfig("init") : runtimeCloudStatus; let runtimeCloudFetch = runtimeCloudStatus;
let runtimeCloudFetchError = null;
if (runtimeCloudStatus.apiKeyConfigured) {
try {
runtimeCloudFetch = await api.runtimeCloud.fetchConfig("init");
} catch (error) {
runtimeCloudFetchError = error instanceof Error ? error.message : String(error);
if (!(runtimeCloudStatus && runtimeCloudStatus.state === "ready" && runtimeCloudStatus.config)) {
throw error;
}
}
}
const runtimeStatus = await api.runtime.getStatus(); const runtimeStatus = await api.runtime.getStatus();
const runtimeHealth = await api.runtime.health(); const runtimeHealth = await api.runtime.health();
const runtimeStartProbe = await api.runtime.start(); const runtimeStartProbe = await api.runtime.start();
...@@ -611,6 +715,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -611,6 +715,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
smokeProjectId: ${JSON.stringify(smokeProjectId)}, smokeProjectId: ${JSON.stringify(smokeProjectId)},
runtimeCloudStatus, runtimeCloudStatus,
runtimeCloudFetch, runtimeCloudFetch,
runtimeCloudFetchError,
runtimeStatus, runtimeStatus,
runtimeHealth, runtimeHealth,
runtimeStartProbe, runtimeStartProbe,
...@@ -668,6 +773,20 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -668,6 +773,20 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
} }
await trace("runSmokeTest:stream-terminal:" + String(streamState.streamSmoke.phase ?? "unknown")); await trace("runSmokeTest:stream-terminal:" + String(streamState.streamSmoke.phase ?? "unknown"));
const workspaceLaunchAccepted = (
process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === "1"
&& Array.isArray(streamState.streamSmoke.statusLabels)
&& streamState.streamSmoke.statusLabels.some((label: string) => String(label || "").includes("Launching project workspace"))
&& !["completed", "fallback", "error"].includes(String(streamState.streamSmoke.phase ?? ""))
);
if (workspaceLaunchAccepted) {
await trace("runSmokeTest:workspace-launch-accepted");
if (smokeWaitForPaths.length > 0) {
await trace("runSmokeTest:artifact-wait-start:" + smokeWaitForPaths.length);
await waitForSmokePaths(smokeWaitForPaths, smokeWaitForPathsTimeoutMs);
await trace("runSmokeTest:artifact-wait-complete");
}
}
await delay(1500); await delay(1500);
const finalState = await waitForRendererSmokeState(window, 5000); const finalState = await waitForRendererSmokeState(window, 5000);
const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke; const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke;
...@@ -802,7 +921,11 @@ async function bootstrap(): Promise<void> { ...@@ -802,7 +921,11 @@ async function bootstrap(): Promise<void> {
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, startupLogger); const packagedBootstrapService = new PackagedBootstrapService(configService, {
bootstrapRoot: resolvePackagedBootstrapRoot(systemSummary),
startupLogger
});
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager, startupLogger, packagedBootstrapService);
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");
...@@ -855,7 +978,18 @@ async function bootstrap(): Promise<void> { ...@@ -855,7 +978,18 @@ async function bootstrap(): Promise<void> {
const projectSkillRouter = new ProjectSkillRouterService(projectStore); const projectSkillRouter = new ProjectSkillRouterService(projectStore);
const projectChatTargetResolver = new ProjectChatTargetResolverService(projectStore, projectIntentRouter); const projectChatTargetResolver = new ProjectChatTargetResolverService(projectStore, projectIntentRouter);
const projectExecutionRouter = new ProjectExecutionRouter(); const projectExecutionRouter = new ProjectExecutionRouter();
let lastRemoteSkillSyncKey = "";
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => { runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
const remoteSkillSyncKey = JSON.stringify(skills.map((skill) => ({
skillId: skill.skillId,
fileName: skill.fileName ?? null,
fileSize: typeof skill.fileSize === "number" ? skill.fileSize : null,
downloadUrl: skill.downloadUrl ?? null
})));
if (remoteSkillSyncKey === lastRemoteSkillSyncKey) {
return;
}
lastRemoteSkillSyncKey = remoteSkillSyncKey;
await skillStore.reconcile(skills, payloadConfig.configVersion); await skillStore.reconcile(skills, payloadConfig.configVersion);
await syncProjectBundles(skills, payloadConfig.configVersion, "sync"); await syncProjectBundles(skills, payloadConfig.configVersion, "sync");
}); });
...@@ -1005,6 +1139,12 @@ async function bootstrap(): Promise<void> { ...@@ -1005,6 +1139,12 @@ async function bootstrap(): Promise<void> {
if (cachedRuntimeCloudConfig) { if (cachedRuntimeCloudConfig) {
void (async () => { void (async () => {
const cachedRemoteSkills = runtimeCloudClient.getRemoteSkillAssets(); const cachedRemoteSkills = runtimeCloudClient.getRemoteSkillAssets();
lastRemoteSkillSyncKey = JSON.stringify(cachedRemoteSkills.map((skill) => ({
skillId: skill.skillId,
fileName: skill.fileName ?? null,
fileSize: typeof skill.fileSize === "number" ? skill.fileSize : null,
downloadUrl: skill.downloadUrl ?? null
})));
await skillStore.reconcile(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion).catch(() => undefined); await skillStore.reconcile(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion).catch(() => undefined);
await syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion, "init"); await syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion, "init");
})().catch((error) => { })().catch((error) => {
...@@ -1021,7 +1161,10 @@ async function bootstrap(): Promise<void> { ...@@ -1021,7 +1161,10 @@ async function bootstrap(): Promise<void> {
if (smokeEnabled && smokeOutputPath) { if (smokeEnabled && smokeOutputPath) {
await traceBootstrap("run-smoke-test-start"); await traceBootstrap("run-smoke-test-start");
void runSmokeTest(window, smokeOutputPath); smokeTestInFlight = true;
void runSmokeTest(window, smokeOutputPath).finally(() => {
smokeTestInFlight = false;
});
} }
app.on("activate", () => { app.on("activate", () => {
...@@ -1032,7 +1175,7 @@ async function bootstrap(): Promise<void> { ...@@ -1032,7 +1175,7 @@ async function bootstrap(): Promise<void> {
} }
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin" && !smokeTestInFlight) {
app.quit(); app.quit();
} }
}); });
......
...@@ -360,6 +360,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -360,6 +360,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}; };
const scheduleRuntimeCloudRefresh = (reason: string) => { const scheduleRuntimeCloudRefresh = (reason: string) => {
if (runtimeCloudRefreshInFlight) {
return;
}
runtimeCloudRefreshInFlight = true; runtimeCloudRefreshInFlight = true;
void (async () => { void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion; const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
...@@ -402,6 +406,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -402,6 +406,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const shouldUseRuntimeCloud = nextConfig.setupMode === "employee-key"; const shouldUseRuntimeCloud = nextConfig.setupMode === "employee-key";
const canUseRuntimeCloudConfig = shouldUseRuntimeCloud && Boolean(apiKey); const canUseRuntimeCloudConfig = shouldUseRuntimeCloud && Boolean(apiKey);
if (canUseRuntimeCloudConfig) {
await runtimeCloudClient.hydrateCache();
}
const usingCachedRuntimeCloudConfig = canUseRuntimeCloudConfig && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload(); const usingCachedRuntimeCloudConfig = canUseRuntimeCloudConfig && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload();
if (canUseRuntimeCloudConfig && !usingCachedRuntimeCloudConfig) { if (canUseRuntimeCloudConfig && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig(options.action ?? "init"); await runtimeCloudClient.fetchConfig(options.action ?? "init");
...@@ -420,7 +427,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -420,7 +427,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (canUseRuntimeCloudConfig) { if (canUseRuntimeCloudConfig) {
await syncRuntimeCloudSupervisor(reason); await syncRuntimeCloudSupervisor(reason);
if (usingCachedRuntimeCloudConfig) { if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh(reason); if (!initialRuntimeCloudRefreshScheduled) {
initialRuntimeCloudRefreshScheduled = true;
scheduleRuntimeCloudRefresh(reason);
}
} }
} else { } else {
await runtimeCloudSupervisor.stop(reason); await runtimeCloudSupervisor.stop(reason);
...@@ -441,6 +451,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -441,6 +451,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 runtimeCloudRefreshInFlight = false; let runtimeCloudRefreshInFlight = false;
let initialRuntimeCloudRefreshScheduled = false;
let bootstrapRecoveryAttempts = 0; let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = ""; let lastWorkspaceSummaryLogKey = "";
...@@ -799,9 +810,21 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -799,9 +810,21 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId); const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId);
const resolvedSessionId = target.sessionState.sessionId; const resolvedSessionId = target.sessionState.sessionId;
const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId); const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId);
const preferWorkspaceDefaultEntry = projectConfig?.defaultEntry?.type === "workspace-entry"; const snapshot = await projectContextService.getSnapshot(target.sessionState.projectId);
const declaredWorkspaceEntryDecision = requestedSkillId
? null
: await projectExecutionRouter.decide({
sessionId: resolvedSessionId,
projectId: target.sessionState.projectId,
projectRoot: target.sessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: null,
projectConfig
});
const preferWorkspaceEntry = declaredWorkspaceEntryDecision?.kind === "workspace-entry";
const autoSkillRoute = requestedSkillId const autoSkillRoute = requestedSkillId
|| preferWorkspaceDefaultEntry || preferWorkspaceEntry
? null ? null
: await projectSkillRouter.resolve(target.sessionState.projectId, prompt); : await projectSkillRouter.resolve(target.sessionState.projectId, prompt);
const defaultEntryRoute = (!requestedSkillId && !autoSkillRoute && projectConfig?.defaultEntry?.type === "skill") const defaultEntryRoute = (!requestedSkillId && !autoSkillRoute && projectConfig?.defaultEntry?.type === "skill")
...@@ -814,24 +837,33 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -814,24 +837,33 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
: null) : null)
: null; : null;
const resolvedSkillRoute = autoSkillRoute ?? defaultEntryRoute; const resolvedSkillRoute = autoSkillRoute ?? defaultEntryRoute;
const selectedSkillId = requestedSkillId ?? resolvedSkillRoute?.skillId ?? null; const candidateSkillId = requestedSkillId ?? resolvedSkillRoute?.skillId ?? null;
const decision = candidateSkillId
? await projectExecutionRouter.decide({
sessionId: resolvedSessionId,
projectId: target.sessionState.projectId,
projectRoot: target.sessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: candidateSkillId,
projectConfig
})
: (declaredWorkspaceEntryDecision ?? await projectExecutionRouter.decide({
sessionId: resolvedSessionId,
projectId: target.sessionState.projectId,
projectRoot: target.sessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: null,
projectConfig
}));
const selectedSkillId = decision.kind === "skill" ? decision.skillId : null;
await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId); await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId);
const sessionState = await projectStore.getSessionState(resolvedSessionId); const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
const snapshot = await projectContextService.getSnapshot(sessionState.projectId); if (reboundSessionState.contextSnapshotId !== snapshot.snapshotId) {
if (sessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId); await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId);
} }
const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
const decision = await projectExecutionRouter.decide({
sessionId: resolvedSessionId,
projectId: reboundSessionState.projectId,
projectRoot: reboundSessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId,
projectConfig
});
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined; const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined;
const executionPolicy = await resolveExecutionPolicy(reboundSessionState.projectId, executionSkillId); const executionPolicy = await resolveExecutionPolicy(reboundSessionState.projectId, executionSkillId);
const gatewayPrompt = await prepareGatewayPrompt(decision, reboundSessionState.projectId); const gatewayPrompt = await prepareGatewayPrompt(decision, reboundSessionState.projectId);
...@@ -845,7 +877,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -845,7 +877,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
autoRouted: target.autoRouted, autoRouted: target.autoRouted,
previousProjectId: target.previousProjectId, previousProjectId: target.previousProjectId,
skillRoute: resolvedSkillRoute, skillRoute: resolvedSkillRoute,
autoSelectedSkill: !requestedSkillId && Boolean(resolvedSkillRoute) autoSelectedSkill: !requestedSkillId && decision.kind === "skill" && Boolean(resolvedSkillRoute)
}; };
}; };
...@@ -1073,68 +1105,68 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1073,68 +1105,68 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const stream = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, { const stream = await runGatewayChatRequestWithRecovery(chatGatewayRecoveryCoordinator, {
reason: "chat-stream", reason: "chat-stream",
execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, { execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => { onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({ queueOrSend({
type: "started", type: "started",
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
executionPolicy: executionPolicy ?? undefined executionPolicy: executionPolicy ?? undefined
}); });
}, },
onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => { onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => {
queueOrSend({ queueOrSend({
type: "status", type: "status",
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
stage, stage,
label, label,
detail detail
}); });
}, },
onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => { onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => {
queueOrSend({ queueOrSend({
type: "delta", type: "delta",
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
textDelta, textDelta,
fullText fullText
}); });
}, },
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => { onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
settled = true; settled = true;
void (async () => { void (async () => {
await projectStore.appendSessionMessage(nextSessionId, reply); await projectStore.appendSessionMessage(nextSessionId, reply);
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined); await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined); })().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
queueOrSend({ queueOrSend({
type: "completed", type: "completed",
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
reply, reply,
executionPolicy: executionPolicy ?? undefined executionPolicy: executionPolicy ?? undefined
}); });
queueProjectContextRefresh(); queueProjectContextRefresh();
}, },
onError: ({ sessionId: nextSessionId, runId, error }) => { onError: ({ sessionId: nextSessionId, runId, error }) => {
settled = true; settled = true;
runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, { runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, {
modelId: executionPolicy?.modelId, modelId: executionPolicy?.modelId,
sessionId: nextSessionId sessionId: nextSessionId
}); });
queueOrSend({ queueOrSend({
type: "error", type: "error",
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
message: error.message message: error.message
}); });
queueProjectContextRefresh(); queueProjectContextRefresh();
} }
}) })
}); });
ready = true; ready = true;
......
...@@ -22,6 +22,7 @@ import type { ...@@ -22,6 +22,7 @@ import type {
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import { getRuntimeCloudApiTarget } from "./app-config.js"; import { getRuntimeCloudApiTarget } from "./app-config.js";
import type { AppConfigService, RuntimeCloudApiBaseUrlSource } from "./app-config.js"; import type { AppConfigService, RuntimeCloudApiBaseUrlSource } from "./app-config.js";
import type { PackagedBootstrapService } from "./packaged-bootstrap.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"; import type { StartupLogger } from "./startup-logger.js";
...@@ -542,6 +543,7 @@ export class OpenClawConfigClient { ...@@ -542,6 +543,7 @@ export class OpenClawConfigClient {
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 readonly startupLogger?: StartupLogger;
private readonly packagedBootstrap?: Pick<PackagedBootstrapService, "materializeForApiKey">;
private payloadCache: OpenClawEmployeeConfigPayload | null = null; private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = { private statusCache: RuntimeCloudStatus = {
state: "unconfigured", state: "unconfigured",
...@@ -551,11 +553,17 @@ export class OpenClawConfigClient { ...@@ -551,11 +553,17 @@ export class OpenClawConfigClient {
}; };
private cacheLoaded = false; private cacheLoaded = false;
constructor(configService: AppConfigService, secretManager: SecretManager, startupLogger?: StartupLogger) { constructor(
configService: AppConfigService,
secretManager: SecretManager,
startupLogger?: StartupLogger,
packagedBootstrap?: Pick<PackagedBootstrapService, "materializeForApiKey">
) {
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; this.startupLogger = startupLogger;
this.packagedBootstrap = packagedBootstrap;
} }
async hydrateCache(): Promise<void> { async hydrateCache(): Promise<void> {
...@@ -564,6 +572,10 @@ export class OpenClawConfigClient { ...@@ -564,6 +572,10 @@ export class OpenClawConfigClient {
} }
this.cacheLoaded = true; this.cacheLoaded = true;
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (apiKey && this.packagedBootstrap) {
await this.packagedBootstrap.materializeForApiKey(apiKey).catch(() => undefined);
}
try { try {
const raw = await readFile(this.cachePath, "utf8"); const raw = await readFile(this.cachePath, "utf8");
const parsed = JSON.parse(raw) as OpenClawConfigCacheRecord; const parsed = JSON.parse(raw) as OpenClawConfigCacheRecord;
...@@ -574,7 +586,6 @@ export class OpenClawConfigClient { ...@@ -574,7 +586,6 @@ export class OpenClawConfigClient {
throw new Error("Runtime cloud cache is missing required summary fields."); throw new Error("Runtime cloud cache is missing required summary fields.");
} }
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!apiKey || parsed.keyFingerprint !== buildApiKeyFingerprint(apiKey)) { if (!apiKey || parsed.keyFingerprint !== buildApiKeyFingerprint(apiKey)) {
return; return;
} }
...@@ -604,7 +615,7 @@ export class OpenClawConfigClient { ...@@ -604,7 +615,7 @@ export class OpenClawConfigClient {
baseUrl: this.statusCache.baseUrl, baseUrl: this.statusCache.baseUrl,
apiKeyConfigured: this.statusCache.apiKeyConfigured apiKeyConfigured: this.statusCache.apiKeyConfigured
}; };
this.cacheLoaded = true; this.cacheLoaded = false;
await rm(this.cachePath, { force: true }).catch(() => undefined); await rm(this.cachePath, { force: true }).catch(() => undefined);
} }
...@@ -664,6 +675,9 @@ export class OpenClawConfigClient { ...@@ -664,6 +675,9 @@ export class OpenClawConfigClient {
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> { private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache(); await this.hydrateCache();
if (action === "init" && this.payloadCache && this.statusCache.config) {
return this.payloadCache;
}
const config = await this.configService.load(); const config = await this.configService.load();
const startedAt = Date.now(); const startedAt = Date.now();
const { baseUrl, source } = getRuntimeCloudApiTarget(config); const { baseUrl, source } = getRuntimeCloudApiTarget(config);
......
...@@ -3,7 +3,7 @@ import path from "node:path"; ...@@ -3,7 +3,7 @@ import path from "node:path";
import type { DailyReportDeliveryState, OpenClawDailyReportPayload } from "@qjclaw/shared-types"; import type { DailyReportDeliveryState, OpenClawDailyReportPayload } from "@qjclaw/shared-types";
import { OpenClawDailyReportClient } from "./cloud-api.js"; import { OpenClawDailyReportClient } from "./cloud-api.js";
import type { RuntimeCloudActivityEvent } from "./runtime-cloud-supervisor.js"; import type { RuntimeCloudActivityEvent } from "./runtime-cloud-supervisor.js";
import type { AppConfigService } from "./app-config.js"; import { getRuntimeCloudApiTarget, type AppConfigService } from "./app-config.js";
import type { SecretManager } from "./secrets.js"; import type { SecretManager } from "./secrets.js";
interface PersistedDailyReportEntry { interface PersistedDailyReportEntry {
...@@ -362,7 +362,7 @@ export class DailyReportService { ...@@ -362,7 +362,7 @@ export class DailyReportService {
private async canSend(): Promise<boolean> { private async canSend(): Promise<boolean> {
const config = await this.configService.load(); const config = await this.configService.load();
const apiKey = (await this.secretManager.getApiKey())?.trim(); const apiKey = (await this.secretManager.getApiKey())?.trim();
return Boolean(config.runtimeCloudApiBaseUrl.trim() && apiKey); return Boolean(getRuntimeCloudApiTarget(config).baseUrl && apiKey);
} }
private async persistState(): Promise<void> { private async persistState(): Promise<void> {
......
import { createHash } from "node:crypto";
import { cp, mkdir, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { AppConfigService } from "./app-config.js";
import type { StartupLogger } from "./startup-logger.js";
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
function buildApiKeyFingerprint(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex");
}
interface PackagedBootstrapServiceOptions {
bootstrapRoot: string;
startupLogger?: StartupLogger;
}
export class PackagedBootstrapService {
private readonly configService: AppConfigService;
private readonly bootstrapRoot: string;
private readonly startupLogger?: StartupLogger;
private readonly materializedFingerprints = new Set<string>();
constructor(configService: AppConfigService, options: PackagedBootstrapServiceOptions) {
this.configService = configService;
this.bootstrapRoot = options.bootstrapRoot;
this.startupLogger = options.startupLogger;
}
async materializeForApiKey(apiKey: string): Promise<boolean> {
const normalizedApiKey = apiKey.trim();
if (!normalizedApiKey) {
return false;
}
const keyFingerprint = buildApiKeyFingerprint(normalizedApiKey);
if (this.materializedFingerprints.has(keyFingerprint)) {
return true;
}
const seedRoot = path.join(this.bootstrapRoot, "employee-key", keyFingerprint);
if (!(await pathExists(seedRoot))) {
return false;
}
const cacheSourcePath = path.join(seedRoot, "config", "runtime-cloud-cache.json");
if (!(await pathExists(cacheSourcePath))) {
return false;
}
const config = await this.configService.load();
const workspaceRoot = config.workspacePath.trim() || this.configService.getDataPath("workspace");
const cacheTargetPath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
await this.copyIfMissing(cacheSourcePath, cacheTargetPath);
await this.copyWorkspaceSeedsIfMissing(path.join(seedRoot, "workspace"), workspaceRoot);
this.materializedFingerprints.add(keyFingerprint);
await this.startupLogger?.info("runtime-cloud", "bootstrap.materialized", "Packaged runtime cloud bootstrap was materialized.", {
keyFingerprint: keyFingerprint.slice(0, 12),
seedRoot,
workspaceRoot
});
return true;
}
private async copyWorkspaceSeedsIfMissing(sourceRoot: string, targetRoot: string): Promise<void> {
if (!(await pathExists(sourceRoot))) {
return;
}
await this.copyChildrenIfMissing(path.join(sourceRoot, "projects"), path.join(targetRoot, "projects"));
await this.copyChildrenIfMissing(path.join(sourceRoot, "skills"), path.join(targetRoot, "skills"));
await this.copyChildrenIfMissing(path.join(sourceRoot, "cron"), path.join(targetRoot, "cron"));
await this.copyIfMissing(
path.join(sourceRoot, "manifests", "project-bundles.json"),
path.join(targetRoot, "manifests", "project-bundles.json")
);
await this.copyIfMissing(
path.join(sourceRoot, "manifests", "active-project.json"),
path.join(targetRoot, "manifests", "active-project.json")
);
}
private async copyIfMissing(sourcePath: string, targetPath: string): Promise<void> {
if (!(await pathExists(sourcePath)) || (await pathExists(targetPath))) {
return;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await cp(sourcePath, targetPath, {
recursive: true,
force: false,
errorOnExist: false
});
}
private async copyChildrenIfMissing(sourceDir: string, targetDir: string): Promise<void> {
if (!(await pathExists(sourceDir))) {
return;
}
await mkdir(targetDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
await this.copyIfMissing(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
}
}
}
...@@ -183,6 +183,7 @@ export class ProjectBundleService { ...@@ -183,6 +183,7 @@ export class ProjectBundleService {
private readonly projectStore: ProjectStoreService; private readonly projectStore: ProjectStoreService;
private readonly startupLogger?: StartupLogger; private readonly startupLogger?: StartupLogger;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" }; private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
private syncTail: Promise<void> = Promise.resolve();
constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) { constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) {
this.configService = configService; this.configService = configService;
...@@ -203,78 +204,84 @@ export class ProjectBundleService { ...@@ -203,78 +204,84 @@ export class ProjectBundleService {
} }
async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> { async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> {
const startedAt = Date.now(); const runSync = async (): Promise<void> => {
this.syncStatus = { const startedAt = Date.now();
...this.syncStatus, this.syncStatus = {
state: "syncing", ...this.syncStatus,
lastError: undefined state: "syncing",
}; lastError: undefined
const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName)); };
logBundle("bundle.sync.start", { const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName));
action: _action ?? "unknown", logBundle("bundle.sync.start", {
configVersion, action: _action ?? "unknown",
remoteSkillCount: remoteSkills.length, configVersion,
bundleAssetCount: bundleAssets.length remoteSkillCount: remoteSkills.length,
}); bundleAssetCount: bundleAssets.length
const workspaceRoot = await this.projectStore.getWorkspaceRoot(); });
await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", { const workspaceRoot = await this.projectStore.getWorkspaceRoot();
action: _action ?? "unknown", await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", {
configVersion, action: _action ?? "unknown",
workspaceRoot, configVersion,
remoteSkillCount: remoteSkills.length, workspaceRoot,
bundleAssetCount: bundleAssets.length remoteSkillCount: remoteSkills.length,
}); bundleAssetCount: bundleAssets.length
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE); });
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {}; const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const nextManifest: Record<string, BundleManifestRecord> = {}; const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const seenBundleKeys = new Set<string>(); const nextManifest: Record<string, BundleManifestRecord> = {};
logBundle("bundle.asset_filter.result", { const seenBundleKeys = new Set<string>();
remoteSkillCount: remoteSkills.length, logBundle("bundle.asset_filter.result", {
bundleAssetCount: bundleAssets.length remoteSkillCount: remoteSkills.length,
}); bundleAssetCount: bundleAssets.length
});
for (const asset of bundleAssets) { for (const asset of bundleAssets) {
if (!asset.downloadUrl || !asset.fileName) { if (!asset.downloadUrl || !asset.fileName) {
continue; continue;
} }
const bundleKey = this.getBundleAssetKey(asset); const bundleKey = this.getBundleAssetKey(asset);
if (seenBundleKeys.has(bundleKey)) { if (seenBundleKeys.has(bundleKey)) {
logBundle("bundle.duplicate.detected", { logBundle("bundle.duplicate.detected", {
skillId: asset.skillId,
bundleKey
});
continue;
}
seenBundleKeys.add(bundleKey);
const currentRecord = this.findManifestRecordForAsset(currentManifest, asset);
logBundle("bundle.reuse.check", {
skillId: asset.skillId, skillId: asset.skillId,
bundleKey bundleKey,
source: sanitizeUrl(new URL(asset.downloadUrl))
}); });
continue; const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
}
seenBundleKeys.add(bundleKey);
const currentRecord = this.findManifestRecordForAsset(currentManifest, asset); if (nextManifest[nextRecord.projectId]) {
logBundle("bundle.reuse.check", { throw new Error(`Project bundle sync resolved duplicate projectId ${nextRecord.projectId}.`);
skillId: asset.skillId, }
bundleKey, nextManifest[nextRecord.projectId] = nextRecord;
source: sanitizeUrl(new URL(asset.downloadUrl))
});
const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
if (nextManifest[nextRecord.projectId]) {
throw new Error(`Project bundle sync resolved duplicate projectId ${nextRecord.projectId}.`);
} }
nextManifest[nextRecord.projectId] = nextRecord;
}
await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest); await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest);
await writeJsonFile(manifestPath, nextManifest); await writeJsonFile(manifestPath, nextManifest);
this.syncStatus = { this.syncStatus = {
state: "ready", state: "ready",
lastError: undefined, lastError: undefined,
lastSyncedAt: nowIso() lastSyncedAt: nowIso()
};
logBundle("bundle.sync.done", {
action: _action ?? "unknown",
configVersion,
projectCount: Object.keys(nextManifest).length,
elapsedMs: Date.now() - startedAt
});
}; };
logBundle("bundle.sync.done", {
action: _action ?? "unknown", const nextRun = this.syncTail.then(runSync);
configVersion, this.syncTail = nextRun.catch(() => undefined);
projectCount: Object.keys(nextManifest).length, await nextRun;
elapsedMs: Date.now() - startedAt
});
} }
private getBundleAssetKey(asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">): string { private getBundleAssetKey(asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">): string {
...@@ -307,10 +314,39 @@ export class ProjectBundleService { ...@@ -307,10 +314,39 @@ export class ProjectBundleService {
decision: "redownload", decision: "redownload",
reason: "manifest-not-reusable" reason: "manifest-not-reusable"
}); });
return this.downloadAndInstallBundle(workspaceRoot, asset, configVersion); try {
return await this.downloadAndInstallBundle(workspaceRoot, asset, configVersion);
} catch (error) {
const hasLocalProjectCache = await this.hasUsableLocalProjectCache(workspaceRoot, currentRecord);
if (!hasLocalProjectCache) {
throw error;
}
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
decision: "reuse",
reason: "redownload-failed-using-local-cache",
error: error instanceof Error ? error.message : String(error)
});
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, null);
}
} }
const freshnessProbe = await this.probeRemoteBundle(new URL(asset.downloadUrl!)); let freshnessProbe: RemoteBundleProbeResult | null = null;
try {
freshnessProbe = await this.probeRemoteBundle(new URL(asset.downloadUrl!));
} catch (error) {
const hasLocalProjectCache = await this.hasUsableLocalProjectCache(workspaceRoot, currentRecord);
if (!hasLocalProjectCache) {
throw error;
}
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
decision: "reuse",
reason: "freshness-probe-failed-using-local-cache",
error: error instanceof Error ? error.message : String(error)
});
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, null);
}
if (this.shouldRedownloadBundle(currentRecord, asset, freshnessProbe)) { if (this.shouldRedownloadBundle(currentRecord, asset, freshnessProbe)) {
logBundle("bundle.reuse.check", { logBundle("bundle.reuse.check", {
skillId: asset.skillId, skillId: asset.skillId,
...@@ -328,6 +364,18 @@ export class ProjectBundleService { ...@@ -328,6 +364,18 @@ export class ProjectBundleService {
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, freshnessProbe); return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, freshnessProbe);
} }
private async hasUsableLocalProjectCache(
workspaceRoot: string,
record: BundleManifestRecord | undefined
): Promise<boolean> {
if (!record?.projectId) {
return false;
}
const projectJsonPath = path.join(workspaceRoot, "projects", record.projectId, "project.json");
return pathExists(projectJsonPath);
}
private canReuseManifestRecord( private canReuseManifestRecord(
record: BundleManifestRecord | undefined, record: BundleManifestRecord | undefined,
asset: RemoteSkillAsset, asset: RemoteSkillAsset,
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import type { ProjectIntentRoute, ProjectIntentRouterService } from "./project-intent-router.js"; import type { ProjectIntentRoute, ProjectIntentRouterService } from "./project-intent-router.js";
import type { ProjectStoreService } from "./project-store.js"; import type { ProjectStoreService } from "./project-store.js";
const BUILTIN_HOME_PROJECT_ID = "home-chat";
export interface ResolvedProjectChatTarget { export interface ResolvedProjectChatTarget {
sessionState: ProjectSessionState; sessionState: ProjectSessionState;
route: ProjectIntentRoute | null; route: ProjectIntentRoute | null;
...@@ -22,7 +24,7 @@ export class ProjectChatTargetResolverService { ...@@ -22,7 +24,7 @@ export class ProjectChatTargetResolverService {
const sessionState = await this.projectStore.getSessionState(sessionId); const sessionState = await this.projectStore.getSessionState(sessionId);
const selectedSkill = selectedSkillId?.trim() || null; const selectedSkill = selectedSkillId?.trim() || null;
if (selectedSkill) { if (selectedSkill || sessionState.projectId !== BUILTIN_HOME_PROJECT_ID) {
return { return {
sessionState, sessionState,
route: null, route: null,
......
...@@ -6,8 +6,14 @@ import type { ...@@ -6,8 +6,14 @@ import type {
ProjectExecutionRequest, ProjectExecutionRequest,
ProjectPackageConfig ProjectPackageConfig
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import { isPublishIntentPrompt } from "./project-prompt-signals.js";
const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"]; const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"];
const LEGACY_WORKSPACE_PLUGIN_MARKERS = [
path.join("plugin", "openclaw.plugin.json"),
path.join("plugin", "index.js"),
path.join("plugin", "index.ts")
] as const;
async function pathExists(targetPath: string): Promise<boolean> { async function pathExists(targetPath: string): Promise<boolean> {
try { try {
...@@ -67,16 +73,8 @@ function resolveDeclaredWorkspaceEntry(projectConfig?: ProjectPackageConfig | nu ...@@ -67,16 +73,8 @@ function resolveDeclaredWorkspaceEntry(projectConfig?: ProjectPackageConfig | nu
export class ProjectExecutionRouter { export class ProjectExecutionRouter {
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> { async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt); const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
if (request.selectedSkillId) {
return {
kind: "skill",
skillId: request.selectedSkillId,
preparedPrompt
};
}
const declaredWorkspaceEntryReason = resolveDeclaredWorkspaceEntry(request.projectConfig); const declaredWorkspaceEntryReason = resolveDeclaredWorkspaceEntry(request.projectConfig);
if (declaredWorkspaceEntryReason) { if (declaredWorkspaceEntryReason && (!request.selectedSkillId || isPublishIntentPrompt(request.userPrompt))) {
return { return {
kind: "workspace-entry", kind: "workspace-entry",
projectRoot: request.projectRoot, projectRoot: request.projectRoot,
...@@ -86,7 +84,7 @@ export class ProjectExecutionRouter { ...@@ -86,7 +84,7 @@ export class ProjectExecutionRouter {
} }
const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot); const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot);
if (workspaceEntryReason) { if (workspaceEntryReason && (!request.selectedSkillId || isPublishIntentPrompt(request.userPrompt))) {
return { return {
kind: "workspace-entry", kind: "workspace-entry",
projectRoot: request.projectRoot, projectRoot: request.projectRoot,
...@@ -95,6 +93,24 @@ export class ProjectExecutionRouter { ...@@ -95,6 +93,24 @@ export class ProjectExecutionRouter {
}; };
} }
const legacyWorkspaceEntryReason = await this.detectLegacyWorkspaceEntry(request.projectRoot);
if (legacyWorkspaceEntryReason && isPublishIntentPrompt(request.userPrompt)) {
return {
kind: "workspace-entry",
projectRoot: request.projectRoot,
preparedPrompt,
reason: legacyWorkspaceEntryReason
};
}
if (request.selectedSkillId) {
return {
kind: "skill",
skillId: request.selectedSkillId,
preparedPrompt
};
}
return { return {
kind: "chat-fallback", kind: "chat-fallback",
preparedPrompt preparedPrompt
...@@ -130,4 +146,14 @@ export class ProjectExecutionRouter { ...@@ -130,4 +146,14 @@ export class ProjectExecutionRouter {
return null; return null;
} }
private async detectLegacyWorkspaceEntry(projectRoot: string): Promise<string | null> {
for (const marker of LEGACY_WORKSPACE_PLUGIN_MARKERS) {
if (await pathExists(path.join(projectRoot, marker))) {
return `legacy workspace plugin marker ${marker} detected`;
}
}
return null;
}
} }
const PUBLISH_KEYWORDS = [
"\u53d1\u5e03",
"\u53d1\u5e16",
"\u53d1\u4e00\u4e2a",
"\u53d1\u4e00\u7bc7",
"\u53d1\u4e00\u6761",
"\u53d1\u4e2a",
"\u53d1\u6761",
"\u53d1\u9001",
"\u53d1\u9001\u4e00\u4e2a",
"\u53d1\u9001\u4e00\u7bc7",
"\u81ea\u52a8\u53d1",
"\u81ea\u52a8\u53d1\u5e03",
"\u63d0\u4ea4",
"publish",
"post"
];
const DRAFT_KEYWORDS = ["\u8349\u7a3f", "markdown", "draft", "md"];
const WRITING_KEYWORDS = [
"\u5199",
"\u751f\u6210",
"\u6587\u6848",
"\u5e16\u5b50",
"\u7b14\u8bb0",
"\u6807\u9898",
"\u7f16\u8f91",
"\u6da6\u8272"
];
const STRATEGY_KEYWORDS = ["\u9009\u9898", "\u7b56\u7565", "\u89c4\u5212", "plan", "topic"];
const REVIEW_KEYWORDS = ["\u5ba1\u6838", "\u6821\u5bf9", "review", "\u68c0\u67e5", "\u5408\u89c4"];
const OPERATIONS_KEYWORDS = ["\u8fd0\u8425", "\u52a9\u624b", "\u5b89\u6392", "\u8def\u7531", "\u534f\u540c", "workflow", "orchestrator"];
export interface ProjectPromptSignals {
publishCount: number;
draftCount: number;
writingCount: number;
strategyCount: number;
reviewCount: number;
operationsCount: number;
}
export function normalizeProjectPrompt(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\n]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function keywordCount(prompt: string, keywords: string[]): number {
return keywords.reduce((count, keyword) => count + (prompt.includes(keyword) ? 1 : 0), 0);
}
export function detectProjectPromptSignals(prompt: string): ProjectPromptSignals {
const normalizedPrompt = normalizeProjectPrompt(prompt);
return {
publishCount: keywordCount(normalizedPrompt, PUBLISH_KEYWORDS),
draftCount: keywordCount(normalizedPrompt, DRAFT_KEYWORDS),
writingCount: keywordCount(normalizedPrompt, WRITING_KEYWORDS),
strategyCount: keywordCount(normalizedPrompt, STRATEGY_KEYWORDS),
reviewCount: keywordCount(normalizedPrompt, REVIEW_KEYWORDS),
operationsCount: keywordCount(normalizedPrompt, OPERATIONS_KEYWORDS)
};
}
export function isPublishIntentPrompt(prompt: string): boolean {
return detectProjectPromptSignals(prompt).publishCount > 0;
}
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import type { ProjectPackageEntry, WorkspaceSkillSummary } from "@qjclaw/shared-types"; import type { ProjectPackageEntry, WorkspaceSkillSummary } from "@qjclaw/shared-types";
import type { ProjectStoreService } from "./project-store.js"; import type { ProjectStoreService } from "./project-store.js";
import {
detectProjectPromptSignals,
normalizeProjectPrompt,
type ProjectPromptSignals
} from "./project-prompt-signals.js";
const MAX_SKILL_BYTES = 4096; const MAX_SKILL_BYTES = 4096;
const MIN_ROUTE_SCORE = 5; const MIN_ROUTE_SCORE = 5;
...@@ -14,13 +19,6 @@ const PLATFORM_ALIAS_MAP: Record<string, string[]> = { ...@@ -14,13 +19,6 @@ const PLATFORM_ALIAS_MAP: Record<string, string[]> = {
tiktok: ["抖音"] tiktok: ["抖音"]
}; };
const PUBLISH_KEYWORDS = ["发布", "发帖", "发一个", "发一篇", "自动发", "自动发布", "提交", "publish", "post"];
const DRAFT_KEYWORDS = ["草稿", "markdown", "draft", "md"];
const WRITING_KEYWORDS = ["写", "生成", "文案", "帖子", "笔记", "标题", "编辑", "润色"];
const STRATEGY_KEYWORDS = ["选题", "策略", "规划", "plan", "topic"];
const REVIEW_KEYWORDS = ["审核", "校对", "review", "检查", "合规"];
const OPERATIONS_KEYWORDS = ["运营", "助手", "安排", "路由", "协同", "workflow", "orchestrator"];
export interface ProjectSkillRoute { export interface ProjectSkillRoute {
skillId: string; skillId: string;
reason: string; reason: string;
...@@ -35,48 +33,15 @@ interface SkillCandidate { ...@@ -35,48 +33,15 @@ interface SkillCandidate {
isDefaultEntry: boolean; isDefaultEntry: boolean;
} }
interface PromptSignals {
publishCount: number;
draftCount: number;
writingCount: number;
strategyCount: number;
reviewCount: number;
operationsCount: number;
}
function normalizeText(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\n]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeAlias(value: string): string { function normalizeAlias(value: string): string {
return normalizeText(value).replace(/\s+/g, ""); return normalizeProjectPrompt(value).replace(/\s+/g, "");
} }
function uniqueStrings(values: Iterable<string>): string[] { function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))]; return [...new Set([...values].map((value) => value.trim()).filter(Boolean))];
} }
function keywordCount(prompt: string, keywords: string[]): number { function scoreRole(signals: ProjectPromptSignals, role: SkillCandidate["role"]): number {
return keywords.reduce((count, keyword) => count + (prompt.includes(keyword) ? 1 : 0), 0);
}
function detectPromptSignals(prompt: string): PromptSignals {
return {
publishCount: keywordCount(prompt, PUBLISH_KEYWORDS),
draftCount: keywordCount(prompt, DRAFT_KEYWORDS),
writingCount: keywordCount(prompt, WRITING_KEYWORDS),
strategyCount: keywordCount(prompt, STRATEGY_KEYWORDS),
reviewCount: keywordCount(prompt, REVIEW_KEYWORDS),
operationsCount: keywordCount(prompt, OPERATIONS_KEYWORDS)
};
}
function scoreRole(signals: PromptSignals, role: SkillCandidate["role"]): number {
switch (role) { switch (role) {
case "pipeline": case "pipeline":
return signals.publishCount * 4 + signals.writingCount * 2 + signals.draftCount + signals.operationsCount * 2; return signals.publishCount * 4 + signals.writingCount * 2 + signals.draftCount + signals.operationsCount * 2;
...@@ -95,7 +60,7 @@ function scoreRole(signals: PromptSignals, role: SkillCandidate["role"]): number ...@@ -95,7 +60,7 @@ function scoreRole(signals: PromptSignals, role: SkillCandidate["role"]): number
} }
} }
function scoreCapabilities(signals: PromptSignals, capabilities: string[]): number { function scoreCapabilities(signals: ProjectPromptSignals, capabilities: string[]): number {
let score = 0; let score = 0;
for (const capability of capabilities) { for (const capability of capabilities) {
const normalized = normalizeAlias(capability); const normalized = normalizeAlias(capability);
...@@ -279,7 +244,7 @@ export class ProjectSkillRouterService { ...@@ -279,7 +244,7 @@ export class ProjectSkillRouterService {
} satisfies SkillCandidate; } satisfies SkillCandidate;
})); }));
const signals = detectPromptSignals(normalizedPrompt); const signals = detectProjectPromptSignals(normalizedPrompt);
if (signals.writingCount > 0 && signals.publishCount === 0) { if (signals.writingCount > 0 && signals.publishCount === 0) {
const route = chooseByRole(candidates, "writer", "matched writer intent"); const route = chooseByRole(candidates, "writer", "matched writer intent");
if (route) { if (route) {
......
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager"; import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ProjectStoreService } from "./project-store.js"; import type { ProjectStoreService } from "./project-store.js";
...@@ -65,6 +65,15 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin ...@@ -65,6 +65,15 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin
return `---\nname: ${runtimeSkillName}\n---\n\n${normalized}`; return `---\nname: ${runtimeSkillName}\n---\n\n${normalized}`;
} }
function sanitizeRuntimeDirName(value: string): string {
return value
.trim()
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\.+$/g, "")
.replace(/^\.+/g, "")
|| "skill";
}
export class RuntimeSkillBridgeService { export class RuntimeSkillBridgeService {
private readonly skillStore: SkillStoreService; private readonly skillStore: SkillStoreService;
private readonly projectStore: ProjectStoreService; private readonly projectStore: ProjectStoreService;
...@@ -143,11 +152,12 @@ export class RuntimeSkillBridgeService { ...@@ -143,11 +152,12 @@ export class RuntimeSkillBridgeService {
const runtimeSkillName = selected const runtimeSkillName = selected
? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}` ? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`
: sourceName; : sourceName;
const runtimeSkillDir = path.join(skillsRoot, `${MANAGED_SKILL_PREFIX}${slugify(target.skillId)}`); const runtimeSkillDir = path.join(skillsRoot, sanitizeRuntimeDirName(target.skillId || target.name || sourceName));
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName); const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
const sourceSkillDir = path.dirname(target.localPath);
await rm(runtimeSkillDir, { recursive: true, force: true }).catch(() => undefined); await rm(runtimeSkillDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(runtimeSkillDir, { recursive: true }); await cp(sourceSkillDir, runtimeSkillDir, { recursive: true, force: true });
await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8"); await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8");
await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({ await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({
source: "cloud", source: "cloud",
...@@ -212,8 +222,22 @@ export class RuntimeSkillBridgeService { ...@@ -212,8 +222,22 @@ export class RuntimeSkillBridgeService {
await Promise.all( await Promise.all(
entries entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(MANAGED_SKILL_PREFIX)) .filter((entry) => entry.isDirectory())
.map((entry) => rm(path.join(skillsRoot, entry.name), { recursive: true, force: true })) .map(async (entry) => {
const metadataPath = path.join(skillsRoot, entry.name, ".qjclaw-skill.json");
try {
const metadataRaw = await readFile(metadataPath, "utf8");
const metadata = JSON.parse(stripBom(metadataRaw)) as { source?: string };
if (metadata.source !== "cloud") {
return;
}
} catch {
if (!entry.name.startsWith(MANAGED_SKILL_PREFIX)) {
return;
}
}
await rm(path.join(skillsRoot, entry.name), { recursive: true, force: true });
})
); );
} }
} }
...@@ -92,8 +92,8 @@ interface SmokeUiSnapshot { ...@@ -92,8 +92,8 @@ interface SmokeUiSnapshot {
currentProjectId?: string; currentProjectId?: string;
} }
const DEFAULT_SESSION_ID = "desktop-main";
const HOME_CHAT_PROJECT_ID = "home-chat"; const HOME_CHAT_PROJECT_ID = "home-chat";
const EMPTY_SESSION_ID = "";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400; const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3; const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60; const MAX_TRACE_ITEMS = 60;
...@@ -739,7 +739,7 @@ export default function App() { ...@@ -739,7 +739,7 @@ export default function App() {
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null); const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]); const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]);
const [messages, setMessages] = useState<UiChatMessage[]>([]); const [messages, setMessages] = useState<UiChatMessage[]>([]);
const [activeSessionId, setActiveSessionId] = useState(DEFAULT_SESSION_ID); const [activeSessionId, setActiveSessionId] = useState(EMPTY_SESSION_ID);
const [projectActionPending, setProjectActionPending] = useState(false); const [projectActionPending, setProjectActionPending] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id); const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
...@@ -800,7 +800,9 @@ export default function App() { ...@@ -800,7 +800,9 @@ export default function App() {
const hasConversationProject = viewMode === "chat" const hasConversationProject = viewMode === "chat"
? visibleProjects.length > 0 ? visibleProjects.length > 0
: Boolean(workspace?.projectReady && activeProject?.id); : Boolean(workspace?.projectReady && activeProject?.id);
const showStartupOverlay = viewMode !== "settings" && ((refreshing && !workspace) || !shellReady || (isBound && chatLaunchState !== "ready")); const startupStateActive = viewMode !== "settings" && ((refreshing && !workspace) || !shellReady || (isBound && chatLaunchState !== "ready"));
const hasVisibleConversation = messages.length > 0 || sendPhase !== "idle";
const showStartupOverlay = startupStateActive && !hasVisibleConversation;
const sending = sendPhase !== "idle"; const sending = sendPhase !== "idle";
const canSend = isBound && hasConversationProject && prompt.trim().length > 0 && !sending && !saving; const canSend = isBound && hasConversationProject && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sendPhase === "preparing" const sendButtonLabel = sendPhase === "preparing"
...@@ -811,9 +813,10 @@ export default function App() { ...@@ -811,9 +813,10 @@ export default function App() {
? ui.bindFirst ? ui.bindFirst
: ui.send; : ui.send;
const isDirectProviderSetup = setupModeDraft === "direct-provider"; const isDirectProviderSetup = setupModeDraft === "direct-provider";
const showBindEntry = !isBound && !showStartupOverlay; const showBindEntry = !isBound && !startupStateActive;
const showSettingsStatusHint = viewMode === "settings" && isBound && chatLaunchState !== "ready" && Boolean(startupMessage); const showSettingsStatusHint = viewMode === "settings" && isBound && chatLaunchState !== "ready" && Boolean(startupMessage);
const isConversationView = viewMode === "chat" || viewMode === "experts"; const isConversationView = viewMode === "chat" || viewMode === "experts";
const showInlineStartupNotice = startupStateActive && hasVisibleConversation && isConversationView;
const pageTitle = viewMode === "plugins" ? ui.plugins : ui.settings; const pageTitle = viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc; const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc;
useEffect(() => { useEffect(() => {
...@@ -830,14 +833,12 @@ export default function App() { ...@@ -830,14 +833,12 @@ export default function App() {
async function loadMessages(sessionId: string, canRead: boolean, showError = false) { async function loadMessages(sessionId: string, canRead: boolean, showError = false) {
if (!canRead) { if (!canRead) {
setMessages([]);
return; return;
} }
try { try {
setMessages((await desktopApi.chat.listMessages(sessionId)).filter(isPrimaryChatMessage).map((message) => toUiChatMessage(message))); setMessages((await desktopApi.chat.listMessages(sessionId)).filter(isPrimaryChatMessage).map((message) => toUiChatMessage(message)));
} catch (error) { } catch (error) {
setMessages([]);
if (showError) { if (showError) {
setErrorText(err(error)); setErrorText(err(error));
} }
...@@ -884,7 +885,6 @@ export default function App() { ...@@ -884,7 +885,6 @@ export default function App() {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null)); setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
} else { } else {
setGatewayHealth(null); setGatewayHealth(null);
setMessages([]);
} }
return nextWorkspace; return nextWorkspace;
...@@ -910,12 +910,15 @@ export default function App() { ...@@ -910,12 +910,15 @@ export default function App() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const preserveVisibleConversation = sendPhase !== "idle";
async function syncScopedSessions() { async function syncScopedSessions() {
if (!isBound || bindingRequired || !sessionScopeProjectId) { if (!isBound || bindingRequired || !sessionScopeProjectId) {
if (!cancelled) { if (!cancelled) {
setSessions([]); setSessions([]);
setMessages([]); if (!preserveVisibleConversation) {
setMessages([]);
}
} }
return; return;
} }
...@@ -928,16 +931,29 @@ export default function App() { ...@@ -928,16 +931,29 @@ export default function App() {
setSessions(nextSessions); setSessions(nextSessions);
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId); const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId);
setActiveSessionId(nextSessionId ?? DEFAULT_SESSION_ID); if (nextSessionId) {
if (!nextSessionId) { setActiveSessionId(nextSessionId);
setMessages([]); } else if (sessionScopeProjectId === HOME_CHAT_PROJECT_ID) {
const homeSession = await desktopApi.chat.createSessionForProject(HOME_CHAT_PROJECT_ID, homeChatCopy.title);
if (cancelled) {
return;
}
setSessions([homeSession, ...nextSessions.filter((session) => session.id !== homeSession.id)]);
setActiveSessionId(homeSession.id);
} else {
setActiveSessionId(EMPTY_SESSION_ID);
if (!preserveVisibleConversation) {
setMessages([]);
}
} }
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
return; return;
} }
setSessions([]); setSessions([]);
setMessages([]); if (!preserveVisibleConversation) {
setMessages([]);
}
setErrorText(err(error)); setErrorText(err(error));
} }
} }
...@@ -946,11 +962,11 @@ export default function App() { ...@@ -946,11 +962,11 @@ export default function App() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sessionScopeProjectId, workspace]); }, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sendPhase, sessionScopeProjectId, workspace]);
useEffect(() => { useEffect(() => {
const shouldPollStartupState = viewMode !== "settings" const shouldPollStartupState = viewMode !== "settings"
&& showStartupOverlay && startupStateActive
&& (chatLaunchState === "starting" || (!isBound && !shellReady)); && (chatLaunchState === "starting" || (!isBound && !shellReady));
if (!shouldPollStartupState) { if (!shouldPollStartupState) {
return; return;
...@@ -986,10 +1002,10 @@ export default function App() { ...@@ -986,10 +1002,10 @@ export default function App() {
window.clearTimeout(timer); window.clearTimeout(timer);
} }
}; };
}, [chatLaunchState, isBound, shellReady, showStartupOverlay, viewMode]); }, [chatLaunchState, isBound, shellReady, startupStateActive, viewMode]);
useEffect(() => { useEffect(() => {
const shouldRequestStartupWarmup = showStartupOverlay && ( const shouldRequestStartupWarmup = startupStateActive && (
(isBound && chatLaunchState === "starting") (isBound && chatLaunchState === "starting")
|| (!isBound && !shellReady) || (!isBound && !shellReady)
); );
...@@ -1004,7 +1020,7 @@ export default function App() { ...@@ -1004,7 +1020,7 @@ export default function App() {
startupWarmupRequestedRef.current = true; startupWarmupRequestedRef.current = true;
void desktopApi.workspace.warmup().catch(() => undefined); void desktopApi.workspace.warmup().catch(() => undefined);
}, [chatLaunchState, isBound, shellReady, showStartupOverlay]); }, [chatLaunchState, isBound, shellReady, startupStateActive]);
useEffect(() => { useEffect(() => {
if (!skillMenuOpen) { if (!skillMenuOpen) {
...@@ -1048,12 +1064,18 @@ export default function App() { ...@@ -1048,12 +1064,18 @@ export default function App() {
}, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]); }, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]);
useEffect(() => { useEffect(() => {
if (!isBound || !resolvedActiveSessionId || !workspace?.chatReady || !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)) { if (
!isBound
|| !resolvedActiveSessionId
|| !workspace?.chatReady
|| sendPhase !== "idle"
|| !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)
) {
return; return;
} }
void loadMessages(resolvedActiveSessionId, true, false); void loadMessages(resolvedActiveSessionId, true, false);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, workspace?.chatReady]); }, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, sendPhase, workspace?.chatReady]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -1102,7 +1124,7 @@ export default function App() { ...@@ -1102,7 +1124,7 @@ export default function App() {
const nextWorkspace = await desktopApi.projects.setActive(projectId); const nextWorkspace = await desktopApi.projects.setActive(projectId);
setWorkspace(nextWorkspace); setWorkspace(nextWorkspace);
setSessions([]); setSessions([]);
setActiveSessionId(DEFAULT_SESSION_ID); setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]); setMessages([]);
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
...@@ -1156,7 +1178,7 @@ export default function App() { ...@@ -1156,7 +1178,7 @@ export default function App() {
setWorkspace(nextWorkspace); setWorkspace(nextWorkspace);
} }
} }
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? DEFAULT_SESSION_ID; const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? EMPTY_SESSION_ID;
setActiveSessionId(nextSessionId); setActiveSessionId(nextSessionId);
setMessages([]); setMessages([]);
} catch (error) { } catch (error) {
...@@ -2036,6 +2058,11 @@ export default function App() { ...@@ -2036,6 +2058,11 @@ export default function App() {
{!messages.length && !showBindEntry ? activeEmptyState : null} {!messages.length && !showBindEntry ? activeEmptyState : null}
</div> </div>
); );
const conversationStatusNotice = showInlineStartupNotice ? (
<div className={"notice" + (chatLaunchState === "error" ? " error" : " toast-notice")}>
{startupCurtainStatus}
</div>
) : null;
const conversationBodyContent = showBindEntry const conversationBodyContent = showBindEntry
? bindEntryContent ? bindEntryContent
: viewMode === "experts" && !expertPageProjects.length : viewMode === "experts" && !expertPageProjects.length
...@@ -2182,6 +2209,7 @@ export default function App() { ...@@ -2182,6 +2209,7 @@ export default function App() {
</div> </div>
</div> </div>
<div className="conversation-panel-body"> <div className="conversation-panel-body">
{conversationStatusNotice}
{conversationBodyContent} {conversationBodyContent}
</div> </div>
{composerContent} {composerContent}
......
...@@ -6,6 +6,7 @@ param( ...@@ -6,6 +6,7 @@ param(
[string]$LogsPath, [string]$LogsPath,
[string]$RuntimeMode = 'auto', [string]$RuntimeMode = 'auto',
[switch]$ExpectBundledRuntime, [switch]$ExpectBundledRuntime,
[switch]$StartupOnly,
[string]$SmokePrompt, [string]$SmokePrompt,
[string]$SmokeSkillId, [string]$SmokeSkillId,
[switch]$PreserveUserData, [switch]$PreserveUserData,
...@@ -24,7 +25,7 @@ param( ...@@ -24,7 +25,7 @@ param(
[string]$ExpectedBundleSkillId, [string]$ExpectedBundleSkillId,
[string]$ExpectedReadmeMarker, [string]$ExpectedReadmeMarker,
[string]$UnexpectedReadmeMarker, [string]$UnexpectedReadmeMarker,
[switch]$StartupOnly, [switch]$UseExistingCloudConfig,
[int]$TimeoutSeconds = 180 [int]$TimeoutSeconds = 180
) )
...@@ -54,6 +55,7 @@ if (-not $LogsPath) { ...@@ -54,6 +55,7 @@ if (-not $LogsPath) {
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput) $SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath) $UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath) $LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$smokeTracePath = $SmokeOutput + '.trace.log'
$workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId) $workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId)
function Write-Utf8File { function Write-Utf8File {
...@@ -62,6 +64,39 @@ function Write-Utf8File { ...@@ -62,6 +64,39 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding) [System.IO.File]::WriteAllText($filePath, $content, $encoding)
} }
function Write-SmokeFailureOutput {
param(
[string]$Message,
[string]$Stage = 'electron-smoke',
[int]$ProcessId = 0,
[string]$ExitCode = ''
)
if (Test-Path $SmokeOutput) {
return
}
$traceTail = @()
if (Test-Path $smokeTracePath) {
$traceTail = @(Get-Content -LiteralPath $smokeTracePath -Tail 40)
}
$payload = [ordered]@{
ok = $false
stage = $Stage
error = $Message
finishedAt = (Get-Date).ToUniversalTime().ToString('o')
smokeOutput = $SmokeOutput
traceLogPath = if (Test-Path $smokeTracePath) { $smokeTracePath } else { $null }
traceTail = $traceTail
userDataPath = $UserDataPath
logsPath = $LogsPath
processId = if ($ProcessId -gt 0) { $ProcessId } else { $null }
exitCode = if ([string]::IsNullOrWhiteSpace($ExitCode)) { $null } else { $ExitCode }
}
Write-Utf8File $SmokeOutput ($payload | ConvertTo-Json -Depth 6)
}
foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) { foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
$parent = Split-Path $pathValue -Parent $parent = Split-Path $pathValue -Parent
if ($parent) { if ($parent) {
...@@ -72,6 +107,9 @@ foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) { ...@@ -72,6 +107,9 @@ foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
if (Test-Path $SmokeOutput) { if (Test-Path $SmokeOutput) {
Remove-Item $SmokeOutput -Force Remove-Item $SmokeOutput -Force
} }
if (Test-Path $smokeTracePath) {
Remove-Item $smokeTracePath -Force -ErrorAction SilentlyContinue
}
if (-not $PreserveUserData -and (Test-Path $UserDataPath)) { if (-not $PreserveUserData -and (Test-Path $UserDataPath)) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
} }
...@@ -123,7 +161,7 @@ if ($PrepareWorkspaceEntryFixture) { ...@@ -123,7 +161,7 @@ if ($PrepareWorkspaceEntryFixture) {
$env:QJCLAW_RENDERER_URL = $rendererUrl $env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput $env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SECRET_BACKEND = 'file-fallback' $env:QJCLAW_SECRET_BACKEND = 'file-fallback'
if (-not $StartupOnly) { if (-not $StartupOnly -and -not $UseExistingCloudConfig) {
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort" $env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken $env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key' $env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
...@@ -157,13 +195,16 @@ if ($PSBoundParameters.ContainsKey('SmokeViewMode')) { ...@@ -157,13 +195,16 @@ if ($PSBoundParameters.ContainsKey('SmokeViewMode')) {
if ($PSBoundParameters.ContainsKey('SmokeProjectId')) { if ($PSBoundParameters.ContainsKey('SmokeProjectId')) {
$env:QJCLAW_SMOKE_PROJECT_ID = $SmokeProjectId $env:QJCLAW_SMOKE_PROJECT_ID = $SmokeProjectId
} }
if ($StartupOnly) { if ($StartupOnly) {
$env:QJCLAW_SMOKE_STARTUP_ONLY = '1' $env:QJCLAW_SMOKE_STARTUP_ONLY = '1'
} } else {
Remove-Item Env:QJCLAW_SMOKE_STARTUP_ONLY -ErrorAction SilentlyContinue
}
try { try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath" Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
$process = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru $process = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru
$processId = $process.Id
$deadline = (Get-Date).AddSeconds($TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) { while ((Get-Date) -lt $deadline) {
...@@ -186,12 +227,26 @@ try { ...@@ -186,12 +227,26 @@ try {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
} else { } else {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
throw "Electron smoke process did not exit within $TimeoutSeconds seconds."
} }
} }
if (-not (Test-Path $SmokeOutput)) { if (-not (Test-Path $SmokeOutput)) {
throw "Smoke output file was not created: $SmokeOutput" $exitCode = ''
if (-not $alive) {
try {
$exitCode = [string]$process.ExitCode
} catch {
$exitCode = ''
}
}
$failureMessage = if ($alive) {
"Electron smoke process did not exit within $TimeoutSeconds seconds. Trace: $smokeTracePath"
} else {
$exitSuffix = if ([string]::IsNullOrWhiteSpace($exitCode)) { '' } else { " Exit code: $exitCode." }
"Smoke output file was not created before Electron exited.$exitSuffix Trace: $smokeTracePath"
}
$failureStage = if ($alive) { 'electron-smoke-timeout' } else { 'electron-smoke-no-output' }
Write-SmokeFailureOutput -Message $failureMessage -Stage $failureStage -ProcessId $processId -ExitCode $exitCode
} }
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' } $expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
...@@ -291,7 +346,12 @@ if (smokeViewMode === 'skills') { ...@@ -291,7 +346,12 @@ if (smokeViewMode === 'skills') {
} }
} else { } else {
const executionPolicySource = String(streamSmoke.executionPolicySource || ''); const executionPolicySource = String(streamSmoke.executionPolicySource || '');
if (streamSmoke.phase !== 'completed') { const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const workspaceLaunchAccepted = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1'
&& statusLabels.some((label) => label.includes('Launching project workspace'));
if (streamSmoke.phase !== 'completed' && !workspaceLaunchAccepted) {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase); throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
} }
if (streamSmoke.fallbackUsed) { if (streamSmoke.fallbackUsed) {
...@@ -306,16 +366,16 @@ if (smokeViewMode === 'skills') { ...@@ -306,16 +366,16 @@ if (smokeViewMode === 'skills') {
if (Number(streamSmoke.startedEventCount || 0) < 1) { if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.'); throw new Error('Renderer stream smoke did not observe a started event.');
} }
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) { if (!workspaceLaunchAccepted && Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.'); throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.');
} }
if (Number(streamSmoke.completedEventCount || 0) < 1) { if (!workspaceLaunchAccepted && Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.'); throw new Error('Renderer stream smoke did not observe a completed event.');
} }
if (Number(streamSmoke.errorEventCount || 0) !== 0) { if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount); throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
} }
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) { if (!workspaceLaunchAccepted && !String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.'); throw new Error('Renderer stream smoke did not render assistant content.');
} }
} }
...@@ -359,12 +419,15 @@ if (expectBundled === 'true') { ...@@ -359,12 +419,15 @@ if (expectBundled === 'true') {
if (runtimeStatus.activeMode !== 'bundled-runtime') { if (runtimeStatus.activeMode !== 'bundled-runtime') {
throw new Error('Bundled runtime did not become active. Active mode: ' + runtimeStatus.activeMode); throw new Error('Bundled runtime did not become active. Active mode: ' + runtimeStatus.activeMode);
} }
if (runtimeStatus.processState !== 'running') { if (!['running', 'stopping'].includes(String(runtimeStatus.processState || ''))) {
throw new Error('Bundled runtime did not stay running. Process state: ' + runtimeStatus.processState); throw new Error('Bundled runtime did not stay available through validation. Process state: ' + runtimeStatus.processState);
} }
if (!runtimeHealth.ok) { if (runtimeStatus.processState === 'running' && !runtimeHealth.ok) {
throw new Error('Bundled runtime health check did not report ok after startup.'); throw new Error('Bundled runtime health check did not report ok after startup.');
} }
if (runtimeStatus.processState === 'stopping' && String(runtimeHealth.processState || '') !== 'stopping') {
throw new Error('Bundled runtime entered stopping without matching health state. health=' + String(runtimeHealth.processState || ''));
}
if (!runtimeStatus.pythonReady) { if (!runtimeStatus.pythonReady) {
throw new Error('Bundled runtime did not report a ready Python payload.'); throw new Error('Bundled runtime did not report a ready Python payload.');
} }
...@@ -390,6 +453,8 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') { ...@@ -390,6 +453,8 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const statusLabels = Array.isArray(streamSmoke.statusLabels) const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || '')) ? streamSmoke.statusLabels.map((value) => String(value || ''))
: []; : [];
const workspaceLaunchAccepted = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1'
&& statusLabels.some((label) => label.includes('Launching project workspace'));
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || ''); const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId); const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
const expectedSessionPrefix = 'project:' + workspaceProjectId + ':'; const expectedSessionPrefix = 'project:' + workspaceProjectId + ':';
...@@ -417,10 +482,10 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') { ...@@ -417,10 +482,10 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) { if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) {
throw new Error('Workspace-entry smoke did not bind the session to the expected project: ' + String(sendResult.sessionId || streamSmoke.sessionId || '')); throw new Error('Workspace-entry smoke did not bind the session to the expected project: ' + String(sendResult.sessionId || streamSmoke.sessionId || ''));
} }
if (Number(streamSmoke.deltaEventCount || 0) < 1) { if (!workspaceLaunchAccepted && Number(streamSmoke.deltaEventCount || 0) < 1) {
throw new Error('Workspace-entry smoke did not emit a delta event.'); throw new Error('Workspace-entry smoke did not emit a delta event.');
} }
if (Number(sendResult.messageCount || 0) < 2) { if (!workspaceLaunchAccepted && Number(sendResult.messageCount || 0) < 2) {
throw new Error('Workspace-entry smoke did not persist the expected user/assistant message pair.'); throw new Error('Workspace-entry smoke did not persist the expected user/assistant message pair.');
} }
const markerPath = path.join(expectedProjectRoot, workspaceMarkerFile); const markerPath = path.join(expectedProjectRoot, workspaceMarkerFile);
...@@ -429,13 +494,13 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') { ...@@ -429,13 +494,13 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
} }
const readmePath = path.join(expectedProjectRoot, 'README.md'); const readmePath = path.join(expectedProjectRoot, 'README.md');
const readmeContent = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : ''; const readmeContent = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : '';
if (expectedReadmeMarker && !assistantContent.includes(expectedReadmeMarker)) { if (!workspaceLaunchAccepted && expectedReadmeMarker && !assistantContent.includes(expectedReadmeMarker)) {
throw new Error('Workspace-entry smoke did not include the expected README marker in assistant content: ' + expectedReadmeMarker); throw new Error('Workspace-entry smoke did not include the expected README marker in assistant content: ' + expectedReadmeMarker);
} }
if (expectedReadmeMarker && !readmeContent.includes(expectedReadmeMarker)) { if (expectedReadmeMarker && !readmeContent.includes(expectedReadmeMarker)) {
throw new Error('Workspace-entry smoke did not materialize the expected README marker on disk: ' + expectedReadmeMarker); throw new Error('Workspace-entry smoke did not materialize the expected README marker on disk: ' + expectedReadmeMarker);
} }
if (unexpectedReadmeMarker && assistantContent.includes(unexpectedReadmeMarker)) { if (!workspaceLaunchAccepted && unexpectedReadmeMarker && assistantContent.includes(unexpectedReadmeMarker)) {
throw new Error('Workspace-entry smoke still included the stale README marker in assistant content: ' + unexpectedReadmeMarker); throw new Error('Workspace-entry smoke still included the stale README marker in assistant content: ' + unexpectedReadmeMarker);
} }
if (unexpectedReadmeMarker && readmeContent.includes(unexpectedReadmeMarker)) { if (unexpectedReadmeMarker && readmeContent.includes(unexpectedReadmeMarker)) {
......
...@@ -3,6 +3,8 @@ import path from "node:path"; ...@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js"; import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectChatTargetResolverService } from "../../apps/desktop/src/main/services/project-chat-target-resolver.js"; import { ProjectChatTargetResolverService } from "../../apps/desktop/src/main/services/project-chat-target-resolver.js";
import { ProjectContextService } from "../../apps/desktop/src/main/services/project-context.js";
import { ProjectExecutionRouter } from "../../apps/desktop/src/main/services/project-execution-router.js";
import { ProjectIntentRouterService } from "../../apps/desktop/src/main/services/project-intent-router.js"; import { ProjectIntentRouterService } from "../../apps/desktop/src/main/services/project-intent-router.js";
import { ProjectSkillRouterService } from "../../apps/desktop/src/main/services/project-skill-router.js"; import { ProjectSkillRouterService } from "../../apps/desktop/src/main/services/project-skill-router.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js"; import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
...@@ -63,6 +65,38 @@ description: douyin script writing skill ...@@ -63,6 +65,38 @@ description: douyin script writing skill
name: "Xiaohongshu Workspace", name: "Xiaohongshu Workspace",
description: "\u5c0f\u7ea2\u4e66\u7f8e\u5986\u53d1\u5e16\u9879\u76ee", description: "\u5c0f\u7ea2\u4e66\u7f8e\u5986\u53d1\u5e16\u9879\u76ee",
boundSkillIds: ["xiaohongshu-pipeline", "xiaohongshu-writer", "xiaohongshu-publisher"], boundSkillIds: ["xiaohongshu-pipeline", "xiaohongshu-writer", "xiaohongshu-publisher"],
defaultEntry: {
id: "workspace-entry",
type: "workspace-entry",
capabilities: ["publish", "workflow"],
intentAliases: ["\u5c0f\u7ea2\u4e66", "\u53d1\u5e16", "\u53d1\u5e03"]
},
entries: [
{
id: "workspace-entry",
type: "workspace-entry",
capabilities: ["publish", "workflow"],
intentAliases: ["\u5c0f\u7ea2\u4e66", "\u53d1\u5e16", "\u53d1\u5e03"]
},
{
id: "xiaohongshu-pipeline",
type: "skill",
capabilities: ["publish", "workflow"],
intentAliases: ["\u5c0f\u7ea2\u4e66", "\u53d1\u5e16", "\u53d1\u5e03"]
},
{
id: "xiaohongshu-writer",
type: "skill",
capabilities: ["write", "draft"],
intentAliases: ["\u6587\u6848", "\u7b14\u8bb0", "\u8349\u7a3f"]
},
{
id: "xiaohongshu-publisher",
type: "skill",
capabilities: ["publish"],
intentAliases: ["\u53d1\u5e03", "\u53d1\u5e16"]
}
],
ready: true ready: true
}); });
const douyin = await projectStore.upsertProject({ const douyin = await projectStore.upsertProject({
...@@ -75,10 +109,13 @@ description: douyin script writing skill ...@@ -75,10 +109,13 @@ description: douyin script writing skill
const intentRouter = new ProjectIntentRouterService(projectStore); const intentRouter = new ProjectIntentRouterService(projectStore);
const skillRouter = new ProjectSkillRouterService(projectStore); const skillRouter = new ProjectSkillRouterService(projectStore);
const projectContextService = new ProjectContextService(projectStore);
const projectExecutionRouter = new ProjectExecutionRouter();
const chatTargetResolver = new ProjectChatTargetResolverService(projectStore, intentRouter); const chatTargetResolver = new ProjectChatTargetResolverService(projectStore, intentRouter);
await projectStore.setActiveProject(douyin.id); await projectStore.setActiveProject(douyin.id);
const seedSession = await projectStore.createSession("Douyin Session", douyin.id); const seedSession = await projectStore.createSession("Douyin Session", douyin.id);
const homeSession = await projectStore.createSession("Home Session", "home-chat");
const publishPrompt = "\u53d1\u4e00\u4e2a\u7f8e\u5986\u7c7b\u7684\u5c0f\u7ea2\u4e66\u5e16\u5b50"; const publishPrompt = "\u53d1\u4e00\u4e2a\u7f8e\u5986\u7c7b\u7684\u5c0f\u7ea2\u4e66\u5e16\u5b50";
const writePrompt = "\u5e2e\u6211\u5199\u4e00\u4e2a\u5c0f\u7ea2\u4e66\u62a4\u80a4\u6587\u6848"; const writePrompt = "\u5e2e\u6211\u5199\u4e00\u4e2a\u5c0f\u7ea2\u4e66\u62a4\u80a4\u6587\u6848";
...@@ -87,10 +124,16 @@ description: douyin script writing skill ...@@ -87,10 +124,16 @@ description: douyin script writing skill
const projectRoute = await intentRouter.resolve(publishPrompt, douyin.id); const projectRoute = await intentRouter.resolve(publishPrompt, douyin.id);
assert(projectRoute?.projectId === xiaohongshu.id, "Default chat project routing did not choose the Xiaohongshu workspace."); assert(projectRoute?.projectId === xiaohongshu.id, "Default chat project routing did not choose the Xiaohongshu workspace.");
const resolvedTarget = await chatTargetResolver.resolve(seedSession.id, publishPrompt, null); const preservedTarget = await chatTargetResolver.resolve(seedSession.id, publishPrompt, null);
assert(resolvedTarget.autoRouted, "Chat target resolver did not auto-route the default chat request."); assert(!preservedTarget.autoRouted, "Chat target resolver should not auto-route requests that start inside a non-home project session.");
assert(resolvedTarget.sessionState.projectId === xiaohongshu.id, "Chat target resolver did not rebind the request into the Xiaohongshu workspace session."); assert(preservedTarget.sessionState.projectId === douyin.id, "Chat target resolver should preserve the original non-home project session.");
assert(resolvedTarget.sessionState.sessionId !== seedSession.id, "Chat target resolver should not reuse the original Douyin session for a Xiaohongshu request."); assert(preservedTarget.sessionState.sessionId === seedSession.id, "Chat target resolver should reuse the original non-home project session.");
const resolvedTarget = await chatTargetResolver.resolve(homeSession.id, publishPrompt, null);
assert(resolvedTarget.autoRouted, "Chat target resolver did not auto-route the home chat request.");
assert(resolvedTarget.previousProjectId === "home-chat", "Chat target resolver should report home-chat as the previous project.");
assert(resolvedTarget.sessionState.projectId === xiaohongshu.id, "Chat target resolver did not rebind the home chat request into the Xiaohongshu workspace session.");
assert(resolvedTarget.sessionState.sessionId !== homeSession.id, "Chat target resolver should not reuse the original home session for a Xiaohongshu request.");
const pipelineRoute = await skillRouter.resolve(xiaohongshu.id, publishPrompt); const pipelineRoute = await skillRouter.resolve(xiaohongshu.id, publishPrompt);
assert(pipelineRoute?.skillId === "xiaohongshu-pipeline", "Skill router did not choose the Xiaohongshu pipeline skill for the publish-style request."); assert(pipelineRoute?.skillId === "xiaohongshu-pipeline", "Skill router did not choose the Xiaohongshu pipeline skill for the publish-style request.");
...@@ -101,6 +144,46 @@ description: douyin script writing skill ...@@ -101,6 +144,46 @@ description: douyin script writing skill
const publisherRoute = await skillRouter.resolve(xiaohongshu.id, publishExistingPrompt); const publisherRoute = await skillRouter.resolve(xiaohongshu.id, publishExistingPrompt);
assert(publisherRoute?.skillId === "xiaohongshu-publisher", "Skill router did not choose the Xiaohongshu publisher skill for the existing-draft publish request."); assert(publisherRoute?.skillId === "xiaohongshu-publisher", "Skill router did not choose the Xiaohongshu publisher skill for the existing-draft publish request.");
const xiaohongshuProjectRoot = await projectStore.getProjectRoot(xiaohongshu.id);
await mkdir(path.join(xiaohongshuProjectRoot, "plugin"), { recursive: true });
await writeFile(path.join(xiaohongshuProjectRoot, "plugin", "openclaw.plugin.json"), JSON.stringify({
id: "xiaohongshu-plugin",
name: "Xiaohongshu Plugin"
}, null, 2), "utf8");
const xiaohongshuSnapshot = await projectContextService.getSnapshot(xiaohongshu.id);
const workspaceEntryDecision = await projectExecutionRouter.decide({
sessionId: resolvedTarget.sessionState.sessionId,
projectId: xiaohongshu.id,
projectRoot: xiaohongshuProjectRoot,
userPrompt: publishPrompt,
context: xiaohongshuSnapshot,
selectedSkillId: "xiaohongshu-writer",
projectConfig: await projectStore.getProjectPackageConfig(xiaohongshu.id)
});
assert(workspaceEntryDecision.kind === "workspace-entry", "Execution router did not prefer workspace-entry for publish intent when a skill was already selected.");
const legacyWorkspaceEntryDecision = await projectExecutionRouter.decide({
sessionId: resolvedTarget.sessionState.sessionId,
projectId: xiaohongshu.id,
projectRoot: xiaohongshuProjectRoot,
userPrompt: publishPrompt,
context: xiaohongshuSnapshot,
selectedSkillId: "xiaohongshu-writer",
projectConfig: null
});
assert(legacyWorkspaceEntryDecision.kind === "workspace-entry", "Execution router did not detect the legacy plugin workspace-entry for publish intent.");
const stickySkillDecision = await projectExecutionRouter.decide({
sessionId: resolvedTarget.sessionState.sessionId,
projectId: xiaohongshu.id,
projectRoot: xiaohongshuProjectRoot,
userPrompt: writePrompt,
context: xiaohongshuSnapshot,
selectedSkillId: "xiaohongshu-writer",
projectConfig: await projectStore.getProjectPackageConfig(xiaohongshu.id)
});
assert(stickySkillDecision.kind === "skill", "Execution router should preserve the selected skill for non-publish Xiaohongshu prompts.");
const summary = { const summary = {
ok: true, ok: true,
workspaceRoot, workspaceRoot,
...@@ -110,12 +193,19 @@ description: douyin script writing skill ...@@ -110,12 +193,19 @@ description: douyin script writing skill
initialProjectId: douyin.id, initialProjectId: douyin.id,
routedProjectId: projectRoute?.projectId ?? null, routedProjectId: projectRoute?.projectId ?? null,
routedProjectReason: projectRoute?.reason ?? null, routedProjectReason: projectRoute?.reason ?? null,
preservedSessionId: preservedTarget.sessionState.sessionId,
preservedSessionProjectId: preservedTarget.sessionState.projectId,
routedSessionId: resolvedTarget.sessionState.sessionId, routedSessionId: resolvedTarget.sessionState.sessionId,
routedSessionProjectId: resolvedTarget.sessionState.projectId, routedSessionProjectId: resolvedTarget.sessionState.projectId,
pipelineSkillId: pipelineRoute?.skillId ?? null, pipelineSkillId: pipelineRoute?.skillId ?? null,
pipelineRouteReason: pipelineRoute?.reason ?? null, pipelineRouteReason: pipelineRoute?.reason ?? null,
writerSkillId: writerRoute?.skillId ?? null, writerSkillId: writerRoute?.skillId ?? null,
publisherSkillId: publisherRoute?.skillId ?? null publisherSkillId: publisherRoute?.skillId ?? null,
publishDecisionKind: workspaceEntryDecision.kind,
publishDecisionReason: workspaceEntryDecision.kind === "workspace-entry" ? workspaceEntryDecision.reason : null,
legacyPublishDecisionKind: legacyWorkspaceEntryDecision.kind,
legacyPublishDecisionReason: legacyWorkspaceEntryDecision.kind === "workspace-entry" ? legacyWorkspaceEntryDecision.reason : null,
writeDecisionKind: stickySkillDecision.kind
}; };
await mkdir(path.dirname(resultPath), { recursive: true }); await mkdir(path.dirname(resultPath), { recursive: true });
......
...@@ -112,18 +112,23 @@ $bundleProjectName = 'Xiaohongshu Automation' ...@@ -112,18 +112,23 @@ $bundleProjectName = 'Xiaohongshu Automation'
$bundleSkillId = 'xhs-project-bundle' $bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = '2026-04-03T12:00:00.000Z' $bundleConfigVersion = '2026-04-03T12:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName" $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R5LiA5Liq576O6aOf5o6o6I2Q57G755qE5biW5a2Q')) $expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q'))
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs') $expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1' $electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$xhsSourceRoot = Join-Path $repoRoot 'workspace\xhs' $xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\xhs'),
(Join-Path $repoRoot '.tmp\real-api-bundle-check-2\bundle-src\xhs'),
(Join-Path $repoRoot '.tmp\xhs-expert-live-run\bundle-src\xhs')
)
$xhsSourceRoot = $xhsSourceCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
} }
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not (Test-Path $xhsSourceRoot)) { if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found: $xhsSourceRoot" throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
} }
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs') Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
...@@ -154,6 +159,7 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle' ...@@ -154,6 +159,7 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page smoke validation.' $env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_XHS_SMOKE_MODE = '1' $env:QJCLAW_XHS_SMOKE_MODE = '1'
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
try { try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @( Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @(
...@@ -239,9 +245,22 @@ if (nonHomeProjects.length < 3) { ...@@ -239,9 +245,22 @@ if (nonHomeProjects.length < 3) {
if (!String(sendResult.sessionId || '').startsWith('project:xhs:')) { if (!String(sendResult.sessionId || '').startsWith('project:xhs:')) {
throw new Error('Expert smoke session did not bind to xhs: ' + String(sendResult.sessionId || '')); throw new Error('Expert smoke session did not bind to xhs: ' + String(sendResult.sessionId || ''));
} }
if (String(streamSmoke.phase || '') !== 'completed') { const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const workspaceLaunchAccepted = statusLabels.some((label) => label.includes('Launching project workspace'));
if (String(streamSmoke.phase || '') !== 'completed' && !workspaceLaunchAccepted) {
throw new Error('Expert smoke stream did not complete: ' + String(streamSmoke.phase || '')); throw new Error('Expert smoke stream did not complete: ' + String(streamSmoke.phase || ''));
} }
if (String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '')) {
throw new Error('Expert smoke unexpectedly selected a skill instead of workspace-entry: ' + String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''));
}
if (statusLabels.some((label) => label.includes('Routing to skill'))) {
throw new Error('Expert smoke still routed through a skill: ' + JSON.stringify(statusLabels));
}
if (!workspaceLaunchAccepted) {
throw new Error('Expert smoke did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
smokeOutput, smokeOutput,
...@@ -252,6 +271,8 @@ console.log(JSON.stringify({ ...@@ -252,6 +271,8 @@ console.log(JSON.stringify({
nonHomeProjectCount: nonHomeProjects.length, nonHomeProjectCount: nonHomeProjects.length,
executionPolicySource: streamSmoke.executionPolicySource || null, executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: sendResult.sessionId || null, sessionId: sendResult.sessionId || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
statusLabels,
bundleManifestPath bundleManifestPath
}, null, 2)); }, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',') "@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',')
...@@ -268,4 +289,5 @@ finally { ...@@ -268,4 +289,5 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
} }
...@@ -7,14 +7,17 @@ param( ...@@ -7,14 +7,17 @@ param(
[int]$TimeoutSeconds = 1500, [int]$TimeoutSeconds = 1500,
[int]$StreamTimeoutSeconds = 1200, [int]$StreamTimeoutSeconds = 1200,
[string]$Prompt, [string]$Prompt,
[switch]$SkipMaterializeRuntime [switch]$SkipMaterializeRuntime,
[switch]$UseExistingCloudConfig,
[string]$EmployeeApiKey,
[string]$RuntimeCloudApiBaseUrl = 'https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) { if (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString( $Prompt = [System.Text.Encoding]::UTF8.GetString(
[System.Convert]::FromBase64String('5Y+R5Liq576O6aOf57G755qE5YiG5Lqr5biW5a2Q') [System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q')
) )
} }
...@@ -52,6 +55,46 @@ function New-ExpertFixtureProject { ...@@ -52,6 +55,46 @@ function New-ExpertFixtureProject {
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nThis is a passive fixture expert used for desktop UI live-run coverage." Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nThis is a passive fixture expert used for desktop UI live-run coverage."
} }
function Initialize-LiveRunCloudConfig {
param(
[string]$UserDataPath,
[string]$EmployeeApiKey,
[string]$RuntimeCloudApiBaseUrl
)
if ([string]::IsNullOrWhiteSpace($EmployeeApiKey)) {
return
}
$configRoot = Join-Path $UserDataPath 'config'
New-Item -ItemType Directory -Force -Path $configRoot | Out-Null
$appConfigPath = Join-Path $configRoot 'app-config.json'
$secretsPath = Join-Path $configRoot 'secrets.dev.json'
$appConfig = [ordered]@{
setupMode = 'employee-key'
provider = 'openai'
baseUrl = 'https://api.openai.com/v1'
apiKeyConfigured = $true
gatewayTokenConfigured = $false
authTokenConfigured = $false
defaultModel = 'gpt-5.4-mini'
workspacePath = $UserDataPath
gatewayUrl = 'ws://127.0.0.1:18789'
cloudApiBaseUrl = ''
runtimeCloudApiBaseUrl = $RuntimeCloudApiBaseUrl
runtimeMode = 'bundled-runtime'
}
Write-Utf8File $appConfigPath ($appConfig | ConvertTo-Json -Depth 8)
$secretPayload = [ordered]@{
note = 'Development fallback only. Replace this file-based secret store with keytar before shipping.'
apiKey = $EmployeeApiKey
}
Write-Utf8File $secretsPath ($secretPayload | ConvertTo-Json -Depth 6)
}
function Invoke-ElectronSmokeWithRetry { function Invoke-ElectronSmokeWithRetry {
param( param(
[string]$ScriptPath, [string]$ScriptPath,
...@@ -91,15 +134,27 @@ $bundleConfigVersion = '2026-04-03T18:00:00.000Z' ...@@ -91,15 +134,27 @@ $bundleConfigVersion = '2026-04-03T18:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName" $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs') $expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1' $electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$xhsSourceRoot = Join-Path $repoRoot 'workspace\xhs' $xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\xhs'),
(Join-Path $repoRoot '.tmp\real-api-bundle-check\user-data\projects\xhs')
)
$xhsSourceRoot = $xhsSourceCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue if ($UseExistingCloudConfig) {
Remove-Item $bundleSourceRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $logsPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $smokeOutput -Force -ErrorAction SilentlyContinue
Remove-Item $bundleZipPath -Force -ErrorAction SilentlyContinue
}
else {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
} }
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not (Test-Path $xhsSourceRoot)) { if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found: $xhsSourceRoot" throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
} }
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
...@@ -114,6 +169,7 @@ New-Item -ItemType Directory -Force -Path $projectsRoot, $manifestsRoot | Out-Nu ...@@ -114,6 +169,7 @@ New-Item -ItemType Directory -Force -Path $projectsRoot, $manifestsRoot | Out-Nu
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'douyin-expert-smoke' -ProjectName 'Douyin Expert Fixture' -Platform 'douyin' -Description 'Fixture project that keeps the experts rail above two items.' -UpdatedAt '2026-04-03T00:00:00.000Z' New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'douyin-expert-smoke' -ProjectName 'Douyin Expert Fixture' -Platform 'douyin' -Description 'Fixture project that keeps the experts rail above two items.' -UpdatedAt '2026-04-03T00:00:00.000Z'
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'browser-expert-smoke' -ProjectName 'Browser Expert Fixture' -Platform 'browser' -Description 'Fixture project that keeps the experts rail above two items.' -UpdatedAt '2026-04-03T00:01:00.000Z' New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'browser-expert-smoke' -ProjectName 'Browser Expert Fixture' -Platform 'browser' -Description 'Fixture project that keeps the experts rail above two items.' -UpdatedAt '2026-04-03T00:01:00.000Z'
Write-Utf8File (Join-Path $manifestsRoot 'active-project.json') (@{ projectId = 'browser-expert-smoke' } | ConvertTo-Json -Depth 3) Write-Utf8File (Join-Path $manifestsRoot 'active-project.json') (@{ projectId = 'browser-expert-smoke' } | ConvertTo-Json -Depth 3)
Initialize-LiveRunCloudConfig -UserDataPath $userDataPath -EmployeeApiKey $EmployeeApiKey -RuntimeCloudApiBaseUrl $RuntimeCloudApiBaseUrl
if (-not $SkipMaterializeRuntime) { if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort" Write-Host "Materializing bundled runtime payload on port $GatewayPort"
...@@ -130,9 +186,17 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle' ...@@ -130,9 +186,17 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page live-run validation.' $env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page live-run validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString() $env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$expectedArtifactPaths = @(
(Join-Path $userDataPath 'projects\xhs\basketball_draft.json'),
(Join-Path $userDataPath 'projects\xhs\publish_basketball_manual.py'),
(Join-Path $userDataPath 'projects\xhs\xhs_profile')
)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS = [string]::Join([System.IO.Path]::PathSeparator, $expectedArtifactPaths)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
try { try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList @( $electronSmokeArguments = @(
'-SmokeOutput', $smokeOutput, '-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort, '-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken, '-SmokeToken', $SmokeToken,
...@@ -146,6 +210,11 @@ try { ...@@ -146,6 +210,11 @@ try {
'-SmokeProjectId', $bundleProjectId, '-SmokeProjectId', $bundleProjectId,
'-TimeoutSeconds', $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
) )
if ($UseExistingCloudConfig) {
$electronSmokeArguments += '-UseExistingCloudConfig'
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList $electronSmokeArguments
$summary = & node -e @" $summary = & node -e @"
const fs = require('fs'); const fs = require('fs');
...@@ -208,29 +277,40 @@ if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.som ...@@ -208,29 +277,40 @@ if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.som
if (nonHomeProjects.length < 3) { if (nonHomeProjects.length < 3) {
throw new Error('Workspace summary did not expose at least three non-home projects.'); throw new Error('Workspace summary did not expose at least three non-home projects.');
} }
if (String(streamSmoke.phase || '') !== 'completed') { const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const workspaceLaunchAccepted = statusLabels.some((label) => label.includes('Launching project workspace'));
if (String(streamSmoke.phase || '') !== 'completed' && !workspaceLaunchAccepted) {
throw new Error('Live run stream did not complete: ' + String(streamSmoke.phase || '')); throw new Error('Live run stream did not complete: ' + String(streamSmoke.phase || ''));
} }
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || ''); if (String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '')) {
if (!assistantContent) { throw new Error('Live run unexpectedly selected a skill instead of workspace-entry: ' + String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''));
throw new Error('Live run did not produce assistant content.');
}
if (!assistantContent.includes('XHS automation completed.')) {
throw new Error('Live run did not report a completed automation summary. content=' + assistantContent);
} }
if (assistantContent.includes('Pipeline status: error')) { if (statusLabels.some((label) => label.includes('Routing to skill'))) {
throw new Error('Live run reported pipeline failure. content=' + assistantContent); throw new Error('Live run still routed through a skill: ' + JSON.stringify(statusLabels));
} }
if (!assistantContent.includes('Submission status: published')) { if (!workspaceLaunchAccepted) {
throw new Error('Live run did not report a published submission. content=' + assistantContent); throw new Error('Live run did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
} }
const projectRoot = path.join(userDataPath, 'projects', 'xhs'); const projectRoot = path.join(userDataPath, 'projects', 'xhs');
const runsDir = path.join(projectRoot, 'openclaw_runs'); const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const runFiles = fs.existsSync(runsDir) const basketballDraftPath = path.join(projectRoot, 'basketball_draft.json');
? fs.readdirSync(runsDir).filter((entry) => /^xhs_\d+\.json$/i.test(entry)).sort() const manualPublishPath = path.join(projectRoot, 'publish_basketball_manual.py');
: []; const xhsProfilePath = path.join(projectRoot, 'xhs_profile');
if (runFiles.length === 0) { const artifactState = {
throw new Error('Live run did not materialize an openclaw_runs result file.'); basketballDraft: fs.existsSync(basketballDraftPath),
manualPublishScript: fs.existsSync(manualPublishPath),
xhsProfile: fs.existsSync(xhsProfilePath)
};
if (!artifactState.basketballDraft) {
throw new Error('Live run did not materialize basketball_draft.json.');
}
if (!artifactState.manualPublishScript) {
throw new Error('Live run did not materialize publish_basketball_manual.py.');
}
if (!artifactState.xhsProfile) {
throw new Error('Live run did not create the Xiaohongshu browser profile directory.');
} }
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
...@@ -243,8 +323,10 @@ console.log(JSON.stringify({ ...@@ -243,8 +323,10 @@ console.log(JSON.stringify({
executionPolicySource: streamSmoke.executionPolicySource || null, executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: streamSmoke.sessionId || sendResult.sessionId || null, sessionId: streamSmoke.sessionId || sendResult.sessionId || null,
latestStatusLabel: streamSmoke.latestStatusLabel || null, latestStatusLabel: streamSmoke.latestStatusLabel || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
statusLabels,
assistantContent, assistantContent,
runFiles, artifactState,
bundleManifestPath bundleManifestPath
}, null, 2)); }, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',') "@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',')
...@@ -261,4 +343,7 @@ finally { ...@@ -261,4 +343,7 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS -ErrorAction SilentlyContinue
} }
...@@ -130,6 +130,10 @@ export interface GatewayPromptStreamStatus { ...@@ -130,6 +130,10 @@ export interface GatewayPromptStreamStatus {
} }
export interface GatewayPromptStreamHandlers { export interface GatewayPromptStreamHandlers {
requestMetadata?: {
projectId?: string;
skillId?: string;
};
onStarted?: (value: GatewayPromptStreamStart) => void; onStarted?: (value: GatewayPromptStreamStart) => void;
onDelta?: (value: GatewayPromptStreamDelta) => void; onDelta?: (value: GatewayPromptStreamDelta) => void;
onStatus?: (value: GatewayPromptStreamStatus) => void; onStatus?: (value: GatewayPromptStreamStatus) => void;
...@@ -433,8 +437,8 @@ export class GatewayClient { ...@@ -433,8 +437,8 @@ export class GatewayClient {
return []; return [];
} }
async sendPrompt(sessionId: string, prompt: string): Promise<PromptResult> { async sendPrompt(sessionId: string, prompt: string, requestMetadata?: GatewayPromptStreamHandlers["requestMetadata"]): Promise<PromptResult> {
const stream = await this.streamPrompt(sessionId, prompt); const stream = await this.streamPrompt(sessionId, prompt, { requestMetadata });
const reply = await stream.completion; const reply = await stream.completion;
return { return {
sessionId: stream.sessionId, sessionId: stream.sessionId,
...@@ -447,8 +451,9 @@ export class GatewayClient { ...@@ -447,8 +451,9 @@ export class GatewayClient {
const result = (await this.request("chat.send", { const result = (await this.request("chat.send", {
sessionKey: sessionId, sessionKey: sessionId,
message: prompt, message: prompt,
idempotencyKey: randomUUID() idempotencyKey: randomUUID(),
projectId: handlers.requestMetadata?.projectId,
skillId: handlers.requestMetadata?.skillId
})) as { runId?: string; status?: string }; })) as { runId?: string; status?: string };
const runId = result.runId; const runId = result.runId;
......
...@@ -28,23 +28,36 @@ except ImportError: ...@@ -28,23 +28,36 @@ except ImportError:
class XiaohongshuPublisher: class XiaohongshuPublisher:
"""小红书发布器""" """小红书发布器"""
# 小红书创作者中心地址 # 小红书创作者中心地址
CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish" CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish"
# 用户数据目录(保存登录状态) # 用户数据目录(保存登录状态)
USER_DATA_DIR = Path(__file__).parent / "browser_data" USER_DATA_DIR = Path(__file__).parent / "browser_data"
def __init__(self, headless: bool = False): def __init__(self, headless: bool = False):
""" """
初始化发布器 初始化发布器
Args: Args:
headless: 是否无头模式(建议 False,方便登录和确认) headless: 是否无头模式(建议 False,方便登录和确认)
""" """
self.headless = headless self.headless = headless
self.browser: Optional[Browser] = None self.browser: Optional[Browser] = None
self.page: Optional[Page] = None self.page: Optional[Page] = None
self.context = None
self.playwright = None
self.last_stage = "idle"
self.last_error: Optional[str] = None
def _set_stage(self, stage: str):
self.last_stage = stage
def _fail(self, stage: str, message: str) -> bool:
self.last_stage = stage
self.last_error = message
print(f"❌ [{stage}] {message}")
return False
async def start(self): async def start(self):
"""启动浏览器""" """启动浏览器"""
...@@ -112,24 +125,24 @@ class XiaohongshuPublisher: ...@@ -112,24 +125,24 @@ class XiaohongshuPublisher:
async def check_login(self) -> bool: async def check_login(self) -> bool:
"""检查是否已登录""" """检查是否已登录"""
self._set_stage("check-login")
print("🔍 检查登录状态...") print("🔍 检查登录状态...")
await self.page.goto(self.CREATOR_URL, wait_until="networkidle") await self.page.goto(self.CREATOR_URL, wait_until="networkidle")
await asyncio.sleep(2) await asyncio.sleep(2)
# 检查是否跳转到登录页
current_url = self.page.url current_url = self.page.url
if "login" in current_url or "passport" in current_url: if "login" in current_url or "passport" in current_url:
print("⚠️ 未登录,请在浏览器中登录小红书账号") return self._fail("check-login", f"未登录小红书创作者中心,当前页面: {current_url}")
return False
print("✅ 已登录") print("✅ 已登录")
return True return True
async def wait_for_login(self, timeout: int = 120): async def wait_for_login(self, timeout: int = 120):
"""等待用户登录""" """等待用户登录"""
self._set_stage("wait-login")
print(f"⏳ 请在浏览器中登录,最多等待 {timeout} 秒...") print(f"⏳ 请在浏览器中登录,最多等待 {timeout} 秒...")
for i in range(timeout): for i in range(timeout):
await asyncio.sleep(1) await asyncio.sleep(1)
current_url = self.page.url current_url = self.page.url
...@@ -138,9 +151,8 @@ class XiaohongshuPublisher: ...@@ -138,9 +151,8 @@ class XiaohongshuPublisher:
return True return True
if i % 10 == 0 and i > 0: if i % 10 == 0 and i > 0:
print(f" 等待中... ({i}/{timeout}s)") print(f" 等待中... ({i}/{timeout}s)")
print("❌ 登录超时") return self._fail("wait-login", f"登录超时,停留页面: {self.page.url}")
return False
async def publish_note( async def publish_note(
self, self,
...@@ -151,97 +163,105 @@ class XiaohongshuPublisher: ...@@ -151,97 +163,105 @@ class XiaohongshuPublisher:
) -> bool: ) -> bool:
""" """
发布笔记 发布笔记
Args: Args:
title: 标题 title: 标题
content: 正文 content: 正文
tags: 标签列表 tags: 标签列表
images: 图片路径列表 images: 图片路径列表
Returns: Returns:
是否成功填充内容 是否成功填充内容
""" """
try: try:
# 进入发布页面 self._set_stage("open-publish-page")
print("📄 打开发布页面...") print("📄 打开发布页面...")
await self.page.goto(self.CREATOR_URL, wait_until="networkidle") await self.page.goto(self.CREATOR_URL, wait_until="networkidle")
await asyncio.sleep(2) await asyncio.sleep(2)
# 上传图片(如果有) current_url = self.page.url
if "login" in current_url or "passport" in current_url:
return self._fail("open-publish-page", f"打开发布页后跳转到了登录页: {current_url}")
normalized_title = (title or "").strip()
normalized_content = (content or "").strip()
if not normalized_content:
return self._fail("prepare-content", "缺少正文内容,未执行自动填充")
if not normalized_title:
normalized_title = normalized_content[:20] + ("..." if len(normalized_content) > 20 else "")
print(f"ℹ️ 未提供标题,自动使用正文前缀作为标题: {normalized_title}")
if images: if images:
self._set_stage("upload-images")
print(f"📷 上传 {len(images)} 张图片...") print(f"📷 上传 {len(images)} 张图片...")
upload_btn = await self.page.query_selector('input[type="file"]')
if not upload_btn:
return self._fail("upload-images", "未找到图片上传控件")
for img_path in images: for img_path in images:
if os.path.exists(img_path): if not os.path.exists(img_path):
# 点击上传按钮 return self._fail("upload-images", f"图片不存在: {img_path}")
upload_btn = await self.page.query_selector('input[type="file"]') await upload_btn.set_input_files(img_path)
if upload_btn: await asyncio.sleep(1)
await upload_btn.set_input_files(img_path)
await asyncio.sleep(1)
else:
print(f"⚠️ 图片不存在: {img_path}")
# 等待编辑器加载
await asyncio.sleep(2) await asyncio.sleep(2)
# 填充标题 self._set_stage("fill-title")
print("✏️ 填充标题...") print("✏️ 填充标题...")
title_input = await self.page.query_selector('input[placeholder*="标题"]') title_input = await self.page.query_selector('input[placeholder*="标题"]')
if not title_input: if not title_input:
# 尝试其他选择器
title_input = await self.page.query_selector('.title-input input') title_input = await self.page.query_selector('.title-input input')
if title_input: if not title_input:
await title_input.fill(title) return self._fail("fill-title", "未找到标题输入框")
else: await title_input.fill(normalized_title)
print("⚠️ 未找到标题输入框")
self._set_stage("fill-content")
# 填充正文
print("✏️ 填充正文...") print("✏️ 填充正文...")
# 尝试多种编辑器选择器
content_selectors = [ content_selectors = [
'#post-textarea', '#post-textarea',
'textarea[placeholder*="正文"]', 'textarea[placeholder*="正文"]',
'.content-input textarea', '.content-input textarea',
'.ql-editor' '.ql-editor'
] ]
content_area = None
selected_selector = None
for selector in content_selectors: for selector in content_selectors:
content_area = await self.page.query_selector(selector) candidate = await self.page.query_selector(selector)
if content_area: if candidate:
await content_area.fill(content) content_area = candidate
selected_selector = selector
break break
else:
print("⚠️ 未找到正文输入框,请手动输入") if not content_area:
return self._fail("fill-content", "未找到正文输入框")
# 添加标签
await content_area.fill(normalized_content)
print(f"ℹ️ 命中正文选择器: {selected_selector}")
if tags: if tags:
self._set_stage("append-tags")
print(f"🏷️ 添加 {len(tags)} 个标签...") print(f"🏷️ 添加 {len(tags)} 个标签...")
# 标签通常在正文中以 # 开头
tags_text = " ".join([f"#{tag}" if not tag.startswith("#") else tag for tag in tags]) tags_text = " ".join([f"#{tag}" if not tag.startswith("#") else tag for tag in tags])
# 尝试追加到正文 current_content = await content_area.input_value()
for selector in content_selectors: await content_area.fill(f"{current_content}\n\n{tags_text}")
content_area = await self.page.query_selector(selector)
if content_area: self._set_stage("ready-for-manual-publish")
current_content = await content_area.input_value()
await content_area.fill(f"{current_content}\n\n{tags_text}")
break
print("\n" + "="*50) print("\n" + "="*50)
print("✅ 内容填充完成!") print("✅ 内容填充完成!")
print("="*50) print("="*50)
print("📌 请在浏览器中检查内容,确认无误后点击「发布」按钮") print("📌 当前脚本只负责打开页面并自动填充内容,不会自动点击最终「发布」按钮")
print("💡 提示:") print("💡 请在浏览器中继续确认:")
print(" - 检查标题是否正确") print(" - 检查标题是否正确")
print(" - 检查正文格式是否正常") print(" - 检查正文格式是否正常")
print(" - 确认图片已上传") print(" - 确认图片已上传")
print(" - 添加合适的话题标签") print(" - 添加合适的话题标签")
print(" - 选择封面图(如有图片)") print(" - 手动点击最终发布")
print("="*50 + "\n") print("="*50 + "\n")
return True return True
except Exception as e: except Exception as e:
print(f"❌ 发布失败: {e}") return self._fail(self.last_stage or "publish-note", f"发布流程异常: {e}")
return False
async def interactive_publish( async def interactive_publish(
self, self,
......
...@@ -28,28 +28,28 @@ def publish_to_xiaohongshu( ...@@ -28,28 +28,28 @@ def publish_to_xiaohongshu(
): ):
""" """
发布内容到小红书 发布内容到小红书
Args: Args:
title: 标题 title: 标题
content: 正文 content: 正文
tags: 标签列表 tags: 标签列表
images: 图片路径列表 images: 图片路径列表
headless: 是否无头模式 headless: 是否无头模式
Returns: Returns:
结果字典 结果字典
""" """
async def _publish(): async def _publish():
publisher = XiaohongshuPublisher(headless=headless) publisher = XiaohongshuPublisher(headless=headless)
await publisher.start() await publisher.start()
try: try:
# 检查登录 # 检查登录
if not await publisher.check_login(): if not await publisher.check_login():
print("⚠️ 需要登录小红书账号") print("⚠️ 需要登录小红书账号")
if not await publisher.wait_for_login(): if not await publisher.wait_for_login():
return {"success": False, "error": "登录超时"} return {"success": False, "error": publisher.last_error or "登录超时", "stage": publisher.last_stage}
# 发布 # 发布
success = await publisher.publish_note( success = await publisher.publish_note(
title=title, title=title,
...@@ -57,21 +57,25 @@ def publish_to_xiaohongshu( ...@@ -57,21 +57,25 @@ def publish_to_xiaohongshu(
tags=tags, tags=tags,
images=images images=images
) )
return {"success": success} return {
"success": success,
"error": None if success else (publisher.last_error or "未知错误"),
"stage": publisher.last_stage
}
finally: finally:
# 不立即关闭,让用户确认 # 不立即关闭,让用户确认
await asyncio.sleep(5) await asyncio.sleep(5)
await publisher.close() await publisher.close()
return asyncio.run(_publish()) return asyncio.run(_publish())
def main(): def main():
"""命令行入口""" """命令行入口"""
import argparse import argparse
parser = argparse.ArgumentParser(description="小红书发布工具") parser = argparse.ArgumentParser(description="小红书发布工具")
parser.add_argument("--title", "-t", help="笔记标题") parser.add_argument("--title", "-t", help="笔记标题")
parser.add_argument("--content", "-c", help="笔记正文") parser.add_argument("--content", "-c", help="笔记正文")
...@@ -79,9 +83,9 @@ def main(): ...@@ -79,9 +83,9 @@ def main():
parser.add_argument("--images", "-i", help="图片路径(逗号分隔)") parser.add_argument("--images", "-i", help="图片路径(逗号分隔)")
parser.add_argument("--json", help="JSON 格式参数") parser.add_argument("--json", help="JSON 格式参数")
parser.add_argument("--headless", action="store_true", help="无头模式") parser.add_argument("--headless", action="store_true", help="无头模式")
args = parser.parse_args() args = parser.parse_args()
# 解析参数 # 解析参数
if args.json: if args.json:
try: try:
...@@ -98,12 +102,12 @@ def main(): ...@@ -98,12 +102,12 @@ def main():
content = args.content content = args.content
tags = [t.strip() for t in args.tags.split(",")] if args.tags else None tags = [t.strip() for t in args.tags.split(",")] if args.tags else None
images = [i.strip() for i in args.images.split(",")] if args.images else None images = [i.strip() for i in args.images.split(",")] if args.images else None
# 检查必要参数 # 检查必要参数
if not content: if not content:
print("❌ 请提供正文内容 (--content 或 --json)") print("❌ 请提供正文内容 (--content 或 --json)")
return return
# 发布 # 发布
result = publish_to_xiaohongshu( result = publish_to_xiaohongshu(
title=title, title=title,
...@@ -112,11 +116,12 @@ def main(): ...@@ -112,11 +116,12 @@ def main():
images=images, images=images,
headless=args.headless headless=args.headless
) )
if result.get("success"): if result.get("success"):
print("✅ 内容填充完成,请在浏览器中确认发布") print(f"✅ 内容填充完成,当前阶段: {result.get('stage')}")
print("ℹ️ 需要在浏览器中手动点击最终发布按钮")
else: else:
print(f"❌ 发布失败: {result.get('error', '未知错误')}") print(f"❌ 发布失败[{result.get('stage', 'unknown')}]: {result.get('error', '未知错误')}")
if __name__ == "__main__": if __name__ == "__main__":
......
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