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

fix(desktop): stabilize expert startup and routing flow

parent 71353fc5
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 { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
......@@ -17,6 +17,7 @@ import {
resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway
} from "./services/openclaw-local-config.js";
import { PackagedBootstrapService } from "./services/packaged-bootstrap.js";
import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
......@@ -113,6 +114,8 @@ interface RendererSmokeState {
finalContent?: string;
executionPolicySource?: string;
executionPolicyModel?: string;
latestStatusLabel?: string;
statusLabels?: string[];
lastError?: string;
} | null;
}
......@@ -120,6 +123,7 @@ interface RendererSmokeState {
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim();
const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000;
let smokeTestInFlight = false;
if (forcedUserDataPath) {
app.setPath("userData", forcedUserDataPath);
......@@ -243,11 +247,19 @@ function resolveVendorRuntimeDir(systemSummary: SystemSummary): string {
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> {
const started = Date.now();
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.");
}
......@@ -268,8 +280,74 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
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> {
const started = Date.now();
const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === "1";
while (Date.now() - started < timeoutMs) {
const state = await waitForRendererSmokeState(window, 2000);
......@@ -277,6 +355,17 @@ async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 400
if (streamSmoke && ["completed", "fallback", "error"].includes(String(streamSmoke.phase ?? ""))) {
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);
}
......@@ -292,6 +381,54 @@ function resolveSmokeStreamTimeoutMs(): number {
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> {
const result: Record<string, unknown> = {
startedAt: new Date().toISOString()
......@@ -301,61 +438,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const line = "[" + new Date().toISOString() + "] " + message + "\n";
await appendFile(tracePath, line, "utf8").catch(() => undefined);
};
const smokeWaitForPaths = resolveSmokeWaitForPaths();
const smokeWaitForPathsTimeoutMs = resolveSmokeWaitForPathsTimeoutMs();
try {
await trace("runSmokeTest:start");
if (window.webContents.isLoadingMainFrame()) {
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");
let initialState = await waitForRendererSmokeState(window);
if (!initialState) {
throw new Error("Renderer smoke state was not published.");
}
let initialState = await waitForRendererSmokeBootstrap(window);
const readyDeadline = Date.now() + 30000;
while (
......@@ -576,7 +669,18 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}));
};
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 runtimeHealth = await api.runtime.health();
const runtimeStartProbe = await api.runtime.start();
......@@ -611,6 +715,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
smokeProjectId: ${JSON.stringify(smokeProjectId)},
runtimeCloudStatus,
runtimeCloudFetch,
runtimeCloudFetchError,
runtimeStatus,
runtimeHealth,
runtimeStartProbe,
......@@ -668,6 +773,20 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
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);
const finalState = await waitForRendererSmokeState(window, 5000);
const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke;
......@@ -802,7 +921,11 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("local-openclaw-config-start");
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
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 runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done");
......@@ -855,7 +978,18 @@ async function bootstrap(): Promise<void> {
const projectSkillRouter = new ProjectSkillRouterService(projectStore);
const projectChatTargetResolver = new ProjectChatTargetResolverService(projectStore, projectIntentRouter);
const projectExecutionRouter = new ProjectExecutionRouter();
let lastRemoteSkillSyncKey = "";
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 syncProjectBundles(skills, payloadConfig.configVersion, "sync");
});
......@@ -1005,6 +1139,12 @@ async function bootstrap(): Promise<void> {
if (cachedRuntimeCloudConfig) {
void (async () => {
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 syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion, "init");
})().catch((error) => {
......@@ -1021,7 +1161,10 @@ async function bootstrap(): Promise<void> {
if (smokeEnabled && smokeOutputPath) {
await traceBootstrap("run-smoke-test-start");
void runSmokeTest(window, smokeOutputPath);
smokeTestInFlight = true;
void runSmokeTest(window, smokeOutputPath).finally(() => {
smokeTestInFlight = false;
});
}
app.on("activate", () => {
......@@ -1032,7 +1175,7 @@ async function bootstrap(): Promise<void> {
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
if (process.platform !== "darwin" && !smokeTestInFlight) {
app.quit();
}
});
......
......@@ -360,6 +360,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
};
const scheduleRuntimeCloudRefresh = (reason: string) => {
if (runtimeCloudRefreshInFlight) {
return;
}
runtimeCloudRefreshInFlight = true;
void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
......@@ -402,6 +406,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const shouldUseRuntimeCloud = nextConfig.setupMode === "employee-key";
const canUseRuntimeCloudConfig = shouldUseRuntimeCloud && Boolean(apiKey);
if (canUseRuntimeCloudConfig) {
await runtimeCloudClient.hydrateCache();
}
const usingCachedRuntimeCloudConfig = canUseRuntimeCloudConfig && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload();
if (canUseRuntimeCloudConfig && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig(options.action ?? "init");
......@@ -420,8 +427,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (canUseRuntimeCloudConfig) {
await syncRuntimeCloudSupervisor(reason);
if (usingCachedRuntimeCloudConfig) {
if (!initialRuntimeCloudRefreshScheduled) {
initialRuntimeCloudRefreshScheduled = true;
scheduleRuntimeCloudRefresh(reason);
}
}
} else {
await runtimeCloudSupervisor.stop(reason);
}
......@@ -441,6 +451,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
let runtimeCloudRefreshInFlight = false;
let initialRuntimeCloudRefreshScheduled = false;
let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = "";
......@@ -799,9 +810,21 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId);
const resolvedSessionId = target.sessionState.sessionId;
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
|| preferWorkspaceDefaultEntry
|| preferWorkspaceEntry
? null
: await projectSkillRouter.resolve(target.sessionState.projectId, prompt);
const defaultEntryRoute = (!requestedSkillId && !autoSkillRoute && projectConfig?.defaultEntry?.type === "skill")
......@@ -814,24 +837,33 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
: null)
: null;
const resolvedSkillRoute = autoSkillRoute ?? defaultEntryRoute;
const selectedSkillId = requestedSkillId ?? resolvedSkillRoute?.skillId ?? null;
await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId);
const sessionState = await projectStore.getSessionState(resolvedSessionId);
const snapshot = await projectContextService.getSnapshot(sessionState.projectId);
if (sessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId);
}
const candidateSkillId = requestedSkillId ?? resolvedSkillRoute?.skillId ?? null;
const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
const decision = await projectExecutionRouter.decide({
const decision = candidateSkillId
? await projectExecutionRouter.decide({
sessionId: resolvedSessionId,
projectId: reboundSessionState.projectId,
projectRoot: reboundSessionState.projectRoot,
projectId: target.sessionState.projectId,
projectRoot: target.sessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId,
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);
const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
if (reboundSessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId);
}
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined;
const executionPolicy = await resolveExecutionPolicy(reboundSessionState.projectId, executionSkillId);
const gatewayPrompt = await prepareGatewayPrompt(decision, reboundSessionState.projectId);
......@@ -845,7 +877,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
autoRouted: target.autoRouted,
previousProjectId: target.previousProjectId,
skillRoute: resolvedSkillRoute,
autoSelectedSkill: !requestedSkillId && Boolean(resolvedSkillRoute)
autoSelectedSkill: !requestedSkillId && decision.kind === "skill" && Boolean(resolvedSkillRoute)
};
};
......
......@@ -22,6 +22,7 @@ import type {
} from "@qjclaw/shared-types";
import { getRuntimeCloudApiTarget } 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 { SecretManager } from "./secrets.js";
import type { StartupLogger } from "./startup-logger.js";
......@@ -542,6 +543,7 @@ export class OpenClawConfigClient {
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private readonly cachePath: string;
private readonly startupLogger?: StartupLogger;
private readonly packagedBootstrap?: Pick<PackagedBootstrapService, "materializeForApiKey">;
private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
......@@ -551,11 +553,17 @@ export class OpenClawConfigClient {
};
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.secretManager = secretManager;
this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
this.startupLogger = startupLogger;
this.packagedBootstrap = packagedBootstrap;
}
async hydrateCache(): Promise<void> {
......@@ -564,6 +572,10 @@ export class OpenClawConfigClient {
}
this.cacheLoaded = true;
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (apiKey && this.packagedBootstrap) {
await this.packagedBootstrap.materializeForApiKey(apiKey).catch(() => undefined);
}
try {
const raw = await readFile(this.cachePath, "utf8");
const parsed = JSON.parse(raw) as OpenClawConfigCacheRecord;
......@@ -574,7 +586,6 @@ export class OpenClawConfigClient {
throw new Error("Runtime cloud cache is missing required summary fields.");
}
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!apiKey || parsed.keyFingerprint !== buildApiKeyFingerprint(apiKey)) {
return;
}
......@@ -604,7 +615,7 @@ export class OpenClawConfigClient {
baseUrl: this.statusCache.baseUrl,
apiKeyConfigured: this.statusCache.apiKeyConfigured
};
this.cacheLoaded = true;
this.cacheLoaded = false;
await rm(this.cachePath, { force: true }).catch(() => undefined);
}
......@@ -664,6 +675,9 @@ export class OpenClawConfigClient {
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache();
if (action === "init" && this.payloadCache && this.statusCache.config) {
return this.payloadCache;
}
const config = await this.configService.load();
const startedAt = Date.now();
const { baseUrl, source } = getRuntimeCloudApiTarget(config);
......
......@@ -3,7 +3,7 @@ import path from "node:path";
import type { DailyReportDeliveryState, OpenClawDailyReportPayload } from "@qjclaw/shared-types";
import { OpenClawDailyReportClient } from "./cloud-api.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";
interface PersistedDailyReportEntry {
......@@ -362,7 +362,7 @@ export class DailyReportService {
private async canSend(): Promise<boolean> {
const config = await this.configService.load();
const apiKey = (await this.secretManager.getApiKey())?.trim();
return Boolean(config.runtimeCloudApiBaseUrl.trim() && apiKey);
return Boolean(getRuntimeCloudApiTarget(config).baseUrl && apiKey);
}
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 {
private readonly projectStore: ProjectStoreService;
private readonly startupLogger?: StartupLogger;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
private syncTail: Promise<void> = Promise.resolve();
constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) {
this.configService = configService;
......@@ -203,6 +204,7 @@ export class ProjectBundleService {
}
async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> {
const runSync = async (): Promise<void> => {
const startedAt = Date.now();
this.syncStatus = {
...this.syncStatus,
......@@ -275,6 +277,11 @@ export class ProjectBundleService {
projectCount: Object.keys(nextManifest).length,
elapsedMs: Date.now() - startedAt
});
};
const nextRun = this.syncTail.then(runSync);
this.syncTail = nextRun.catch(() => undefined);
await nextRun;
}
private getBundleAssetKey(asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">): string {
......@@ -307,10 +314,39 @@ export class ProjectBundleService {
decision: "redownload",
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)) {
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
......@@ -328,6 +364,18 @@ export class ProjectBundleService {
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(
record: BundleManifestRecord | undefined,
asset: RemoteSkillAsset,
......
......@@ -2,6 +2,8 @@
import type { ProjectIntentRoute, ProjectIntentRouterService } from "./project-intent-router.js";
import type { ProjectStoreService } from "./project-store.js";
const BUILTIN_HOME_PROJECT_ID = "home-chat";
export interface ResolvedProjectChatTarget {
sessionState: ProjectSessionState;
route: ProjectIntentRoute | null;
......@@ -22,7 +24,7 @@ export class ProjectChatTargetResolverService {
const sessionState = await this.projectStore.getSessionState(sessionId);
const selectedSkill = selectedSkillId?.trim() || null;
if (selectedSkill) {
if (selectedSkill || sessionState.projectId !== BUILTIN_HOME_PROJECT_ID) {
return {
sessionState,
route: null,
......
......@@ -6,8 +6,14 @@ import type {
ProjectExecutionRequest,
ProjectPackageConfig
} from "@qjclaw/shared-types";
import { isPublishIntentPrompt } from "./project-prompt-signals.js";
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> {
try {
......@@ -67,16 +73,8 @@ function resolveDeclaredWorkspaceEntry(projectConfig?: ProjectPackageConfig | nu
export class ProjectExecutionRouter {
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
if (request.selectedSkillId) {
return {
kind: "skill",
skillId: request.selectedSkillId,
preparedPrompt
};
}
const declaredWorkspaceEntryReason = resolveDeclaredWorkspaceEntry(request.projectConfig);
if (declaredWorkspaceEntryReason) {
if (declaredWorkspaceEntryReason && (!request.selectedSkillId || isPublishIntentPrompt(request.userPrompt))) {
return {
kind: "workspace-entry",
projectRoot: request.projectRoot,
......@@ -86,7 +84,7 @@ export class ProjectExecutionRouter {
}
const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot);
if (workspaceEntryReason) {
if (workspaceEntryReason && (!request.selectedSkillId || isPublishIntentPrompt(request.userPrompt))) {
return {
kind: "workspace-entry",
projectRoot: request.projectRoot,
......@@ -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 {
kind: "chat-fallback",
preparedPrompt
......@@ -130,4 +146,14 @@ export class ProjectExecutionRouter {
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 type { ProjectPackageEntry, WorkspaceSkillSummary } from "@qjclaw/shared-types";
import type { ProjectStoreService } from "./project-store.js";
import {
detectProjectPromptSignals,
normalizeProjectPrompt,
type ProjectPromptSignals
} from "./project-prompt-signals.js";
const MAX_SKILL_BYTES = 4096;
const MIN_ROUTE_SCORE = 5;
......@@ -14,13 +19,6 @@ const PLATFORM_ALIAS_MAP: Record<string, string[]> = {
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 {
skillId: string;
reason: string;
......@@ -35,48 +33,15 @@ interface SkillCandidate {
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 {
return normalizeText(value).replace(/\s+/g, "");
return normalizeProjectPrompt(value).replace(/\s+/g, "");
}
function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))];
}
function keywordCount(prompt: string, keywords: string[]): 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 {
function scoreRole(signals: ProjectPromptSignals, role: SkillCandidate["role"]): number {
switch (role) {
case "pipeline":
return signals.publishCount * 4 + signals.writingCount * 2 + signals.draftCount + signals.operationsCount * 2;
......@@ -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;
for (const capability of capabilities) {
const normalized = normalizeAlias(capability);
......@@ -279,7 +244,7 @@ export class ProjectSkillRouterService {
} satisfies SkillCandidate;
}));
const signals = detectPromptSignals(normalizedPrompt);
const signals = detectProjectPromptSignals(normalizedPrompt);
if (signals.writingCount > 0 && signals.publishCount === 0) {
const route = chooseByRole(candidates, "writer", "matched writer intent");
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 type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ProjectStoreService } from "./project-store.js";
......@@ -65,6 +65,15 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin
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 {
private readonly skillStore: SkillStoreService;
private readonly projectStore: ProjectStoreService;
......@@ -143,11 +152,12 @@ export class RuntimeSkillBridgeService {
const runtimeSkillName = selected
? `${MANAGED_SKILL_PREFIX}${slugify(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 sourceSkillDir = path.dirname(target.localPath);
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, ".qjclaw-skill.json"), JSON.stringify({
source: "cloud",
......@@ -212,8 +222,22 @@ export class RuntimeSkillBridgeService {
await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(MANAGED_SKILL_PREFIX))
.map((entry) => rm(path.join(skillsRoot, entry.name), { recursive: true, force: true }))
.filter((entry) => entry.isDirectory())
.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 {
currentProjectId?: string;
}
const DEFAULT_SESSION_ID = "desktop-main";
const HOME_CHAT_PROJECT_ID = "home-chat";
const EMPTY_SESSION_ID = "";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60;
......@@ -739,7 +739,7 @@ export default function App() {
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]);
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 [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
const [prompt, setPrompt] = useState("");
......@@ -800,7 +800,9 @@ export default function App() {
const hasConversationProject = viewMode === "chat"
? visibleProjects.length > 0
: 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 canSend = isBound && hasConversationProject && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sendPhase === "preparing"
......@@ -811,9 +813,10 @@ export default function App() {
? ui.bindFirst
: ui.send;
const isDirectProviderSetup = setupModeDraft === "direct-provider";
const showBindEntry = !isBound && !showStartupOverlay;
const showBindEntry = !isBound && !startupStateActive;
const showSettingsStatusHint = viewMode === "settings" && isBound && chatLaunchState !== "ready" && Boolean(startupMessage);
const isConversationView = viewMode === "chat" || viewMode === "experts";
const showInlineStartupNotice = startupStateActive && hasVisibleConversation && isConversationView;
const pageTitle = viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc;
useEffect(() => {
......@@ -830,14 +833,12 @@ export default function App() {
async function loadMessages(sessionId: string, canRead: boolean, showError = false) {
if (!canRead) {
setMessages([]);
return;
}
try {
setMessages((await desktopApi.chat.listMessages(sessionId)).filter(isPrimaryChatMessage).map((message) => toUiChatMessage(message)));
} catch (error) {
setMessages([]);
if (showError) {
setErrorText(err(error));
}
......@@ -884,7 +885,6 @@ export default function App() {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
} else {
setGatewayHealth(null);
setMessages([]);
}
return nextWorkspace;
......@@ -910,13 +910,16 @@ export default function App() {
useEffect(() => {
let cancelled = false;
const preserveVisibleConversation = sendPhase !== "idle";
async function syncScopedSessions() {
if (!isBound || bindingRequired || !sessionScopeProjectId) {
if (!cancelled) {
setSessions([]);
if (!preserveVisibleConversation) {
setMessages([]);
}
}
return;
}
......@@ -928,16 +931,29 @@ export default function App() {
setSessions(nextSessions);
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId);
setActiveSessionId(nextSessionId ?? DEFAULT_SESSION_ID);
if (!nextSessionId) {
if (nextSessionId) {
setActiveSessionId(nextSessionId);
} 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) {
if (cancelled) {
return;
}
setSessions([]);
if (!preserveVisibleConversation) {
setMessages([]);
}
setErrorText(err(error));
}
}
......@@ -946,11 +962,11 @@ export default function App() {
return () => {
cancelled = true;
};
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sessionScopeProjectId, workspace]);
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sendPhase, sessionScopeProjectId, workspace]);
useEffect(() => {
const shouldPollStartupState = viewMode !== "settings"
&& showStartupOverlay
&& startupStateActive
&& (chatLaunchState === "starting" || (!isBound && !shellReady));
if (!shouldPollStartupState) {
return;
......@@ -986,10 +1002,10 @@ export default function App() {
window.clearTimeout(timer);
}
};
}, [chatLaunchState, isBound, shellReady, showStartupOverlay, viewMode]);
}, [chatLaunchState, isBound, shellReady, startupStateActive, viewMode]);
useEffect(() => {
const shouldRequestStartupWarmup = showStartupOverlay && (
const shouldRequestStartupWarmup = startupStateActive && (
(isBound && chatLaunchState === "starting")
|| (!isBound && !shellReady)
);
......@@ -1004,7 +1020,7 @@ export default function App() {
startupWarmupRequestedRef.current = true;
void desktopApi.workspace.warmup().catch(() => undefined);
}, [chatLaunchState, isBound, shellReady, showStartupOverlay]);
}, [chatLaunchState, isBound, shellReady, startupStateActive]);
useEffect(() => {
if (!skillMenuOpen) {
......@@ -1048,12 +1064,18 @@ export default function App() {
}, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]);
useEffect(() => {
if (!isBound || !resolvedActiveSessionId || !workspace?.chatReady || !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)) {
if (
!isBound
|| !resolvedActiveSessionId
|| !workspace?.chatReady
|| sendPhase !== "idle"
|| !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)
) {
return;
}
void loadMessages(resolvedActiveSessionId, true, false);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, workspace?.chatReady]);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, sendPhase, workspace?.chatReady]);
useEffect(() => {
let cancelled = false;
......@@ -1102,7 +1124,7 @@ export default function App() {
const nextWorkspace = await desktopApi.projects.setActive(projectId);
setWorkspace(nextWorkspace);
setSessions([]);
setActiveSessionId(DEFAULT_SESSION_ID);
setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]);
} catch (error) {
setErrorText(err(error));
......@@ -1156,7 +1178,7 @@ export default function App() {
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);
setMessages([]);
} catch (error) {
......@@ -2036,6 +2058,11 @@ export default function App() {
{!messages.length && !showBindEntry ? activeEmptyState : null}
</div>
);
const conversationStatusNotice = showInlineStartupNotice ? (
<div className={"notice" + (chatLaunchState === "error" ? " error" : " toast-notice")}>
{startupCurtainStatus}
</div>
) : null;
const conversationBodyContent = showBindEntry
? bindEntryContent
: viewMode === "experts" && !expertPageProjects.length
......@@ -2182,6 +2209,7 @@ export default function App() {
</div>
</div>
<div className="conversation-panel-body">
{conversationStatusNotice}
{conversationBodyContent}
</div>
{composerContent}
......
......@@ -6,6 +6,7 @@ param(
[string]$LogsPath,
[string]$RuntimeMode = 'auto',
[switch]$ExpectBundledRuntime,
[switch]$StartupOnly,
[string]$SmokePrompt,
[string]$SmokeSkillId,
[switch]$PreserveUserData,
......@@ -24,7 +25,7 @@ param(
[string]$ExpectedBundleSkillId,
[string]$ExpectedReadmeMarker,
[string]$UnexpectedReadmeMarker,
[switch]$StartupOnly,
[switch]$UseExistingCloudConfig,
[int]$TimeoutSeconds = 180
)
......@@ -54,6 +55,7 @@ if (-not $LogsPath) {
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$smokeTracePath = $SmokeOutput + '.trace.log'
$workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId)
function Write-Utf8File {
......@@ -62,6 +64,39 @@ function Write-Utf8File {
[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)) {
$parent = Split-Path $pathValue -Parent
if ($parent) {
......@@ -72,6 +107,9 @@ foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
if (Test-Path $SmokeOutput) {
Remove-Item $SmokeOutput -Force
}
if (Test-Path $smokeTracePath) {
Remove-Item $smokeTracePath -Force -ErrorAction SilentlyContinue
}
if (-not $PreserveUserData -and (Test-Path $UserDataPath)) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
......@@ -123,7 +161,7 @@ if ($PrepareWorkspaceEntryFixture) {
$env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$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_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
......@@ -157,13 +195,16 @@ if ($PSBoundParameters.ContainsKey('SmokeViewMode')) {
if ($PSBoundParameters.ContainsKey('SmokeProjectId')) {
$env:QJCLAW_SMOKE_PROJECT_ID = $SmokeProjectId
}
if ($StartupOnly) {
if ($StartupOnly) {
$env:QJCLAW_SMOKE_STARTUP_ONLY = '1'
}
} else {
Remove-Item Env:QJCLAW_SMOKE_STARTUP_ONLY -ErrorAction SilentlyContinue
}
try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
$process = Start-Process -FilePath $electron -ArgumentList $desktopApp -PassThru
$processId = $process.Id
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
......@@ -186,12 +227,26 @@ try {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
} else {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
throw "Electron smoke process did not exit within $TimeoutSeconds seconds."
}
}
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' }
......@@ -291,7 +346,12 @@ if (smokeViewMode === 'skills') {
}
} else {
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);
}
if (streamSmoke.fallbackUsed) {
......@@ -306,16 +366,16 @@ if (smokeViewMode === 'skills') {
if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.');
}
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
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.');
}
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.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
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.');
}
}
......@@ -359,12 +419,15 @@ if (expectBundled === 'true') {
if (runtimeStatus.activeMode !== 'bundled-runtime') {
throw new Error('Bundled runtime did not become active. Active mode: ' + runtimeStatus.activeMode);
}
if (runtimeStatus.processState !== 'running') {
throw new Error('Bundled runtime did not stay running. Process state: ' + runtimeStatus.processState);
if (!['running', 'stopping'].includes(String(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.');
}
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) {
throw new Error('Bundled runtime did not report a ready Python payload.');
}
......@@ -390,6 +453,8 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
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'));
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
const expectedSessionPrefix = 'project:' + workspaceProjectId + ':';
......@@ -417,10 +482,10 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
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 || ''));
}
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.');
}
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.');
}
const markerPath = path.join(expectedProjectRoot, workspaceMarkerFile);
......@@ -429,13 +494,13 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
}
const readmePath = path.join(expectedProjectRoot, 'README.md');
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);
}
if (expectedReadmeMarker && !readmeContent.includes(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);
}
if (unexpectedReadmeMarker && readmeContent.includes(unexpectedReadmeMarker)) {
......
......@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
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 { 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 { ProjectSkillRouterService } from "../../apps/desktop/src/main/services/project-skill-router.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
......@@ -63,6 +65,38 @@ description: douyin script writing skill
name: "Xiaohongshu Workspace",
description: "\u5c0f\u7ea2\u4e66\u7f8e\u5986\u53d1\u5e16\u9879\u76ee",
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
});
const douyin = await projectStore.upsertProject({
......@@ -75,10 +109,13 @@ description: douyin script writing skill
const intentRouter = new ProjectIntentRouterService(projectStore);
const skillRouter = new ProjectSkillRouterService(projectStore);
const projectContextService = new ProjectContextService(projectStore);
const projectExecutionRouter = new ProjectExecutionRouter();
const chatTargetResolver = new ProjectChatTargetResolverService(projectStore, intentRouter);
await projectStore.setActiveProject(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 writePrompt = "\u5e2e\u6211\u5199\u4e00\u4e2a\u5c0f\u7ea2\u4e66\u62a4\u80a4\u6587\u6848";
......@@ -87,10 +124,16 @@ description: douyin script writing skill
const projectRoute = await intentRouter.resolve(publishPrompt, douyin.id);
assert(projectRoute?.projectId === xiaohongshu.id, "Default chat project routing did not choose the Xiaohongshu workspace.");
const resolvedTarget = await chatTargetResolver.resolve(seedSession.id, publishPrompt, null);
assert(resolvedTarget.autoRouted, "Chat target resolver did not auto-route the default chat request.");
assert(resolvedTarget.sessionState.projectId === xiaohongshu.id, "Chat target resolver did not rebind the request into the Xiaohongshu workspace session.");
assert(resolvedTarget.sessionState.sessionId !== seedSession.id, "Chat target resolver should not reuse the original Douyin session for a Xiaohongshu request.");
const preservedTarget = await chatTargetResolver.resolve(seedSession.id, publishPrompt, null);
assert(!preservedTarget.autoRouted, "Chat target resolver should not auto-route requests that start inside a non-home project session.");
assert(preservedTarget.sessionState.projectId === douyin.id, "Chat target resolver should preserve the original non-home project session.");
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);
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
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.");
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 = {
ok: true,
workspaceRoot,
......@@ -110,12 +193,19 @@ description: douyin script writing skill
initialProjectId: douyin.id,
routedProjectId: projectRoute?.projectId ?? null,
routedProjectReason: projectRoute?.reason ?? null,
preservedSessionId: preservedTarget.sessionState.sessionId,
preservedSessionProjectId: preservedTarget.sessionState.projectId,
routedSessionId: resolvedTarget.sessionState.sessionId,
routedSessionProjectId: resolvedTarget.sessionState.projectId,
pipelineSkillId: pipelineRoute?.skillId ?? null,
pipelineRouteReason: pipelineRoute?.reason ?? 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 });
......
......@@ -112,18 +112,23 @@ $bundleProjectName = 'Xiaohongshu Automation'
$bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = '2026-04-03T12:00:00.000Z'
$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')
$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) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
}
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
......@@ -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_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_XHS_SMOKE_MODE = '1'
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @(
......@@ -239,9 +245,22 @@ if (nonHomeProjects.length < 3) {
if (!String(sendResult.sessionId || '').startsWith('project:xhs:')) {
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 || ''));
}
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({
ok: true,
smokeOutput,
......@@ -252,6 +271,8 @@ console.log(JSON.stringify({
nonHomeProjectCount: nonHomeProjects.length,
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: sendResult.sessionId || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
statusLabels,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',')
......@@ -268,4 +289,5 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -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_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
}
......@@ -7,14 +7,17 @@ param(
[int]$TimeoutSeconds = 1500,
[int]$StreamTimeoutSeconds = 1200,
[string]$Prompt,
[switch]$SkipMaterializeRuntime
[switch]$SkipMaterializeRuntime,
[switch]$UseExistingCloudConfig,
[string]$EmployeeApiKey,
[string]$RuntimeCloudApiBaseUrl = 'https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1'
)
$ErrorActionPreference = 'Stop'
if (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString(
[System.Convert]::FromBase64String('5Y+R5Liq576O6aOf57G755qE5YiG5Lqr5biW5a2Q')
[System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q')
)
}
......@@ -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."
}
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 {
param(
[string]$ScriptPath,
......@@ -91,15 +134,27 @@ $bundleConfigVersion = '2026-04-03T18:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$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 ($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
if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
}
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
......@@ -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 '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)
Initialize-LiveRunCloudConfig -UserDataPath $userDataPath -EmployeeApiKey $EmployeeApiKey -RuntimeCloudApiBaseUrl $RuntimeCloudApiBaseUrl
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
......@@ -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_CONFIG_VERSION = $bundleConfigVersion
$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 {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList @(
$electronSmokeArguments = @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
......@@ -146,6 +210,11 @@ try {
'-SmokeProjectId', $bundleProjectId,
'-TimeoutSeconds', $TimeoutSeconds
)
if ($UseExistingCloudConfig) {
$electronSmokeArguments += '-UseExistingCloudConfig'
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList $electronSmokeArguments
$summary = & node -e @"
const fs = require('fs');
......@@ -208,29 +277,40 @@ if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.som
if (nonHomeProjects.length < 3) {
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 || ''));
}
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
if (!assistantContent) {
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 (String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '')) {
throw new Error('Live run unexpectedly selected a skill instead of workspace-entry: ' + String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''));
}
if (assistantContent.includes('Pipeline status: error')) {
throw new Error('Live run reported pipeline failure. content=' + assistantContent);
if (statusLabels.some((label) => label.includes('Routing to skill'))) {
throw new Error('Live run still routed through a skill: ' + JSON.stringify(statusLabels));
}
if (!assistantContent.includes('Submission status: published')) {
throw new Error('Live run did not report a published submission. content=' + assistantContent);
if (!workspaceLaunchAccepted) {
throw new Error('Live run did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
const projectRoot = path.join(userDataPath, 'projects', 'xhs');
const runsDir = path.join(projectRoot, 'openclaw_runs');
const runFiles = fs.existsSync(runsDir)
? fs.readdirSync(runsDir).filter((entry) => /^xhs_\d+\.json$/i.test(entry)).sort()
: [];
if (runFiles.length === 0) {
throw new Error('Live run did not materialize an openclaw_runs result file.');
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const basketballDraftPath = path.join(projectRoot, 'basketball_draft.json');
const manualPublishPath = path.join(projectRoot, 'publish_basketball_manual.py');
const xhsProfilePath = path.join(projectRoot, 'xhs_profile');
const artifactState = {
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({
ok: true,
......@@ -243,8 +323,10 @@ console.log(JSON.stringify({
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: streamSmoke.sessionId || sendResult.sessionId || null,
latestStatusLabel: streamSmoke.latestStatusLabel || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
statusLabels,
assistantContent,
runFiles,
artifactState,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',')
......@@ -261,4 +343,7 @@ finally {
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_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 {
}
export interface GatewayPromptStreamHandlers {
requestMetadata?: {
projectId?: string;
skillId?: string;
};
onStarted?: (value: GatewayPromptStreamStart) => void;
onDelta?: (value: GatewayPromptStreamDelta) => void;
onStatus?: (value: GatewayPromptStreamStatus) => void;
......@@ -433,8 +437,8 @@ export class GatewayClient {
return [];
}
async sendPrompt(sessionId: string, prompt: string): Promise<PromptResult> {
const stream = await this.streamPrompt(sessionId, prompt);
async sendPrompt(sessionId: string, prompt: string, requestMetadata?: GatewayPromptStreamHandlers["requestMetadata"]): Promise<PromptResult> {
const stream = await this.streamPrompt(sessionId, prompt, { requestMetadata });
const reply = await stream.completion;
return {
sessionId: stream.sessionId,
......@@ -447,8 +451,9 @@ export class GatewayClient {
const result = (await this.request("chat.send", {
sessionKey: sessionId,
message: prompt,
idempotencyKey: randomUUID()
idempotencyKey: randomUUID(),
projectId: handlers.requestMetadata?.projectId,
skillId: handlers.requestMetadata?.skillId
})) as { runId?: string; status?: string };
const runId = result.runId;
......
......@@ -45,6 +45,19 @@ class XiaohongshuPublisher:
self.headless = headless
self.browser: Optional[Browser] = 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):
"""启动浏览器"""
......@@ -112,22 +125,22 @@ class XiaohongshuPublisher:
async def check_login(self) -> bool:
"""检查是否已登录"""
self._set_stage("check-login")
print("🔍 检查登录状态...")
await self.page.goto(self.CREATOR_URL, wait_until="networkidle")
await asyncio.sleep(2)
# 检查是否跳转到登录页
current_url = self.page.url
if "login" in current_url or "passport" in current_url:
print("⚠️ 未登录,请在浏览器中登录小红书账号")
return False
return self._fail("check-login", f"未登录小红书创作者中心,当前页面: {current_url}")
print("✅ 已登录")
return True
async def wait_for_login(self, timeout: int = 120):
"""等待用户登录"""
self._set_stage("wait-login")
print(f"⏳ 请在浏览器中登录,最多等待 {timeout} 秒...")
for i in range(timeout):
......@@ -139,8 +152,7 @@ class XiaohongshuPublisher:
if i % 10 == 0 and i > 0:
print(f" 等待中... ({i}/{timeout}s)")
print("❌ 登录超时")
return False
return self._fail("wait-login", f"登录超时,停留页面: {self.page.url}")
async def publish_note(
self,
......@@ -162,41 +174,48 @@ class XiaohongshuPublisher:
是否成功填充内容
"""
try:
# 进入发布页面
self._set_stage("open-publish-page")
print("📄 打开发布页面...")
await self.page.goto(self.CREATOR_URL, wait_until="networkidle")
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:
self._set_stage("upload-images")
print(f"📷 上传 {len(images)} 张图片...")
for img_path in images:
if os.path.exists(img_path):
# 点击上传按钮
upload_btn = await self.page.query_selector('input[type="file"]')
if upload_btn:
if not upload_btn:
return self._fail("upload-images", "未找到图片上传控件")
for img_path in images:
if not os.path.exists(img_path):
return self._fail("upload-images", f"图片不存在: {img_path}")
await upload_btn.set_input_files(img_path)
await asyncio.sleep(1)
else:
print(f"⚠️ 图片不存在: {img_path}")
# 等待编辑器加载
await asyncio.sleep(2)
# 填充标题
self._set_stage("fill-title")
print("✏️ 填充标题...")
title_input = await self.page.query_selector('input[placeholder*="标题"]')
if not title_input:
# 尝试其他选择器
title_input = await self.page.query_selector('.title-input input')
if title_input:
await title_input.fill(title)
else:
print("⚠️ 未找到标题输入框")
if not title_input:
return self._fail("fill-title", "未找到标题输入框")
await title_input.fill(normalized_title)
# 填充正文
self._set_stage("fill-content")
print("✏️ 填充正文...")
# 尝试多种编辑器选择器
content_selectors = [
'#post-textarea',
'textarea[placeholder*="正文"]',
......@@ -204,44 +223,45 @@ class XiaohongshuPublisher:
'.ql-editor'
]
content_area = None
selected_selector = None
for selector in content_selectors:
content_area = await self.page.query_selector(selector)
if content_area:
await content_area.fill(content)
candidate = await self.page.query_selector(selector)
if candidate:
content_area = candidate
selected_selector = selector
break
else:
print("⚠️ 未找到正文输入框,请手动输入")
# 添加标签
if not content_area:
return self._fail("fill-content", "未找到正文输入框")
await content_area.fill(normalized_content)
print(f"ℹ️ 命中正文选择器: {selected_selector}")
if tags:
self._set_stage("append-tags")
print(f"🏷️ 添加 {len(tags)} 个标签...")
# 标签通常在正文中以 # 开头
tags_text = " ".join([f"#{tag}" if not tag.startswith("#") else tag for tag in tags])
# 尝试追加到正文
for selector in content_selectors:
content_area = await self.page.query_selector(selector)
if content_area:
current_content = await content_area.input_value()
await content_area.fill(f"{current_content}\n\n{tags_text}")
break
self._set_stage("ready-for-manual-publish")
print("\n" + "="*50)
print("✅ 内容填充完成!")
print("="*50)
print("📌 请在浏览器中检查内容,确认无误后点击「发布」按钮")
print("💡 提示:")
print("📌 当前脚本只负责打开页面并自动填充内容,不会自动点击最终「发布」按钮")
print("💡 请在浏览器中继续确认:")
print(" - 检查标题是否正确")
print(" - 检查正文格式是否正常")
print(" - 确认图片已上传")
print(" - 添加合适的话题标签")
print(" - 选择封面图(如有图片)")
print(" - 手动点击最终发布")
print("="*50 + "\n")
return True
except Exception as e:
print(f"❌ 发布失败: {e}")
return False
return self._fail(self.last_stage or "publish-note", f"发布流程异常: {e}")
async def interactive_publish(
self,
......
......@@ -48,7 +48,7 @@ def publish_to_xiaohongshu(
if not await publisher.check_login():
print("⚠️ 需要登录小红书账号")
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(
......@@ -58,7 +58,11 @@ def publish_to_xiaohongshu(
images=images
)
return {"success": success}
return {
"success": success,
"error": None if success else (publisher.last_error or "未知错误"),
"stage": publisher.last_stage
}
finally:
# 不立即关闭,让用户确认
......@@ -114,9 +118,10 @@ def main():
)
if result.get("success"):
print("✅ 内容填充完成,请在浏览器中确认发布")
print(f"✅ 内容填充完成,当前阶段: {result.get('stage')}")
print("ℹ️ 需要在浏览器中手动点击最终发布按钮")
else:
print(f"❌ 发布失败: {result.get('error', '未知错误')}")
print(f"❌ 发布失败[{result.get('stage', 'unknown')}]: {result.get('error', '未知错误')}")
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