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

fix(desktop): stabilize expert startup and routing flow

parent 71353fc5
This diff is collapsed.
This diff is collapsed.
......@@ -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,78 +204,84 @@ export class ProjectBundleService {
}
async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> {
const startedAt = Date.now();
this.syncStatus = {
...this.syncStatus,
state: "syncing",
lastError: undefined
};
const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName));
logBundle("bundle.sync.start", {
action: _action ?? "unknown",
configVersion,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", {
action: _action ?? "unknown",
configVersion,
workspaceRoot,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
const seenBundleKeys = new Set<string>();
logBundle("bundle.asset_filter.result", {
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const runSync = async (): Promise<void> => {
const startedAt = Date.now();
this.syncStatus = {
...this.syncStatus,
state: "syncing",
lastError: undefined
};
const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName));
logBundle("bundle.sync.start", {
action: _action ?? "unknown",
configVersion,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", {
action: _action ?? "unknown",
configVersion,
workspaceRoot,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
const seenBundleKeys = new Set<string>();
logBundle("bundle.asset_filter.result", {
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
for (const asset of bundleAssets) {
if (!asset.downloadUrl || !asset.fileName) {
continue;
}
for (const asset of bundleAssets) {
if (!asset.downloadUrl || !asset.fileName) {
continue;
}
const bundleKey = this.getBundleAssetKey(asset);
if (seenBundleKeys.has(bundleKey)) {
logBundle("bundle.duplicate.detected", {
const bundleKey = this.getBundleAssetKey(asset);
if (seenBundleKeys.has(bundleKey)) {
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,
bundleKey
bundleKey,
source: sanitizeUrl(new URL(asset.downloadUrl))
});
continue;
}
seenBundleKeys.add(bundleKey);
const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
const currentRecord = this.findManifestRecordForAsset(currentManifest, asset);
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
bundleKey,
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}.`);
if (nextManifest[nextRecord.projectId]) {
throw new Error(`Project bundle sync resolved duplicate projectId ${nextRecord.projectId}.`);
}
nextManifest[nextRecord.projectId] = nextRecord;
}
nextManifest[nextRecord.projectId] = nextRecord;
}
await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest);
await writeJsonFile(manifestPath, nextManifest);
this.syncStatus = {
state: "ready",
lastError: undefined,
lastSyncedAt: nowIso()
await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest);
await writeJsonFile(manifestPath, nextManifest);
this.syncStatus = {
state: "ready",
lastError: undefined,
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",
configVersion,
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,12 +910,15 @@ export default function App() {
useEffect(() => {
let cancelled = false;
const preserveVisibleConversation = sendPhase !== "idle";
async function syncScopedSessions() {
if (!isBound || bindingRequired || !sessionScopeProjectId) {
if (!cancelled) {
setSessions([]);
setMessages([]);
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) {
setMessages([]);
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([]);
setMessages([]);
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}
......
This diff is collapsed.
......@@ -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
}
This diff is collapsed.
......@@ -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;
......
......@@ -28,23 +28,36 @@ except ImportError:
class XiaohongshuPublisher:
"""小红书发布器"""
# 小红书创作者中心地址
CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish"
# 用户数据目录(保存登录状态)
USER_DATA_DIR = Path(__file__).parent / "browser_data"
def __init__(self, headless: bool = False):
"""
初始化发布器
Args:
headless: 是否无头模式(建议 False,方便登录和确认)
"""
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,24 +125,24 @@ 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):
await asyncio.sleep(1)
current_url = self.page.url
......@@ -138,9 +151,8 @@ class XiaohongshuPublisher:
return True
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,
......@@ -151,97 +163,105 @@ class XiaohongshuPublisher:
) -> bool:
"""
发布笔记
Args:
title: 标题
content: 正文
tags: 标签列表
images: 图片路径列表
Returns:
是否成功填充内容
"""
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)} 张图片...")
upload_btn = await self.page.query_selector('input[type="file"]')
if not upload_btn:
return self._fail("upload-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:
await upload_btn.set_input_files(img_path)
await asyncio.sleep(1)
else:
print(f"⚠️ 图片不存在: {img_path}")
# 等待编辑器加载
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)
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*="正文"]',
'.content-input textarea',
'.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
current_content = await content_area.input_value()
await content_area.fill(f"{current_content}\n\n{tags_text}")
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,
......
......@@ -28,28 +28,28 @@ def publish_to_xiaohongshu(
):
"""
发布内容到小红书
Args:
title: 标题
content: 正文
tags: 标签列表
images: 图片路径列表
headless: 是否无头模式
Returns:
结果字典
"""
async def _publish():
publisher = XiaohongshuPublisher(headless=headless)
await publisher.start()
try:
# 检查登录
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(
title=title,
......@@ -57,21 +57,25 @@ def publish_to_xiaohongshu(
tags=tags,
images=images
)
return {"success": success}
return {
"success": success,
"error": None if success else (publisher.last_error or "未知错误"),
"stage": publisher.last_stage
}
finally:
# 不立即关闭,让用户确认
await asyncio.sleep(5)
await publisher.close()
return asyncio.run(_publish())
def main():
"""命令行入口"""
import argparse
parser = argparse.ArgumentParser(description="小红书发布工具")
parser.add_argument("--title", "-t", help="笔记标题")
parser.add_argument("--content", "-c", help="笔记正文")
......@@ -79,9 +83,9 @@ def main():
parser.add_argument("--images", "-i", help="图片路径(逗号分隔)")
parser.add_argument("--json", help="JSON 格式参数")
parser.add_argument("--headless", action="store_true", help="无头模式")
args = parser.parse_args()
# 解析参数
if args.json:
try:
......@@ -98,12 +102,12 @@ def main():
content = args.content
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
# 检查必要参数
if not content:
print("❌ 请提供正文内容 (--content 或 --json)")
return
# 发布
result = publish_to_xiaohongshu(
title=title,
......@@ -112,11 +116,12 @@ def main():
images=images,
headless=args.headless
)
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