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

feat: add project bundle smoke workflows

parent 0a92cbb9
...@@ -397,6 +397,50 @@ function resolveSmokeWaitForPathsTimeoutMs(): number { ...@@ -397,6 +397,50 @@ function resolveSmokeWaitForPathsTimeoutMs(): number {
return 120_000; return 120_000;
} }
function resolveSmokeAttachments(): Array<{
kind: "image";
name: string;
mimeType: string;
localPath: string;
}> {
const raw = process.env.QJCLAW_SMOKE_ATTACHMENTS_JSON ?? "";
if (!raw.trim()) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((attachment) => {
if (!attachment || typeof attachment !== "object") {
return [];
}
const typed = attachment as {
kind?: unknown;
name?: unknown;
mimeType?: unknown;
localPath?: unknown;
};
const kind = typed.kind === "image" ? "image" : null;
const localPath = typeof typed.localPath === "string" ? typed.localPath.trim() : "";
if (!kind || !localPath) {
return [];
}
return [{
kind,
name: typeof typed.name === "string" && typed.name.trim() ? typed.name.trim() : path.basename(localPath),
mimeType: typeof typed.mimeType === "string" && typed.mimeType.trim() ? typed.mimeType.trim() : "application/octet-stream",
localPath
}];
});
} catch {
return [];
}
}
async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Promise<void> { async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Promise<void> {
if (pathsToCheck.length === 0) { if (pathsToCheck.length === 0) {
return; return;
...@@ -429,6 +473,63 @@ async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Pro ...@@ -429,6 +473,63 @@ async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Pro
throw new Error("Workspace launch was accepted, but expected artifacts were not created in time: " + missingPaths.filter(Boolean).join(", ")); throw new Error("Workspace launch was accepted, but expected artifacts were not created in time: " + missingPaths.filter(Boolean).join(", "));
} }
async function waitForWorkspaceVideoGeneration(
userDataPath: string,
projectId: string,
timeoutMs: number
): Promise<void> {
const outputRoot = path.join(userDataPath, "projects", projectId, "memory", "output");
const latestProjectAliasPath = path.join(outputRoot, "_latest_project_path.txt");
const started = Date.now();
let lastStatus = "missing";
while (Date.now() - started < timeoutMs) {
try {
await access(latestProjectAliasPath);
const latestProjectPath = (await readFile(latestProjectAliasPath, "utf8")).trim();
if (latestProjectPath) {
const videoStatusPath = path.join(latestProjectPath, "video_generation_status.json");
try {
const videoStatusRaw = await readFile(videoStatusPath, "utf8");
const videoStatus = JSON.parse(videoStatusRaw) as { status?: unknown; error?: unknown; message?: unknown };
lastStatus = String(videoStatus.status ?? "").trim() || "unknown";
if (lastStatus === "success") {
const finalVideoPath = path.join(latestProjectPath, "latest_seedance_split.mp4");
await access(finalVideoPath);
return;
}
if (["error", "failed"].includes(lastStatus)) {
const detail = String(videoStatus.error ?? videoStatus.message ?? "").trim();
throw new Error("Workspace video generation failed" + (detail ? ": " + detail : "."));
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("Workspace video generation failed")) {
throw error;
}
}
}
} catch {
// Keep polling until the video artifacts are ready or timeout.
}
await delay(1000);
}
throw new Error("Workspace video generation did not finish in time. lastStatus=" + lastStatus);
}
async function waitForRendererTerminalStreamSmoke(window: BrowserWindow, timeoutMs = 40000): Promise<RendererSmokeState | null> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const state = await waitForRendererSmokeState(window, 2000);
const streamSmoke = state?.streamSmoke;
if (streamSmoke && ["completed", "fallback", "error"].includes(String(streamSmoke.phase ?? ""))) {
return state;
}
await delay(250);
}
return null;
}
async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> { async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> {
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
startedAt: new Date().toISOString() startedAt: new Date().toISOString()
...@@ -549,6 +650,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -549,6 +650,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
? "skills" ? "skills"
: "chat"; : "chat";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || ""; const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
await trace("runSmokeTest:before-send-script"); await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => { const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop; const api = window.qjcDesktop;
...@@ -565,6 +667,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -565,6 +667,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")}; const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")};
const preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")}; const preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
const smokeViewMode = ${JSON.stringify(smokeViewMode)}; const smokeViewMode = ${JSON.stringify(smokeViewMode)};
const smokeAttachments = ${JSON.stringify(smokeAttachments)};
if (smokeBaseUrl) { if (smokeBaseUrl) {
const current = await api.config.load(); const current = await api.config.load();
await api.config.save({ await api.config.save({
...@@ -707,12 +810,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -707,12 +810,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, { : await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)}, mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)}, projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
}); });
return { return {
prompt: ${JSON.stringify(prompt)}, prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)}, smokeViewMode: ${JSON.stringify(smokeViewMode)},
smokeProjectId: ${JSON.stringify(smokeProjectId)}, smokeProjectId: ${JSON.stringify(smokeProjectId)},
smokeAttachments,
runtimeCloudStatus, runtimeCloudStatus,
runtimeCloudFetch, runtimeCloudFetch,
runtimeCloudFetchError, runtimeCloudFetchError,
...@@ -786,9 +891,25 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -786,9 +891,25 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
await waitForSmokePaths(smokeWaitForPaths, smokeWaitForPathsTimeoutMs); await waitForSmokePaths(smokeWaitForPaths, smokeWaitForPathsTimeoutMs);
await trace("runSmokeTest:artifact-wait-complete"); await trace("runSmokeTest:artifact-wait-complete");
} }
if (
process.env.QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE === "video_generated"
&& process.env.QJCLAW_SMOKE_PROJECT_ID
) {
await trace("runSmokeTest:video-artifact-wait-start");
await waitForWorkspaceVideoGeneration(
app.getPath("userData"),
process.env.QJCLAW_SMOKE_PROJECT_ID,
smokeWaitForPathsTimeoutMs
);
await trace("runSmokeTest:video-artifact-wait-complete");
}
} }
await delay(1500); await delay(1500);
const finalState = await waitForRendererSmokeState(window, 5000); const finalState = (
process.env.QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE === "video_generated"
? await waitForRendererTerminalStreamSmoke(window, 30000)
: null
) ?? await waitForRendererSmokeState(window, 5000);
const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke; const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke;
await trace("runSmokeTest:before-post-stream-script"); await trace("runSmokeTest:before-post-stream-script");
const postStreamResult = await window.webContents.executeJavaScript(`(async () => { const postStreamResult = await window.webContents.executeJavaScript(`(async () => {
......
...@@ -3,12 +3,14 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; ...@@ -3,12 +3,14 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import type { ProjectResolvedAttachment } from "@qjclaw/shared-types";
interface RunnerInput { interface RunnerInput {
vendorPackageDir: string; vendorPackageDir: string;
projectRoot: string; projectRoot: string;
sessionId: string; sessionId: string;
prompt: string; prompt: string;
attachments?: ProjectResolvedAttachment[];
runId?: string; runId?: string;
} }
...@@ -150,6 +152,23 @@ function createRuntimeSessionIdentity(sessionId: string): { sessionId: string; s ...@@ -150,6 +152,23 @@ function createRuntimeSessionIdentity(sessionId: string): { sessionId: string; s
}; };
} }
function renderAttachmentPrelude(projectRoot: string, attachments: ProjectResolvedAttachment[] | undefined): string {
if (!attachments?.length) {
return "";
}
const lines = [
"",
"Project attachments available for this request:",
...attachments.map((attachment, index) => {
const relativePath = attachment.relativeProjectPath || path.relative(projectRoot, attachment.projectPath);
return `${index + 1}. ${attachment.kind} | ${attachment.name} | ${relativePath}`;
}),
""
];
return lines.join("\n");
}
async function main(): Promise<void> { async function main(): Promise<void> {
const inputRaw = await readStdin(); const inputRaw = await readStdin();
const input = JSON.parse(inputRaw) as RunnerInput; const input = JSON.parse(inputRaw) as RunnerInput;
...@@ -219,9 +238,10 @@ async function main(): Promise<void> { ...@@ -219,9 +238,10 @@ async function main(): Promise<void> {
}; };
const runtimeSession = createRuntimeSessionIdentity(input.sessionId); const runtimeSession = createRuntimeSessionIdentity(input.sessionId);
const message = `${input.prompt}${renderAttachmentPrelude(input.projectRoot, input.attachments)}`.trim();
try { try {
const result = await agentModule.t({ const result = await agentModule.t({
message: input.prompt, message,
sessionId: runtimeSession.sessionId, sessionId: runtimeSession.sessionId,
sessionKey: runtimeSession.sessionKey, sessionKey: runtimeSession.sessionKey,
workspaceDir: input.projectRoot, workspaceDir: input.projectRoot,
......
...@@ -12,6 +12,9 @@ export interface RuntimeCloudApiTarget { ...@@ -12,6 +12,9 @@ export interface RuntimeCloudApiTarget {
const CONFIG_DIR = "config"; const CONFIG_DIR = "config";
const CONFIG_FILE = "app-config.json"; const CONFIG_FILE = "app-config.json";
const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1"; const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1";
const DEPRECATED_RUNTIME_CLOUD_API_BASE_URLS = new Set([
"https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1"
]);
const UI_ROUTE_NAMES = new Set([ const UI_ROUTE_NAMES = new Set([
"chat", "chat",
"control", "control",
...@@ -95,6 +98,14 @@ function normalizeRuntimeCloudApiBaseUrl(raw?: string): string { ...@@ -95,6 +98,14 @@ function normalizeRuntimeCloudApiBaseUrl(raw?: string): string {
return normalizeCloudApiBaseUrl(raw ?? ""); return normalizeCloudApiBaseUrl(raw ?? "");
} }
function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized && DEPRECATED_RUNTIME_CLOUD_API_BASE_URLS.has(normalized)) {
return DEFAULT_RUNTIME_CLOUD_API_BASE_URL;
}
return normalized;
}
function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget { function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw); const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized) { if (normalized) {
...@@ -139,7 +150,7 @@ export class AppConfigService { ...@@ -139,7 +150,7 @@ export class AppConfigService {
workspacePath: input.workspacePath, workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl), gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl), cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl), runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode) runtimeMode: normalizeRuntimeMode(input.runtimeMode)
}; };
...@@ -185,7 +196,7 @@ export class AppConfigService { ...@@ -185,7 +196,7 @@ export class AppConfigService {
workspacePath: config.workspacePath ?? this.userDataPath, workspacePath: config.workspacePath ?? this.userDataPath,
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`), gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""), cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl), runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE) runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
}; };
} }
......
...@@ -242,6 +242,49 @@ function classifyRuntimeCloudError(message: string): string { ...@@ -242,6 +242,49 @@ function classifyRuntimeCloudError(message: string): string {
return message; return message;
} }
function mergeAdditionalModelProvidersFromEnv(config: Record<string, unknown>): Record<string, unknown> {
const rawProviders = process.env.QJCLAW_EXTRA_MODEL_PROVIDERS_JSON?.trim();
if (!rawProviders) {
return config;
}
let parsed: unknown;
try {
parsed = JSON.parse(rawProviders);
} catch (error) {
console.warn("[runtime-cloud]", "failed to parse QJCLAW_EXTRA_MODEL_PROVIDERS_JSON", error);
return config;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return config;
}
const nextConfig = cloneJson(config);
const modelsSection = asRecord(nextConfig.models);
const providers = asRecord(modelsSection.providers);
for (const [providerKey, providerValue] of Object.entries(parsed)) {
if (providerKey.trim().length === 0) {
continue;
}
const incomingProvider = asRecord(providerValue);
if (Object.keys(incomingProvider).length === 0) {
continue;
}
const existingProvider = asRecord(providers[providerKey]);
providers[providerKey] = {
...existingProvider,
...incomingProvider
};
}
modelsSection.mode = "merge";
modelsSection.providers = providers;
nextConfig.models = modelsSection;
return nextConfig;
}
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {}; return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
} }
...@@ -961,7 +1004,7 @@ export class OpenClawConfigClient { ...@@ -961,7 +1004,7 @@ export class OpenClawConfigClient {
agentsSection.defaults = agentDefaults; agentsSection.defaults = agentDefaults;
nextConfig.agents = agentsSection; nextConfig.agents = agentsSection;
return nextConfig; return mergeAdditionalModelProvidersFromEnv(nextConfig);
} }
} }
export class AuthClient { export class AuthClient {
......
...@@ -25,6 +25,10 @@ export interface LocalOpenClawGatewayConfig { ...@@ -25,6 +25,10 @@ export interface LocalOpenClawGatewayConfig {
} }
export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGatewayConfig | null> { export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGatewayConfig | null> {
if (process.env.QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY === "1") {
return null;
}
const sourcePath = path.join(os.homedir(), ".openclaw", "openclaw.json"); const sourcePath = path.join(os.homedir(), ".openclaw", "openclaw.json");
try { try {
......
...@@ -704,19 +704,11 @@ export class ProjectStoreService { ...@@ -704,19 +704,11 @@ export class ProjectStoreService {
const project = await this.getProjectById(projectId); const project = await this.getProjectById(projectId);
const projectRecord = await this.readProjectRecord(project.id); const projectRecord = await this.readProjectRecord(project.id);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []); const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
const merged = new Map<string, WorkspaceSkillSummary>(); if (project.isBuiltinHome) {
return (await this.listCuratedGenericSkills(project.updatedAt)).sort(compareSkills);
for (const skill of await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)) {
merged.set(skill.id, skill);
} }
for (const skill of await this.listCuratedGenericSkills(project.updatedAt)) { return (await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)).sort(compareSkills);
if (!merged.has(skill.id)) {
merged.set(skill.id, skill);
}
}
return [...merged.values()].sort(compareSkills);
} }
async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> { async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
...@@ -729,6 +721,9 @@ export class ProjectStoreService { ...@@ -729,6 +721,9 @@ export class ProjectStoreService {
const projectRecord = await this.readProjectRecord(projectId); const projectRecord = await this.readProjectRecord(projectId);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []); const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
if (CURATED_GENERIC_SKILL_IDS.has(normalizedSkillId)) { if (CURATED_GENERIC_SKILL_IDS.has(normalizedSkillId)) {
if (!isBuiltinHomeProjectId(projectId)) {
return undefined;
}
return this.resolveCuratedGenericSkillTarget(normalizedSkillId); return this.resolveCuratedGenericSkillTarget(normalizedSkillId);
} }
...@@ -766,17 +761,50 @@ export class ProjectStoreService { ...@@ -766,17 +761,50 @@ export class ProjectStoreService {
projectName: string; projectName: string;
version?: string; version?: string;
boundSkillIds?: string[]; boundSkillIds?: string[];
declaredBoundSkillIds?: string[];
description?: string; description?: string;
ready?: boolean; ready?: boolean;
}): Promise<ProjectSummary> { }): Promise<ProjectSummary> {
const projectId = input.projectId?.trim() || slugify(input.projectName); const projectId = input.projectId?.trim() || slugify(input.projectName);
const bundleProjectRecord = await this.readProjectRecord(projectId);
const availableWorkspaceSkillIds = await this.readWorkspaceSkillIds();
const materializedBoundSkillIds = uniqueSkillIds(input.boundSkillIds ?? []);
const declaredBoundSkillIds = uniqueSkillIds(input.declaredBoundSkillIds ?? bundleProjectRecord?.boundSkillIds ?? []);
const requestedBoundSkillIds = declaredBoundSkillIds.length > 0
? declaredBoundSkillIds
: materializedBoundSkillIds;
const validBoundSkillIds = requestedBoundSkillIds.filter((skillId) => availableWorkspaceSkillIds.has(skillId));
const missingBoundSkillIds = requestedBoundSkillIds.filter((skillId) => !availableWorkspaceSkillIds.has(skillId));
const undeclaredMaterializedSkillIds = declaredBoundSkillIds.length > 0
? materializedBoundSkillIds.filter((skillId) => !declaredBoundSkillIds.includes(skillId))
: [];
if (missingBoundSkillIds.length > 0) {
console.warn("[project-store] bundle project declared missing bound skills", {
projectId,
missingBoundSkillIds
});
}
if (undeclaredMaterializedSkillIds.length > 0) {
console.warn("[project-store] bundle project materialized undeclared skills", {
projectId,
undeclaredMaterializedSkillIds
});
}
const lastError = missingBoundSkillIds.length > 0
? `Missing bound skills: ${missingBoundSkillIds.join(", ")}`
: undefined;
const nextReady = (input.ready ?? bundleProjectRecord?.ready ?? true)
&& (requestedBoundSkillIds.length === 0 || validBoundSkillIds.length > 0);
const project = await this.upsertProject({ const project = await this.upsertProject({
...(bundleProjectRecord ?? {}),
id: projectId, id: projectId,
name: input.projectName, name: input.projectName,
description: input.description, description: input.description ?? bundleProjectRecord?.description,
version: input.version, version: input.version ?? bundleProjectRecord?.version,
boundSkillIds: input.boundSkillIds, boundSkillIds: validBoundSkillIds,
ready: input.ready ?? true ready: nextReady,
lastError
}); });
const activeProject = await this.getActiveProject().catch(() => null); const activeProject = await this.getActiveProject().catch(() => null);
if (!activeProject || activeProject.isBuiltinHome) { if (!activeProject || activeProject.isBuiltinHome) {
...@@ -1045,6 +1073,17 @@ export class ProjectStoreService { ...@@ -1045,6 +1073,17 @@ export class ProjectStoreService {
return skills; return skills;
} }
private async readWorkspaceSkillIds(): Promise<Set<string>> {
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
return new Set(
dirEntries
.filter((entry) => entry.isDirectory())
.map((entry) => sanitizeSkillId(entry.name))
);
}
private async listCuratedGenericSkills(projectUpdatedAt: string): Promise<WorkspaceSkillSummary[]> { private async listCuratedGenericSkills(projectUpdatedAt: string): Promise<WorkspaceSkillSummary[]> {
const genericRoot = this.resolveCuratedSkillsRoot(); const genericRoot = this.resolveCuratedSkillsRoot();
if (!genericRoot) { if (!genericRoot) {
......
...@@ -3,13 +3,14 @@ import { spawn } from "node:child_process"; ...@@ -3,13 +3,14 @@ import { spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager"; import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ChatMessage } from "@qjclaw/shared-types"; import type { ChatMessage, ProjectResolvedAttachment } from "@qjclaw/shared-types";
interface ProjectWorkspaceExecutionInput { interface ProjectWorkspaceExecutionInput {
sessionId: string; sessionId: string;
projectRoot: string; projectRoot: string;
prompt: string; prompt: string;
userPrompt?: string; userPrompt?: string;
attachments?: ProjectResolvedAttachment[];
runId?: string; runId?: string;
} }
...@@ -184,7 +185,9 @@ async function resolveProjectAutomationCommand( ...@@ -184,7 +185,9 @@ async function resolveProjectAutomationCommand(
prompt: input.userPrompt?.trim() || input.prompt, prompt: input.userPrompt?.trim() || input.prompt,
preparedPrompt: input.prompt, preparedPrompt: input.prompt,
projectRoot: input.projectRoot, projectRoot: input.projectRoot,
sessionId: input.sessionId sessionId: input.sessionId,
firstAttachmentPath: input.attachments?.[0]?.projectPath ?? "",
attachmentPathsJson: JSON.stringify(input.attachments?.map((attachment) => attachment.projectPath) ?? [])
}; };
const scriptPath = path.resolve(projectRoot, renderTemplate(script, variables)); const scriptPath = path.resolve(projectRoot, renderTemplate(script, variables));
...@@ -271,6 +274,8 @@ export class ProjectWorkspaceExecutorService { ...@@ -271,6 +274,8 @@ export class ProjectWorkspaceExecutorService {
path.dirname(paths.pythonExecutable), path.dirname(paths.pythonExecutable),
process.env.PATH ?? "" process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter), ].filter(Boolean).join(path.delimiter),
QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []),
QJC_PROJECT_MAIN_IMAGE: input.attachments?.find((attachment) => attachment.kind === "image")?.projectPath ?? "",
...(automationCommand?.env ?? {}) ...(automationCommand?.env ?? {})
}; };
const spawnOptions = { const spawnOptions = {
...@@ -404,6 +409,7 @@ export class ProjectWorkspaceExecutorService { ...@@ -404,6 +409,7 @@ export class ProjectWorkspaceExecutorService {
projectRoot: input.projectRoot, projectRoot: input.projectRoot,
sessionId: input.sessionId, sessionId: input.sessionId,
prompt: input.prompt, prompt: input.prompt,
attachments: input.attachments ?? [],
runId runId
}); });
if (!automationCommand) { if (!automationCommand) {
......
...@@ -152,7 +152,8 @@ export class RuntimeSkillBridgeService { ...@@ -152,7 +152,8 @@ export class RuntimeSkillBridgeService {
const runtimeSkillName = selected const runtimeSkillName = selected
? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}` ? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`
: sourceName; : sourceName;
const runtimeSkillDir = path.join(skillsRoot, sanitizeRuntimeDirName(target.skillId || target.name || sourceName)); const runtimeSkillDirName = `${MANAGED_SKILL_PREFIX}${slugify(target.skillId || target.name || sourceName)}`;
const runtimeSkillDir = path.join(skillsRoot, sanitizeRuntimeDirName(runtimeSkillDirName));
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName); const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
const sourceSkillDir = path.dirname(target.localPath); const sourceSkillDir = path.dirname(target.localPath);
......
...@@ -7,4 +7,10 @@ lxml==5.3.0 ...@@ -7,4 +7,10 @@ lxml==5.3.0
pypdf==5.4.0 pypdf==5.4.0
python-docx==1.1.2 python-docx==1.1.2
charset-normalizer==3.4.1 charset-normalizer==3.4.1
pyyaml==6.0.2 pyyaml==6.0.2
\ No newline at end of file pillow==11.3.0
python-dotenv==1.2.2
greenlet==3.1.1
playwright==1.50.0
edge-tts==7.2.3
imageio-ffmpeg==0.6.0
...@@ -10,4 +10,8 @@ charset-normalizer==3.4.1 ...@@ -10,4 +10,8 @@ charset-normalizer==3.4.1
pyyaml==6.0.2 pyyaml==6.0.2
pillow==11.3.0 pillow==11.3.0
python-dotenv==1.2.2 python-dotenv==1.2.2
greenlet==3.1.1
playwright==1.50.0 playwright==1.50.0
edge-tts==7.2.3
imageio-ffmpeg==0.6.0
qiniu==7.17.0
...@@ -10,8 +10,12 @@ ...@@ -10,8 +10,12 @@
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end - `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry` - `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle` - `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `xhs-expert-cloud-bundle-smoke.ps1` packages `workspace/xhs` as a zip-backed employee-config bundle, preserves two extra fixture experts so the experts rail exceeds two items, switches to the XHS expert, and sends `发一个美食推荐类的帖子` through the experts view; `pnpm smoke:xhs-expert-cloud-bundle` - `xhs-expert-cloud-bundle-smoke.ps1` packages `workspace/xhs` as a zip-backed employee-config bundle, injects the fixed `volces` image provider into the managed runtime config for XHS generation, preserves two extra fixture experts so the experts rail exceeds two items, switches to the XHS expert, and sends `发一个美食推荐类的帖子` through the experts view; `pnpm smoke:xhs-expert-cloud-bundle`
- `xhs-expert-manual-launch.ps1` packages `workspace/xhs` into a local zip bundle, boots the packaged desktop app against the built-in mock `/openclaw-employee-config`, preserves two extra fixture experts so the experts rail exceeds two items, and leaves the app open for manual experts-page testing; close any already running `QianjiangClaw.exe` instance first, then run `powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1` - `douyin-expert-cloud-bundle-smoke.ps1` packages `workspace/douyin` as a zip-backed employee-config bundle, preserves two extra fixture experts so the experts rail exceeds two items, switches to the Douyin expert, and sends `帮我做一个关于防晒喷雾的抖音视频文案` through the experts view; `pnpm smoke:douyin-expert-cloud-bundle`
- `local-project-package-smoke.ps1` copies the current local `workspace/xhs` and `workspace/douyin` sources with `bundlePackaging.excludePaths` applied, runs package-level workspace-entry smoke checks from the copied package roots, verifies the XHS path/publish behavior, verifies injected XHS image-provider resolution plus topic extraction cleanup, and verifies the Douyin multi-turn intake flow; `pnpm smoke:local-project-package`
- `xhs-expert-manual-launch.ps1` packages `workspace/xhs` into a local zip bundle, boots the packaged desktop app against the built-in mock `/openclaw-employee-config`, injects the fixed `volces` image provider plus `XHS_IMAGE_PROVIDER/XHS_IMAGE_MODEL`, preserves two extra fixture experts so the experts rail exceeds two items, and leaves the app open for manual experts-page testing; close any already running `QianjiangClaw.exe` instance first, then run `powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1`
- `douyin-expert-manual-launch.ps1` packages `workspace/douyin` into a local zip bundle with `bundlePackaging.excludePaths` applied, boots the packaged desktop app against the built-in mock `/openclaw-employee-config`, preserves two extra fixture experts so the experts rail exceeds two items, and leaves the app open for manual experts-page testing; close any already running `QianjiangClaw.exe` instance first, then run `powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-manual-launch.ps1`
- `douyin-expert-live-run.ps1` packages `workspace/douyin` into a zip-backed expert bundle, sends an experts-page prompt with a test image attachment, and validates that Douyin writes fresh preview artifacts such as `_latest_workflow_summary.json`, `video_request.json`, `storyboard.json`, and `omnihuman_prompt.txt` inside the synced project; `powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-live-run.ps1`
- Remote project zip delivery and workspace-entry packaging rules are documented in `docs/remote-project-bundle-spec.zh-CN.md` - Remote project zip delivery and workspace-entry packaging rules are documented in `docs/remote-project-bundle-spec.zh-CN.md`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat` - `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe` - `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
......
This diff is collapsed.
This diff is collapsed.
param(
[int]$SmokePort = 4319,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[string]$AppExePath
)
$ErrorActionPreference = 'Stop'
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function Copy-ProjectBundleSource {
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
$projectJsonPath = Join-Path $SourceRoot 'project.json'
$excludePaths = @()
if (Test-Path $projectJsonPath) {
$projectConfig = Get-Content -Path $projectJsonPath -Raw | ConvertFrom-Json
$configuredExcludes = $projectConfig.bundlePackaging.excludePaths
if ($configuredExcludes) {
$excludePaths = @($configuredExcludes | ForEach-Object { [string]$_ })
}
}
$excludeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $excludePaths) {
if ($entry) {
[void]$excludeSet.Add($entry)
}
}
New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null
foreach ($entry in Get-ChildItem -LiteralPath $SourceRoot -Force) {
if ($excludeSet.Contains($entry.Name)) {
continue
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
function New-ExpertFixtureProject {
param(
[string]$ProjectsRoot,
[string]$ProjectId,
[string]$ProjectName,
[string]$Platform,
[string]$Description,
[string]$UpdatedAt
)
$projectRoot = Join-Path $ProjectsRoot $ProjectId
New-Item -ItemType Directory -Force -Path $projectRoot, (Join-Path $projectRoot 'memory') | Out-Null
$projectPayload = [ordered]@{
id = $ProjectId
name = $ProjectName
description = $Description
platform = $Platform
ready = $true
updatedAt = $UpdatedAt
boundSkillIds = @()
workspaceEntryEnabled = $true
}
Write-Utf8File (Join-Path $projectRoot 'project.json') ($projectPayload | ConvertTo-Json -Depth 8)
Write-Utf8File (Join-Path $projectRoot 'README.md') "# $ProjectName`n`nFixture expert project for manual Douyin validation."
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nPassive fixture expert used to keep the experts list above two items."
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\douyin-expert-manual-launch'
}
if (-not $AppExePath) {
$AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\QianjiangClaw.exe'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$AppExePath = [System.IO.Path]::GetFullPath($AppExePath)
$douyinSourceRoot = Join-Path $repoRoot 'workspace\douyin'
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleZipPath = Join-Path $BaseOutputDir 'douyin-expert-manual-launch.zip'
$bundleFileName = 'douyin-expert-manual-launch.zip'
$bundleSkillId = 'douyin-project-bundle'
$bundleConfigVersion = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
if (-not (Test-Path $AppExePath)) {
throw "Packaged desktop executable was not found: $AppExePath"
}
if (-not (Test-Path $douyinSourceRoot)) {
throw "Douyin workspace source was not found: $douyinSourceRoot"
}
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
if ($runningDesktop) {
$processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', '
throw "QianjiangClaw is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
}
if (Test-Path $BaseOutputDir) {
Remove-Item -LiteralPath $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $BaseOutputDir) {
throw "Failed to reset manual launch directory: $BaseOutputDir"
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $userDataPath, $logsPath, $bundleSourceRoot | Out-Null
Copy-ProjectBundleSource -SourceRoot $douyinSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'douyin')
if (Test-Path $bundleZipPath) {
Remove-Item -LiteralPath $bundleZipPath -Force
}
Compress-Archive -Path (Join-Path $bundleSourceRoot 'douyin') -DestinationPath $bundleZipPath -Force
$projectsRoot = Join-Path $userDataPath 'projects'
$manifestsRoot = Join-Path $userDataPath 'manifests'
New-Item -ItemType Directory -Force -Path $projectsRoot, $manifestsRoot | Out-Null
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'xhs-expert-smoke' -ProjectName 'XHS Expert Fixture' -Platform 'xiaohongshu' -Description 'Fixture project that keeps the experts list 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 list 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)
$env:QJCLAW_USER_DATA_PATH = $userDataPath
$env:QJCLAW_LOGS_PATH = $logsPath
$env:QJCLAW_RUNTIME_MODE = 'bundled-runtime'
$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'
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPath
$env:QJCLAW_SMOKE_BUNDLE_FILE_NAME = $bundleFileName
$env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId
$env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'Douyin Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Local mock employee-config bundle for manual Douyin expert validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$null = Start-Process -FilePath $AppExePath -PassThru
Write-Host "Launched packaged desktop app with local mock employee-config."
Write-Host "User data path: $userDataPath"
Write-Host "Bundle zip path: $bundleZipPath"
Write-Host "Open the Experts view, select douyin, and send your manual prompt."
...@@ -390,6 +390,7 @@ const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath) ...@@ -390,6 +390,7 @@ const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath)
? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8')) ? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'))
: null; : null;
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {}; const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1';
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') { if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.'); throw new Error('Runtime cloud config fetch did not succeed.');
} }
...@@ -400,10 +401,10 @@ if (smokeViewMode !== 'skills') { ...@@ -400,10 +401,10 @@ if (smokeViewMode !== 'skills') {
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) { if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.'); throw new Error('Runtime telemetry did not record a successful heartbeat.');
} }
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) { if (!acceptWorkspaceLaunch && Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount); throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
} }
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) { if (!acceptWorkspaceLaunch && Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.'); throw new Error('Runtime telemetry did not record a successful config sync.');
} }
if (!diagnostics || !diagnostics.runtimeTelemetry) { if (!diagnostics || !diagnostics.runtimeTelemetry) {
...@@ -453,7 +454,7 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') { ...@@ -453,7 +454,7 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const statusLabels = Array.isArray(streamSmoke.statusLabels) const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || '')) ? streamSmoke.statusLabels.map((value) => String(value || ''))
: []; : [];
const workspaceLaunchAccepted = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1' const workspaceLaunchAccepted = acceptWorkspaceLaunch
&& statusLabels.some((label) => label.includes('Launching project workspace')); && statusLabels.some((label) => label.includes('Launching project workspace'));
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || ''); const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId); const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
...@@ -464,16 +465,16 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') { ...@@ -464,16 +465,16 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
if (!reportedWorkspaceLabel && !(expectRemoteBundle === 'true' && reportedSkillRouteLabel)) { if (!reportedWorkspaceLabel && !(expectRemoteBundle === 'true' && reportedSkillRouteLabel)) {
throw new Error('Workspace-entry smoke did not report a workspace or routed-skill status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel); throw new Error('Workspace-entry smoke did not report a workspace or routed-skill status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel);
} }
if (!assistantContent.includes('desktop project-isolated workspace')) { if (!workspaceLaunchAccepted && !assistantContent.includes('desktop project-isolated workspace')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.'); throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
} }
if (!assistantContent.includes('Current project: ' + workspaceProjectName + ' (' + workspaceProjectId + ')')) { if (!workspaceLaunchAccepted && !assistantContent.includes('Current project: ' + workspaceProjectName + ' (' + workspaceProjectId + ')')) {
throw new Error('Workspace-entry smoke did not reference the expected project identity.'); throw new Error('Workspace-entry smoke did not reference the expected project identity.');
} }
if (!assistantContent.includes('Project root: ' + expectedProjectRoot)) { if (!workspaceLaunchAccepted && !assistantContent.includes('Project root: ' + expectedProjectRoot)) {
throw new Error('Workspace-entry smoke did not reference the expected project root.'); throw new Error('Workspace-entry smoke did not reference the expected project root.');
} }
if (!assistantContent.includes('Keep project context isolated to this project and session.')) { if (!workspaceLaunchAccepted && !assistantContent.includes('Keep project context isolated to this project and session.')) {
throw new Error('Workspace-entry smoke did not preserve the project isolation instruction.'); throw new Error('Workspace-entry smoke did not preserve the project isolation instruction.');
} }
if (selectedSkillId && expectRemoteBundle !== 'true') { if (selectedSkillId && expectRemoteBundle !== 'true') {
......
...@@ -919,26 +919,97 @@ try { ...@@ -919,26 +919,97 @@ try {
throw $failureError throw $failureError
} }
$pythonImportProbe = & $packagedPythonExe -c "import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright; print('ok')" $pythonImportProbeScript = @'
if ($LASTEXITCODE -ne 0 -or $pythonImportProbe -notmatch 'ok') { import json
result = {
"ok": True,
"moduleSet": False,
"greenletImport": False,
"playwrightAsyncImport": False,
"edgeTtsImport": False
}
try:
import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright, edge_tts
result["moduleSet"] = True
import greenlet
result["greenletImport"] = True
from playwright.async_api import async_playwright
assert async_playwright is not None
result["playwrightAsyncImport"] = True
result["edgeTtsImport"] = True
except Exception as exc:
result["ok"] = False
result["error"] = f"{type(exc).__name__}: {exc}"
print(json.dumps(result))
'@
$pythonImportProbePath = Join-Path (Split-Path $SmokeOutput -Parent) 'installer-smoke-python-import-probe.py'
Write-Utf8File -FilePath $pythonImportProbePath -Content $pythonImportProbeScript
$pythonImportProbeRaw = ''
$pythonImportProbeExitCode = $null
try {
$pythonImportProbeRaw = (& $packagedPythonExe $pythonImportProbePath 2>&1 | Out-String).Trim()
$pythonImportProbeExitCode = $LASTEXITCODE
} finally {
if (Test-Path $pythonImportProbePath) {
Remove-Item -LiteralPath $pythonImportProbePath -Force -ErrorAction SilentlyContinue
}
}
$pythonImportProbeJson = $null
try {
if ($pythonImportProbeRaw) {
$pythonImportProbeJson = $pythonImportProbeRaw | ConvertFrom-Json
}
} catch {
$pythonImportProbeJson = $null
}
$pythonImportProbeOk = (
$pythonImportProbeExitCode -eq 0 `
-and $null -ne $pythonImportProbeJson `
-and [bool]$pythonImportProbeJson.ok
)
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $pythonImportProbeOk
exitCode = $pythonImportProbeExitCode
output = $pythonImportProbeRaw
moduleSet = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.moduleSet)
greenletImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.greenletImport)
playwrightAsyncImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.playwrightAsyncImport)
edgeTtsImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.edgeTtsImport)
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.greenletImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.greenletImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.playwrightAsyncImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.playwrightAsyncImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.edgeTtsImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.edgeTtsImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
if (-not $pythonImportProbeOk) {
$failureStage = 'installer-materialization' $failureStage = 'installer-materialization'
$failureClassification = 'payload-validation-failure' $failureClassification = 'payload-validation-failure'
$failureError = 'Bundled Python import probe failed for the packaged runtime payload.' $failureError = 'Bundled Python import probe failed for the packaged runtime payload.'
$installerStageSummary.failureStage = $failureStage $installerStageSummary.failureStage = $failureStage
$installerStageSummary.failureClassification = $failureClassification $installerStageSummary.failureClassification = $failureClassification
$installerStageSummary.ok = $false $installerStageSummary.ok = $false
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $false
output = [string]$pythonImportProbe
}
throw $failureError throw $failureError
} }
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $true
output = [string]$pythonImportProbe
}
$installerStageSummary.packagedWorkspaceTemplate = [ordered]@{ $installerStageSummary.packagedWorkspaceTemplate = [ordered]@{
path = $packagedWorkspaceTemplate path = $packagedWorkspaceTemplate
exists = (Test-Path $packagedWorkspaceTemplate) exists = (Test-Path $packagedWorkspaceTemplate)
......
This diff is collapsed.
...@@ -16,6 +16,50 @@ function Write-Utf8File { ...@@ -16,6 +16,50 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding) [System.IO.File]::WriteAllText($filePath, $content, $encoding)
} }
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'c434d7a4-ac38-48c4-9d50-ce0e3c9aaf6b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Write-Base64File {
param(
[string]$FilePath,
[string]$Base64
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Base64))
}
function Copy-ProjectBundleSource { function Copy-ProjectBundleSource {
param( param(
[string]$SourceRoot, [string]$SourceRoot,
...@@ -43,7 +87,17 @@ function Copy-ProjectBundleSource { ...@@ -43,7 +87,17 @@ function Copy-ProjectBundleSource {
if ($excludeSet.Contains($entry.Name)) { if ($excludeSet.Contains($entry.Name)) {
continue continue
} }
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
} }
} }
...@@ -115,6 +169,17 @@ $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileNam ...@@ -115,6 +169,17 @@ $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileNam
$expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q')) $expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q'))
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs') $expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1' $electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$attachmentFixturePath = Join-Path $BaseOutputDir 'fixtures\smoke-image.png'
$expectedAttachmentDir = Join-Path $userDataPath 'projects\xhs\inputs\images\main'
$attachmentFixtureName = 'smoke-image.png'
$attachmentPayload = @(
@{
kind = 'image'
name = $attachmentFixtureName
mimeType = 'image/png'
localPath = $attachmentFixturePath
}
)
$xhsSourceCandidates = @( $xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\xhs'), (Join-Path $repoRoot 'workspace\xhs'),
(Join-Path $repoRoot '.tmp\real-api-bundle-check-2\bundle-src\xhs'), (Join-Path $repoRoot '.tmp\real-api-bundle-check-2\bundle-src\xhs'),
...@@ -131,6 +196,7 @@ if (-not $xhsSourceRoot) { ...@@ -131,6 +196,7 @@ if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')" throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
} }
Write-Base64File -FilePath $attachmentFixturePath -Base64 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aKJwAAAAASUVORK5CYII='
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs') Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) { if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force Remove-Item $bundleZipPath -Force
...@@ -158,8 +224,14 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId ...@@ -158,8 +224,14 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId
$env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle' $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page smoke validation.' $env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Xiaohongshu project bundle for expert-page smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($xhsImageProviderConfig.providers | ConvertTo-Json -Depth 10 -Compress)
$env:XHS_IMAGE_PROVIDER = $xhsImageProviderConfig.providerName
$env:XHS_IMAGE_MODEL = $xhsImageProviderConfig.modelId
$env:QJCLAW_XHS_SMOKE_MODE = '1' $env:QJCLAW_XHS_SMOKE_MODE = '1'
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1' $env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
try { try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @( Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @(
...@@ -189,6 +261,11 @@ const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8')); ...@@ -189,6 +261,11 @@ const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) { if (!result.ok) {
throw new Error(result.error || 'Smoke failed.'); throw new Error(result.error || 'Smoke failed.');
} }
function sanitizeAttachmentFileComponent(value) {
const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
return sanitized || 'attachment';
}
const expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort(); const expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort();
const finalState = result.finalState || {}; const finalState = result.finalState || {};
const sendResult = result.sendResult || {}; const sendResult = result.sendResult || {};
...@@ -261,6 +338,21 @@ if (statusLabels.some((label) => label.includes('Routing to skill'))) { ...@@ -261,6 +338,21 @@ if (statusLabels.some((label) => label.includes('Routing to skill'))) {
if (!workspaceLaunchAccepted) { if (!workspaceLaunchAccepted) {
throw new Error('Expert smoke did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels)); throw new Error('Expert smoke did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
} }
const smokeAttachments = Array.isArray(sendResult.smokeAttachments) ? sendResult.smokeAttachments : [];
if (smokeAttachments.length !== 1) {
throw new Error('Expected exactly one smoke attachment. actual=' + smokeAttachments.length);
}
const attachmentSessionId = String(sendResult.sessionId || '');
const sessionSlug = sanitizeAttachmentFileComponent(attachmentSessionId.replace(/[:]/g, '-'));
const expectedAttachmentRelativePath = 'inputs/images/main/' + sessionSlug + '-01.png';
const expectedAttachmentPath = path.join(userDataPath, 'projects', 'xhs', ...expectedAttachmentRelativePath.split('/'));
if (!fs.existsSync(expectedAttachmentPath)) {
throw new Error('Materialized attachment file was not found: ' + expectedAttachmentPath);
}
const attachmentStat = fs.statSync(expectedAttachmentPath);
if (!attachmentStat.isFile() || attachmentStat.size < 1) {
throw new Error('Materialized attachment file is empty: ' + expectedAttachmentPath);
}
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
smokeOutput, smokeOutput,
...@@ -272,6 +364,9 @@ console.log(JSON.stringify({ ...@@ -272,6 +364,9 @@ console.log(JSON.stringify({
executionPolicySource: streamSmoke.executionPolicySource || null, executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: sendResult.sessionId || null, sessionId: sendResult.sessionId || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null, selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath,
statusLabels, statusLabels,
bundleManifestPath bundleManifestPath
}, null, 2)); }, null, 2));
...@@ -288,6 +383,11 @@ finally { ...@@ -288,6 +383,11 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_PROVIDER -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_MODEL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
} }
This diff is collapsed.
...@@ -13,6 +13,109 @@ function Write-Utf8File { ...@@ -13,6 +13,109 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding) [System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
} }
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'c434d7a4-ac38-48c4-9d50-ce0e3c9aaf6b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Get-XhsQwenTextProviderConfig {
$modelId = 'qwen3.5-plus'
return [ordered]@{
providerName = 'qwen'
modelId = $modelId
providers = [ordered]@{
qwen = [ordered]@{
baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
apiKey = 'sk-cfd27311f00649438753fac04851585b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'qwen-chat'
reasoning = $false
input = @('text')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 8192
}
)
}
}
}
}
function Copy-ProjectBundleSource {
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
$projectJsonPath = Join-Path $SourceRoot 'project.json'
$excludePaths = @()
if (Test-Path $projectJsonPath) {
$projectConfig = Get-Content -Path $projectJsonPath -Raw | ConvertFrom-Json
$configuredExcludes = $projectConfig.bundlePackaging.excludePaths
if ($configuredExcludes) {
$excludePaths = @($configuredExcludes | ForEach-Object { [string]$_ })
}
}
$excludeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $excludePaths) {
if ($entry) {
[void]$excludeSet.Add($entry)
}
}
New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null
foreach ($entry in Get-ChildItem -LiteralPath $SourceRoot -Force) {
if ($excludeSet.Contains($entry.Name)) {
continue
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
function New-ExpertFixtureProject { function New-ExpertFixtureProject {
param( param(
[string]$ProjectsRoot, [string]$ProjectsRoot,
...@@ -83,7 +186,7 @@ if (Test-Path $BaseOutputDir) { ...@@ -83,7 +186,7 @@ if (Test-Path $BaseOutputDir) {
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $userDataPath, $logsPath, $bundleSourceRoot | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir, $userDataPath, $logsPath, $bundleSourceRoot | Out-Null
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) { if (Test-Path $bundleZipPath) {
Remove-Item -LiteralPath $bundleZipPath -Force Remove-Item -LiteralPath $bundleZipPath -Force
} }
...@@ -109,10 +212,30 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId ...@@ -109,10 +212,30 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId
$env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle' $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Local mock employee-config bundle for manual Xiaohongshu expert validation.' $env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Local mock employee-config bundle for manual Xiaohongshu expert validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$xhsTextProviderConfig = Get-XhsQwenTextProviderConfig
$combinedProviders = [ordered]@{}
foreach ($entry in $xhsTextProviderConfig.providers.GetEnumerator()) {
$combinedProviders[$entry.Key] = $entry.Value
}
foreach ($entry in $xhsImageProviderConfig.providers.GetEnumerator()) {
$combinedProviders[$entry.Key] = $entry.Value
}
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($combinedProviders | ConvertTo-Json -Depth 10 -Compress)
$env:XHS_LLM_PROVIDER = $xhsTextProviderConfig.providerName
$env:QWEN_BASE_URL = [string]$xhsTextProviderConfig.providers.qwen.baseUrl
$env:QWEN_API_KEY = [string]$xhsTextProviderConfig.providers.qwen.apiKey
$env:QWEN_MODEL = $xhsTextProviderConfig.modelId
$env:XHS_IMAGE_PROVIDER = $xhsImageProviderConfig.providerName
$env:XHS_IMAGE_BASE_URL = [string]$xhsImageProviderConfig.providers.volces.baseUrl
$env:XHS_IMAGE_API_KEY = [string]$xhsImageProviderConfig.providers.volces.apiKey
$env:XHS_IMAGE_MODEL = $xhsImageProviderConfig.modelId
$null = Start-Process -FilePath $AppExePath -PassThru $null = Start-Process -FilePath $AppExePath -PassThru
Write-Host "Launched packaged desktop app with local mock employee-config." Write-Host "Launched packaged desktop app with local mock employee-config."
Write-Host "User data path: $userDataPath" Write-Host "User data path: $userDataPath"
Write-Host "Bundle zip path: $bundleZipPath" Write-Host "Bundle zip path: $bundleZipPath"
Write-Host "Configured XHS text provider: $($xhsTextProviderConfig.providerName)/$($xhsTextProviderConfig.modelId)"
Write-Host "Configured XHS image provider: $($xhsImageProviderConfig.providerName)/$($xhsImageProviderConfig.modelId)"
Write-Host "Open the Experts view, select xhs, and send your manual prompt." Write-Host "Open the Experts view, select xhs, and send your manual prompt."
...@@ -17,7 +17,12 @@ ...@@ -17,7 +17,12 @@
"smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1", "smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1",
"smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1", "smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1",
"smoke:xhs-expert-cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-cloud-bundle-smoke.ps1", "smoke:xhs-expert-cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-cloud-bundle-smoke.ps1",
"smoke:douyin-expert-cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-cloud-bundle-smoke.ps1",
"smoke:local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1",
"smoke:xhs-local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1 -ProjectId xhs",
"smoke:douyin-local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1 -ProjectId douyin",
"launch:xhs-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1", "launch:xhs-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1",
"launch:douyin-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-manual-launch.ps1",
"smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1", "smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1",
"smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1", "smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1",
"smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1", "smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1",
......
...@@ -96,6 +96,7 @@ interface PythonPayloadProbeResult { ...@@ -96,6 +96,7 @@ interface PythonPayloadProbeResult {
pythonVersion?: string; pythonVersion?: string;
installedPackages: string[]; installedPackages: string[];
missingModules: string[]; missingModules: string[];
importErrors?: Record<string, string>;
error?: string; error?: string;
} }
...@@ -155,9 +156,23 @@ const PYTHON_RUNTIME_IMPORTS = [ ...@@ -155,9 +156,23 @@ const PYTHON_RUNTIME_IMPORTS = [
["pyyaml", "yaml"], ["pyyaml", "yaml"],
["pillow", "PIL"], ["pillow", "PIL"],
["python-dotenv", "dotenv"], ["python-dotenv", "dotenv"],
["playwright", "playwright"] ["greenlet", "greenlet"],
["playwright", "playwright"],
["edge-tts", "edge_tts"]
] as const; ] as const;
const PYTHON_RUNTIME_DIRECT_IMPORT_PROBES = [
["greenlet", "import greenlet"],
["playwright.async_api", "from playwright.async_api import async_playwright"]
] as const;
const PYTHON_RUNTIME_PROBE_NAMES = [
...new Set([
...PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
...PYTHON_RUNTIME_DIRECT_IMPORT_PROBES.map(([probeName]) => probeName)
])
];
function formatExecError(error: unknown): string { function formatExecError(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
const typedError = error as Error & { code?: number | string; stderr?: string }; const typedError = error as Error & { code?: number | string; stderr?: string };
...@@ -365,6 +380,13 @@ function formatPayloadIssue( ...@@ -365,6 +380,13 @@ function formatPayloadIssue(
} }
if (pythonProbe.missingModules.length > 0) { if (pythonProbe.missingModules.length > 0) {
const importErrors = Object.entries(pythonProbe.importErrors ?? {});
if (importErrors.length > 0) {
const formattedImportErrors = importErrors
.map(([probeName, message]) => `${probeName} (${message})`)
.join("; ");
return `the bundled Python dependency probe failed. Missing or broken imports: ${pythonProbe.missingModules.join(", ")}. Import errors: ${formattedImportErrors}`;
}
return `the bundled Python dependency probe failed. Missing modules: ${pythonProbe.missingModules.join(", ")}`; return `the bundled Python dependency probe failed. Missing modules: ${pythonProbe.missingModules.join(", ")}`;
} }
...@@ -375,19 +397,30 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo ...@@ -375,19 +397,30 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
const inlineScript = [ const inlineScript = [
"import importlib.util, json, sys", "import importlib.util, json, sys",
`modules = ${JSON.stringify(PYTHON_RUNTIME_IMPORTS)}`, `modules = ${JSON.stringify(PYTHON_RUNTIME_IMPORTS)}`,
`direct_imports = ${JSON.stringify(PYTHON_RUNTIME_DIRECT_IMPORT_PROBES)}`,
"installed = []", "installed = []",
"missing = []", "missing = []",
"import_errors = {}",
"for package_name, module_name in modules:", "for package_name, module_name in modules:",
" spec = importlib.util.find_spec(module_name)", " spec = importlib.util.find_spec(module_name)",
" if spec is None:", " if spec is None:",
" missing.append(package_name)", " if package_name not in missing:",
" missing.append(package_name)",
" else:", " else:",
" installed.append(package_name)", " installed.append(package_name)",
"for probe_name, statement in direct_imports:",
" try:",
" exec(statement, {})",
" except Exception as exc:",
" if probe_name not in missing:",
" missing.append(probe_name)",
" import_errors[probe_name] = f\"{type(exc).__name__}: {exc}\"",
"print(json.dumps({", "print(json.dumps({",
" 'ready': len(missing) == 0,", " 'ready': len(missing) == 0,",
" 'pythonVersion': sys.version.split()[0],", " 'pythonVersion': sys.version.split()[0],",
" 'installedPackages': installed,", " 'installedPackages': installed,",
" 'missingModules': missing", " 'missingModules': missing,",
" 'importErrors': import_errors",
"}))" "}))"
].join("\n"); ].join("\n");
...@@ -399,13 +432,15 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo ...@@ -399,13 +432,15 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
pythonVersion: parsed.pythonVersion, pythonVersion: parsed.pythonVersion,
installedPackages: parsed.installedPackages ?? [], installedPackages: parsed.installedPackages ?? [],
missingModules: parsed.missingModules ?? [], missingModules: parsed.missingModules ?? [],
importErrors: parsed.importErrors ?? {},
error: parsed.error error: parsed.error
}; };
} catch (error) { } catch (error) {
return { return {
ready: false, ready: false,
installedPackages: [], installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName), missingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
importErrors: {},
error: formatExecError(error) error: formatExecError(error)
}; };
} }
...@@ -461,6 +496,7 @@ async function probePythonPayloadFromManifest(pythonManifestPath: string): Promi ...@@ -461,6 +496,7 @@ async function probePythonPayloadFromManifest(pythonManifestPath: string): Promi
pythonVersion: typeof parsed.pythonVersion === "string" ? parsed.pythonVersion : undefined, pythonVersion: typeof parsed.pythonVersion === "string" ? parsed.pythonVersion : undefined,
installedPackages, installedPackages,
missingModules, missingModules,
importErrors: {},
error: undefined error: undefined
}; };
} catch { } catch {
...@@ -578,7 +614,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -578,7 +614,7 @@ export class RuntimeManager extends EventEmitter {
pythonReady: false, pythonReady: false,
pythonVersion: undefined, pythonVersion: undefined,
installedPythonPackages: [], installedPythonPackages: [],
pythonMissingModules: [...PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName)], pythonMissingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
runtimeDataDir: resolved.runtimeDataDir, runtimeDataDir: resolved.runtimeDataDir,
runtimeStateDir: resolved.runtimeStateDir, runtimeStateDir: resolved.runtimeStateDir,
runtimeLogsDir: resolved.runtimeLogsDir, runtimeLogsDir: resolved.runtimeLogsDir,
...@@ -662,7 +698,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -662,7 +698,8 @@ export class RuntimeManager extends EventEmitter {
: { : {
ready: false, ready: false,
installedPackages: [], installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName), missingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
importErrors: {},
error: undefined error: undefined
}; };
......
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