Commit 6dec17bf authored by AI-甘富林's avatar AI-甘富林

feat(客户端): 增加专家模型配置与品牌化打包支持

parent 785aa93a
"use strict";
const { access, copyFile, mkdir } = require("node:fs/promises");
const { constants } = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const RCEDIT_FILE_NAME = "rcedit-x64.exe";
function pathExists(targetPath) {
return access(targetPath, constants.F_OK)
.then(() => true)
.catch(() => false);
}
async function firstExisting(paths) {
for (const candidate of paths) {
if (candidate && await pathExists(candidate)) {
return candidate;
}
}
return null;
}
async function findRceditUnder(rootDir) {
if (!rootDir || !await pathExists(rootDir)) {
return null;
}
const { readdir } = require("node:fs/promises");
const entries = await readdir(rootDir, { withFileTypes: true });
const candidates = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(rootDir, entry.name, RCEDIT_FILE_NAME));
return firstExisting(candidates);
}
function toWindowsVersion(rawVersion) {
const numericParts = String(rawVersion || "")
.split(/[^0-9]+/)
.filter(Boolean)
.slice(0, 4)
.map((part) => String(Number.parseInt(part, 10) || 0));
while (numericParts.length < 4) {
numericParts.push("0");
}
return numericParts.join(".");
}
async function ensureWorkspaceCachedRcedit(sourcePath, projectDir) {
const cacheDir = path.join(projectDir, "..", "..", ".cache", "electron-builder", "tools");
const cachedPath = path.join(cacheDir, RCEDIT_FILE_NAME);
if (await pathExists(cachedPath)) {
return cachedPath;
}
await mkdir(cacheDir, { recursive: true });
await copyFile(sourcePath, cachedPath);
return cachedPath;
}
async function resolveRceditPath(projectDir) {
const envOverride = process.env.QJCLAW_RCEDIT_PATH?.trim();
if (envOverride && await pathExists(envOverride)) {
return envOverride;
}
const workspaceCached = path.join(projectDir, "..", "..", ".cache", "electron-builder", "tools", RCEDIT_FILE_NAME);
if (await pathExists(workspaceCached)) {
return workspaceCached;
}
const localAppData = process.env.LOCALAPPDATA?.trim();
const localCacheRoot = localAppData
? path.join(localAppData, "electron-builder", "Cache", "winCodeSign")
: null;
const localCached = await findRceditUnder(localCacheRoot);
if (localCached) {
return ensureWorkspaceCachedRcedit(localCached, projectDir);
}
const workspaceCacheRoot = path.join(projectDir, "..", "..", ".cache", "electron-builder", "Cache", "winCodeSign");
const workspaceCachedNested = await findRceditUnder(workspaceCacheRoot);
if (workspaceCachedNested) {
return workspaceCachedNested;
}
return null;
}
function runRcedit(executablePath, args) {
return new Promise((resolve, reject) => {
const child = spawn(executablePath, args, { stdio: "pipe" });
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`rcedit exited with code ${code}: ${stderr.trim()}`));
});
});
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== "win32") {
return;
}
if (process.env.QJCLAW_SKIP_WINDOWS_BRANDING_PATCH === "1") {
return;
}
const projectDir = context.packager.projectDir;
const productFilename = context.packager.appInfo.productFilename;
const executableName = productFilename.endsWith(".exe")
? productFilename
: `${productFilename}.exe`;
const executablePath = path.join(context.appOutDir, executableName);
const iconPath = path.join(projectDir, "build", "icons", "brand-icon.ico");
const [exeExists, iconExists] = await Promise.all([
pathExists(executablePath),
pathExists(iconPath)
]);
if (!exeExists) {
throw new Error(`Windows branding patch could not find packaged executable: ${executablePath}`);
}
if (!iconExists) {
throw new Error(`Windows branding patch could not find icon: ${iconPath}`);
}
const rceditPath = await resolveRceditPath(projectDir);
if (!rceditPath) {
throw new Error(
"Windows branding patch could not find rcedit-x64.exe. " +
"Set QJCLAW_RCEDIT_PATH or install electron-builder winCodeSign cache once on this machine."
);
}
const productName = context.packager.appInfo.productName;
const version = toWindowsVersion(context.packager.appInfo.version);
const companyName = productName;
await runRcedit(rceditPath, [
executablePath,
"--set-version-string", "FileDescription", productName,
"--set-version-string", "ProductName", productName,
"--set-version-string", "CompanyName", companyName,
"--set-version-string", "InternalName", productName,
"--set-version-string", "OriginalFilename", executableName,
"--set-file-version", version,
"--set-product-version", version,
"--set-icon", iconPath
]);
};
appId: com.qianjiangclaw.desktop appId: com.qianjiangclaw.desktop
productName: QianjiangClaw productName: 千匠问天
compression: store compression: store
asar: true asar: true
asarUnpack: asarUnpack:
...@@ -8,13 +8,22 @@ asarUnpack: ...@@ -8,13 +8,22 @@ asarUnpack:
directories: directories:
output: ../../dist/installer output: ../../dist/installer
artifactName: ${productName}-Setup-${version}.${ext} artifactName: ${productName}-Setup-${version}.${ext}
afterPack: build/hooks/after-pack-branding.cjs
files: files:
- dist/**/* - dist/**/*
- package.json - package.json
extraResources: extraResources:
- from: ../../vendor/openclaw-runtime - from: ../../vendor/openclaw-runtime
to: vendor/openclaw-runtime to: vendor/openclaw-runtime
- from: bootstrap
to: bootstrap
- from: assets/expert-prompts
to: expert-prompts
- from: build/icons/brand-icon.ico
to: brand-icon.ico
win: win:
executableName: 千匠问天
icon: build/icons/brand-icon.ico
signAndEditExecutable: false signAndEditExecutable: false
target: target:
- nsis - nsis
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
"name": "@qjclaw/desktop", "name": "@qjclaw/desktop",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "QianjiangClaw desktop client", "description": "千匠问天 desktop client",
"author": "QianjiangClaw", "author": "千匠问天",
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"build": "tsup --config tsup.config.ts", "build": "tsup --config tsup.config.ts",
......
This diff is collapsed.
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { AppConfig, ExpertModelConfig, RuntimeModePreference, SaveConfigInput, SetupMode } from "@qjclaw/shared-types"; import {
FIXED_DIGITAL_HUMAN_CONFIG,
FIXED_EXPERT_MODEL_ENDPOINTS,
type AppConfig,
type DigitalHumanModelConfig,
type ExpertModelConfig,
type ModelEndpointConfig,
type RuntimeModePreference,
type SaveConfigInput,
type SetupMode
} from "@qjclaw/shared-types";
export type RuntimeCloudApiBaseUrlSource = "config" | "env" | "default"; export type RuntimeCloudApiBaseUrlSource = "config" | "env" | "default";
...@@ -46,7 +56,12 @@ interface LegacyConfig { ...@@ -46,7 +56,12 @@ interface LegacyConfig {
cloudApiBaseUrl?: string; cloudApiBaseUrl?: string;
runtimeCloudApiBaseUrl?: string; runtimeCloudApiBaseUrl?: string;
runtimeMode?: RuntimeModePreference; runtimeMode?: RuntimeModePreference;
expertModelConfig?: Partial<Record<keyof ExpertModelConfig, Partial<ExpertModelConfig[keyof ExpertModelConfig]>>>; expertModelConfig?: {
image?: Partial<ModelEndpointConfig>;
video?: Partial<ModelEndpointConfig>;
copywriting?: Partial<ModelEndpointConfig>;
digitalHuman?: Partial<DigitalHumanModelConfig>;
};
} }
function normalizeGatewayUrl(raw: string): string { function normalizeGatewayUrl(raw: string): string {
...@@ -130,20 +145,31 @@ function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string { ...@@ -130,20 +145,31 @@ function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
return normalized; return normalized;
} }
function createFixedModelEndpointConfig(kind: keyof typeof FIXED_EXPERT_MODEL_ENDPOINTS): ModelEndpointConfig {
const config = FIXED_EXPERT_MODEL_ENDPOINTS[kind];
return {
baseUrl: config.baseUrl,
apiKeyConfigured: false,
modelId: config.modelId
};
}
function createDefaultDigitalHumanModelConfig(): DigitalHumanModelConfig {
return {
...FIXED_DIGITAL_HUMAN_CONFIG,
volcAccessKeyConfigured: false,
volcSecretKeyConfigured: false,
qiniuAccessKeyConfigured: false,
qiniuSecretKeyConfigured: false
};
}
function createDefaultExpertModelConfig(): ExpertModelConfig { function createDefaultExpertModelConfig(): ExpertModelConfig {
return { return {
image: { image: createFixedModelEndpointConfig("image"),
baseUrl: "", video: createFixedModelEndpointConfig("video"),
apiKeyConfigured: false copywriting: createFixedModelEndpointConfig("copywriting"),
}, digitalHuman: createDefaultDigitalHumanModelConfig()
video: {
baseUrl: "",
apiKeyConfigured: false
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false
}
}; };
} }
...@@ -153,16 +179,26 @@ function mergeExpertModelConfig( ...@@ -153,16 +179,26 @@ function mergeExpertModelConfig(
): ExpertModelConfig { ): ExpertModelConfig {
return { return {
image: { image: {
baseUrl: input?.image?.baseUrl?.trim() ?? current.image.baseUrl, baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.image.baseUrl,
apiKeyConfigured: typeof input?.image?.apiKey === "string" ? Boolean(input.image.apiKey.trim()) : current.image.apiKeyConfigured apiKeyConfigured: typeof input?.image?.apiKey === "string" ? Boolean(input.image.apiKey.trim()) : current.image.apiKeyConfigured,
modelId: FIXED_EXPERT_MODEL_ENDPOINTS.image.modelId
}, },
video: { video: {
baseUrl: input?.video?.baseUrl?.trim() ?? current.video.baseUrl, baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.video.baseUrl,
apiKeyConfigured: typeof input?.video?.apiKey === "string" ? Boolean(input.video.apiKey.trim()) : current.video.apiKeyConfigured apiKeyConfigured: typeof input?.video?.apiKey === "string" ? Boolean(input.video.apiKey.trim()) : current.video.apiKeyConfigured,
modelId: FIXED_EXPERT_MODEL_ENDPOINTS.video.modelId
}, },
copywriting: { copywriting: {
baseUrl: input?.copywriting?.baseUrl?.trim() ?? current.copywriting.baseUrl, baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.copywriting.baseUrl,
apiKeyConfigured: typeof input?.copywriting?.apiKey === "string" ? Boolean(input.copywriting.apiKey.trim()) : current.copywriting.apiKeyConfigured apiKeyConfigured: typeof input?.copywriting?.apiKey === "string" ? Boolean(input.copywriting.apiKey.trim()) : current.copywriting.apiKeyConfigured,
modelId: FIXED_EXPERT_MODEL_ENDPOINTS.copywriting.modelId
},
digitalHuman: {
...FIXED_DIGITAL_HUMAN_CONFIG,
volcAccessKeyConfigured: typeof input?.digitalHuman?.volcAccessKey === "string" ? Boolean(input.digitalHuman.volcAccessKey.trim()) : current.digitalHuman.volcAccessKeyConfigured,
volcSecretKeyConfigured: typeof input?.digitalHuman?.volcSecretKey === "string" ? Boolean(input.digitalHuman.volcSecretKey.trim()) : current.digitalHuman.volcSecretKeyConfigured,
qiniuAccessKeyConfigured: typeof input?.digitalHuman?.qiniuAccessKey === "string" ? Boolean(input.digitalHuman.qiniuAccessKey.trim()) : current.digitalHuman.qiniuAccessKeyConfigured,
qiniuSecretKeyConfigured: typeof input?.digitalHuman?.qiniuSecretKey === "string" ? Boolean(input.digitalHuman.qiniuSecretKey.trim()) : current.digitalHuman.qiniuSecretKeyConfigured
} }
}; };
} }
...@@ -250,16 +286,26 @@ export class AppConfigService { ...@@ -250,16 +286,26 @@ export class AppConfigService {
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE), runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE),
expertModelConfig: { expertModelConfig: {
image: { image: {
baseUrl: config.expertModelConfig?.image?.baseUrl?.trim() ?? defaultExpertModelConfig.image.baseUrl, baseUrl: defaultExpertModelConfig.image.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.image?.apiKeyConfigured) apiKeyConfigured: Boolean(config.expertModelConfig?.image?.apiKeyConfigured),
modelId: defaultExpertModelConfig.image.modelId
}, },
video: { video: {
baseUrl: config.expertModelConfig?.video?.baseUrl?.trim() ?? defaultExpertModelConfig.video.baseUrl, baseUrl: defaultExpertModelConfig.video.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.video?.apiKeyConfigured) apiKeyConfigured: Boolean(config.expertModelConfig?.video?.apiKeyConfigured),
modelId: defaultExpertModelConfig.video.modelId
}, },
copywriting: { copywriting: {
baseUrl: config.expertModelConfig?.copywriting?.baseUrl?.trim() ?? defaultExpertModelConfig.copywriting.baseUrl, baseUrl: defaultExpertModelConfig.copywriting.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.copywriting?.apiKeyConfigured) apiKeyConfigured: Boolean(config.expertModelConfig?.copywriting?.apiKeyConfigured),
modelId: defaultExpertModelConfig.copywriting.modelId
},
digitalHuman: {
...FIXED_DIGITAL_HUMAN_CONFIG,
volcAccessKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.volcAccessKeyConfigured),
volcSecretKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.volcSecretKeyConfigured),
qiniuAccessKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuAccessKeyConfigured),
qiniuSecretKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuSecretKeyConfigured)
} }
} }
}; };
......
...@@ -883,9 +883,6 @@ export class OpenClawConfigClient { ...@@ -883,9 +883,6 @@ export class OpenClawConfigClient {
if (!payload.config_version) { if (!payload.config_version) {
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u0020\u0063\u006f\u006e\u0066\u0069\u0067\u005f\u0076\u0065\u0072\u0073\u0069\u006f\u006e\u3002"); throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u0020\u0063\u006f\u006e\u0066\u0069\u0067\u005f\u0076\u0065\u0072\u0073\u0069\u006f\u006e\u3002");
} }
if (!payload.llm?.provider?.base_url || !payload.llm?.provider?.api_key || !payload.llm?.model_id) {
throw new Error("\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u914d\u7f6e\u63a5\u53e3\u7f3a\u5c11\u0020\u006c\u006c\u006d\u002e\u0070\u0072\u006f\u0076\u0069\u0064\u0065\u0072\u002e\u0062\u0061\u0073\u0065\u005f\u0075\u0072\u006c\u0020\u002f\u0020\u0061\u0070\u0069\u005f\u006b\u0065\u0079\u0020\u002f\u0020\u006d\u006f\u0064\u0065\u006c\u005f\u0069\u0064\u3002");
}
} }
private toSummary(payload: OpenClawEmployeeConfigPayload, fetchedAt: string): RuntimeCloudConfigSummary { private toSummary(payload: OpenClawEmployeeConfigPayload, fetchedAt: string): RuntimeCloudConfigSummary {
...@@ -914,96 +911,7 @@ export class OpenClawConfigClient { ...@@ -914,96 +911,7 @@ export class OpenClawConfigClient {
private mergeConfig(defaultConfig: Record<string, unknown>, payload: OpenClawEmployeeConfigPayload): Record<string, unknown> { private mergeConfig(defaultConfig: Record<string, unknown>, payload: OpenClawEmployeeConfigPayload): Record<string, unknown> {
const nextConfig = cloneJson(defaultConfig); const nextConfig = cloneJson(defaultConfig);
const providerKey = "openclaw-cloud"; void payload;
const modelId = payload.llm?.model_id ?? "unknown-model";
const modelLabel = payload.llm?.display_name ?? modelId;
const apiMode = payload.llm?.provider?.provider_type === "anthropic" ? "anthropic-messages" : "openai-completions";
const modelsSection = asRecord(nextConfig.models);
const providers = asRecord(modelsSection.providers);
const existingProvider = asRecord(providers[providerKey]);
const authSection = asRecord(nextConfig.auth);
const authProfiles = asRecord(authSection.profiles);
const agentsSection = asRecord(nextConfig.agents);
const agentDefaults = asRecord(agentsSection.defaults);
const modelDefaults = asRecord(agentDefaults.model);
const modelAliases = asRecord(agentDefaults.models);
const defaultProviders = asRecord(asRecord(defaultConfig.models).providers);
let templateModel: Record<string, unknown> | undefined;
for (const providerValue of Object.values(defaultProviders)) {
const provider = asRecord(providerValue);
const providerModels = Array.isArray(provider.models) ? provider.models : [];
const matched = providerModels.find((candidate) => {
const model = asRecord(candidate);
return model.id === modelId || model.name === modelId || model.name === modelLabel;
});
if (matched) {
templateModel = asRecord(matched);
break;
}
}
const inputTypes = Array.isArray(templateModel?.input)
? templateModel.input.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
const templateContextWindow = typeof templateModel?.contextWindow === "number" && templateModel.contextWindow > 0
? templateModel.contextWindow
: undefined;
const maxTokens = typeof payload.llm?.max_tokens === "number" && payload.llm.max_tokens > 0
? payload.llm.max_tokens
: typeof templateModel?.maxTokens === "number" && templateModel.maxTokens > 0
? templateModel.maxTokens
: 2048;
const rawCloudContextWindow = typeof payload.llm?.max_context_length === "number" && payload.llm.max_context_length > 0
? payload.llm.max_context_length
: undefined;
const contextWindow = rawCloudContextWindow && rawCloudContextWindow > maxTokens
? rawCloudContextWindow
: templateContextWindow;
authProfiles[`${providerKey}:default`] = {
provider: providerKey,
mode: "api_key"
};
authSection.profiles = authProfiles;
nextConfig.auth = authSection;
providers[providerKey] = {
...existingProvider,
baseUrl: payload.llm?.provider?.base_url,
apiKey: payload.llm?.provider?.api_key,
api: apiMode,
models: [
{
id: modelId,
name: modelLabel,
reasoning: typeof templateModel?.reasoning === "boolean" ? templateModel.reasoning : false,
input: inputTypes.length > 0 ? inputTypes : ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0
},
...(typeof contextWindow === "number" ? { contextWindow } : {}),
maxTokens
}
]
};
modelsSection.mode = "merge";
modelsSection.providers = providers;
nextConfig.models = modelsSection;
modelDefaults.primary = `${providerKey}/${modelId}`;
modelDefaults.fallbacks = [];
modelAliases[`${providerKey}/${modelId}`] = {
alias: modelLabel
};
agentDefaults.model = modelDefaults;
agentDefaults.models = modelAliases;
agentsSection.defaults = agentDefaults;
nextConfig.agents = agentsSection;
return mergeAdditionalModelProvidersFromEnv(nextConfig); return mergeAdditionalModelProvidersFromEnv(nextConfig);
} }
} }
......
...@@ -93,6 +93,7 @@ export class DiagnosticsService { ...@@ -93,6 +93,7 @@ export class DiagnosticsService {
provider: input.config.provider, provider: input.config.provider,
baseUrl: input.config.baseUrl, baseUrl: input.config.baseUrl,
defaultModel: input.config.defaultModel, defaultModel: input.config.defaultModel,
expertModelConfig: input.config.expertModelConfig,
workspacePath: input.config.workspacePath, workspacePath: input.config.workspacePath,
gatewayUrl: input.config.gatewayUrl, gatewayUrl: input.config.gatewayUrl,
cloudApiBaseUrl: input.config.cloudApiBaseUrl, cloudApiBaseUrl: input.config.cloudApiBaseUrl,
......
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { FIXED_DIGITAL_HUMAN_CONFIG, type AppConfig } from "@qjclaw/shared-types";
export interface ProjectModelRuntimeSecrets {
copywritingApiKey?: string;
imageApiKey?: string;
videoApiKey?: string;
digitalHumanVolcAccessKey?: string;
digitalHumanVolcSecretKey?: string;
digitalHumanQiniuAccessKey?: string;
digitalHumanQiniuSecretKey?: string;
}
export interface ProjectModelRuntimePreparation {
env: Record<string, string>;
envFileContent: string;
summary: {
envKeys: string[];
copywritingBaseUrl?: string;
copywritingModelId?: string;
imageBaseUrl?: string;
imageModelId?: string;
};
}
export interface ProjectModelRuntimeValidationResult {
ok: boolean;
message?: string;
missingFields: string[];
}
const XHS_PROJECT_IDS = new Set([
"xhs",
"xiaohongshu",
"xiaohongshu-writer"
]);
const DOUYIN_PROJECT_IDS = new Set([
"douyin"
]);
const CLIENT_SETTINGS_HINT = "请在客户端设置中完成模型配置后重试。";
function normalizeValue(raw?: string): string {
return raw?.trim() ?? "";
}
function withoutTrailingSlash(raw?: string): string {
return normalizeValue(raw).replace(/\/+$/, "");
}
function normalizeChatCompletionsBaseUrl(raw?: string): string {
const baseUrl = normalizeOpenAiCompatibleBaseUrl(raw);
if (!baseUrl) {
return "";
}
return baseUrl.endsWith("/chat/completions")
? baseUrl
: `${baseUrl}/chat/completions`;
}
function normalizeOpenAiCompatibleBaseUrl(raw?: string): string {
const baseUrl = withoutTrailingSlash(raw);
if (!baseUrl) {
return "";
}
return baseUrl.endsWith("/chat/completions")
? baseUrl.slice(0, -"/chat/completions".length)
: baseUrl;
}
function normalizeArkBaseUrl(raw?: string): string {
const baseUrl = withoutTrailingSlash(raw);
if (!baseUrl) {
return "";
}
return baseUrl.endsWith("/images/generations")
? baseUrl.slice(0, -"/images/generations".length)
: baseUrl;
}
function encodeEnvValue(value: string): string {
return JSON.stringify(value);
}
function formatMissingSection(label: string, missing: string[]): string | null {
if (missing.length === 0) {
return null;
}
return `${label}缺少 ${missing.join("、")}`;
}
export function validateProjectModelRuntime(
projectId: string,
config: Pick<AppConfig, "expertModelConfig">,
secrets: ProjectModelRuntimeSecrets
): ProjectModelRuntimeValidationResult {
const normalizedProjectId = normalizeValue(projectId).toLowerCase();
const copywritingBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.expertModelConfig.copywriting.baseUrl);
const copywritingModelId = normalizeValue(config.expertModelConfig.copywriting.modelId);
const copywritingApiKey = normalizeValue(secrets.copywritingApiKey);
const imageBaseUrl = withoutTrailingSlash(config.expertModelConfig.image.baseUrl);
const imageModelId = normalizeValue(config.expertModelConfig.image.modelId);
const imageApiKey = normalizeValue(secrets.imageApiKey);
if (!XHS_PROJECT_IDS.has(normalizedProjectId) && !DOUYIN_PROJECT_IDS.has(normalizedProjectId)) {
return {
ok: true,
missingFields: []
};
}
const copywritingMissing: string[] = [];
if (!copywritingBaseUrl) {
copywritingMissing.push("baseUrl");
}
if (!copywritingApiKey) {
copywritingMissing.push("apiKey");
}
if (!copywritingModelId) {
copywritingMissing.push("modelId");
}
const imageMissing: string[] = [];
if (!imageBaseUrl) {
imageMissing.push("baseUrl");
}
if (!imageApiKey) {
imageMissing.push("apiKey");
}
if (!imageModelId) {
imageMissing.push("modelId");
}
const sections = [
formatMissingSection("文案模型", copywritingMissing),
formatMissingSection("生图模型", imageMissing)
].filter((value): value is string => Boolean(value));
if (sections.length === 0) {
return {
ok: true,
missingFields: []
};
}
const projectLabel = XHS_PROJECT_IDS.has(normalizedProjectId) ? "小红书专家" : "抖音专家";
return {
ok: false,
message: `${projectLabel}缺少客户端模型配置:${sections.join(";")}${CLIENT_SETTINGS_HINT}`,
missingFields: [
...copywritingMissing.map((field) => `copywriting.${field}`),
...imageMissing.map((field) => `image.${field}`)
]
};
}
export function buildProjectModelRuntime(
projectId: string,
config: Pick<AppConfig, "expertModelConfig">,
secrets: ProjectModelRuntimeSecrets
): ProjectModelRuntimePreparation {
const normalizedProjectId = normalizeValue(projectId).toLowerCase();
const validation = validateProjectModelRuntime(projectId, config, secrets);
if (!validation.ok) {
throw new Error(validation.message);
}
const copywritingBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.expertModelConfig.copywriting.baseUrl);
const copywritingModelId = normalizeValue(config.expertModelConfig.copywriting.modelId);
const copywritingApiKey = normalizeValue(secrets.copywritingApiKey);
const imageBaseUrl = withoutTrailingSlash(config.expertModelConfig.image.baseUrl);
const imageModelId = normalizeValue(config.expertModelConfig.image.modelId);
const imageApiKey = normalizeValue(secrets.imageApiKey);
const videoBaseUrl = withoutTrailingSlash(config.expertModelConfig.video.baseUrl);
const videoModelId = normalizeValue(config.expertModelConfig.video.modelId);
const videoApiKey = normalizeValue(secrets.videoApiKey);
const digitalHumanVolcAccessKey = normalizeValue(secrets.digitalHumanVolcAccessKey);
const digitalHumanVolcSecretKey = normalizeValue(secrets.digitalHumanVolcSecretKey);
const digitalHumanQiniuAccessKey = normalizeValue(secrets.digitalHumanQiniuAccessKey);
const digitalHumanQiniuSecretKey = normalizeValue(secrets.digitalHumanQiniuSecretKey);
const env: Record<string, string> = {};
if (XHS_PROJECT_IDS.has(normalizedProjectId)) {
if (copywritingBaseUrl) {
env.QWEN_BASE_URL = copywritingBaseUrl;
}
if (copywritingApiKey) {
env.QWEN_API_KEY = copywritingApiKey;
}
if (copywritingModelId) {
env.QWEN_MODEL = copywritingModelId;
env.QWEN_VISION_MODEL = copywritingModelId;
}
if (imageBaseUrl || imageApiKey || imageModelId) {
env.XHS_IMAGE_PROVIDER = "env:xhsImage";
}
if (imageBaseUrl) {
env.XHS_IMAGE_BASE_URL = imageBaseUrl;
}
if (imageApiKey) {
env.XHS_IMAGE_API_KEY = imageApiKey;
}
if (imageModelId) {
env.XHS_IMAGE_MODEL = imageModelId;
}
}
if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) {
const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl);
const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl);
if (writerBaseUrl) {
env.DOUYIN_WRITER_LLM_BASE_URL = writerBaseUrl;
}
if (copywritingApiKey) {
env.DASHSCOPE_API_KEY = copywritingApiKey;
env.QWEN_API_KEY = copywritingApiKey;
}
if (copywritingModelId) {
env.DOUYIN_WRITER_LLM_MODEL = copywritingModelId;
}
if (seedreamBaseUrl) {
env.SEEDREAM_ARK_BASE_URL = seedreamBaseUrl;
}
if (imageApiKey) {
env.SEEDREAM_ARK_API_KEY = imageApiKey;
}
if (imageModelId) {
env.SEEDREAM_MODEL = imageModelId;
}
if (videoBaseUrl) {
env.SEEDANCE_ARK_BASE_URL = videoBaseUrl;
}
if (videoApiKey) {
env.SEEDANCE_ARK_API_KEY = videoApiKey;
}
if (videoModelId) {
env.SEEDANCE_MODEL = videoModelId;
}
if (digitalHumanVolcAccessKey) {
env.OMNIHUMAN_VOLC_ACCESS_KEY = digitalHumanVolcAccessKey;
}
if (digitalHumanVolcSecretKey) {
env.OMNIHUMAN_VOLC_SECRET_KEY = digitalHumanVolcSecretKey;
}
env.OMNIHUMAN_VOLC_REGION = FIXED_DIGITAL_HUMAN_CONFIG.volcRegion;
env.OMNIHUMAN_VOLC_SERVICE = FIXED_DIGITAL_HUMAN_CONFIG.volcService;
env.OMNIHUMAN_VOLC_HOST = FIXED_DIGITAL_HUMAN_CONFIG.volcHost;
env.OMNIHUMAN_VOLC_SCHEME = FIXED_DIGITAL_HUMAN_CONFIG.volcScheme;
env.OMNIHUMAN_TTS_VOICE = FIXED_DIGITAL_HUMAN_CONFIG.ttsVoice;
if (digitalHumanQiniuAccessKey) {
env.OMNIHUMAN_QINIU_ACCESS_KEY = digitalHumanQiniuAccessKey;
}
if (digitalHumanQiniuSecretKey) {
env.OMNIHUMAN_QINIU_SECRET_KEY = digitalHumanQiniuSecretKey;
}
env.OMNIHUMAN_QINIU_BUCKET = FIXED_DIGITAL_HUMAN_CONFIG.qiniuBucket;
env.OMNIHUMAN_QINIU_DOMAIN = FIXED_DIGITAL_HUMAN_CONFIG.qiniuDomain;
env.OMNIHUMAN_QINIU_KEY_PREFIX = FIXED_DIGITAL_HUMAN_CONFIG.qiniuKeyPrefix;
}
const envKeys = Object.keys(env).sort();
const lines = [
"# Generated by qjclaw desktop runtime model injection.",
...envKeys.map((key) => `${key}=${encodeEnvValue(env[key])}`)
];
return {
env,
envFileContent: lines.join("\n") + "\n",
summary: {
envKeys,
...(copywritingBaseUrl ? { copywritingBaseUrl } : {}),
...(copywritingModelId ? { copywritingModelId } : {}),
...(imageBaseUrl ? { imageBaseUrl } : {}),
...(imageModelId ? { imageModelId } : {})
}
};
}
export async function materializeProjectModelRuntime(
projectRoot: string,
runtime: ProjectModelRuntimePreparation
): Promise<string> {
const memoryRoot = path.join(projectRoot, "memory");
const envFilePath = path.join(memoryRoot, "project.env");
await mkdir(memoryRoot, { recursive: true });
await writeFile(envFilePath, runtime.envFileContent, "utf8");
return envFilePath;
}
...@@ -12,6 +12,7 @@ interface ProjectWorkspaceExecutionInput { ...@@ -12,6 +12,7 @@ interface ProjectWorkspaceExecutionInput {
userPrompt?: string; userPrompt?: string;
attachments?: ProjectResolvedAttachment[]; attachments?: ProjectResolvedAttachment[];
runId?: string; runId?: string;
extraEnv?: Record<string, string>;
} }
interface ProjectWorkspaceExecutionCallbacks { interface ProjectWorkspaceExecutionCallbacks {
...@@ -285,7 +286,8 @@ export class ProjectWorkspaceExecutorService { ...@@ -285,7 +286,8 @@ export class ProjectWorkspaceExecutorService {
].filter(Boolean).join(path.delimiter), ].filter(Boolean).join(path.delimiter),
QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []), QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []),
QJC_PROJECT_MAIN_IMAGE: input.attachments?.find((attachment) => attachment.kind === "image")?.projectPath ?? "", QJC_PROJECT_MAIN_IMAGE: input.attachments?.find((attachment) => attachment.kind === "image")?.projectPath ?? "",
...(automationCommand?.env ?? {}) ...(automationCommand?.env ?? {}),
...(input.extraEnv ?? {})
}; };
const spawnOptions = { const spawnOptions = {
cwd: input.projectRoot, cwd: input.projectRoot,
......
...@@ -10,6 +10,10 @@ interface SecretRecord { ...@@ -10,6 +10,10 @@ interface SecretRecord {
imageModelApiKey?: string; imageModelApiKey?: string;
videoModelApiKey?: string; videoModelApiKey?: string;
copywritingModelApiKey?: string; copywritingModelApiKey?: string;
digitalHumanVolcAccessKey?: string;
digitalHumanVolcSecretKey?: string;
digitalHumanQiniuAccessKey?: string;
digitalHumanQiniuSecretKey?: string;
} }
interface SecretAccessor { interface SecretAccessor {
...@@ -17,7 +21,18 @@ interface SecretAccessor { ...@@ -17,7 +21,18 @@ interface SecretAccessor {
set(secretName: SecretName, value?: string): Promise<void>; set(secretName: SecretName, value?: string): Promise<void>;
} }
type SecretName = "apiKey" | "gatewayToken" | "deviceToken" | "authToken" | "imageModelApiKey" | "videoModelApiKey" | "copywritingModelApiKey"; type SecretName =
| "apiKey"
| "gatewayToken"
| "deviceToken"
| "authToken"
| "imageModelApiKey"
| "videoModelApiKey"
| "copywritingModelApiKey"
| "digitalHumanVolcAccessKey"
| "digitalHumanVolcSecretKey"
| "digitalHumanQiniuAccessKey"
| "digitalHumanQiniuSecretKey";
type KeytarModule = typeof import("keytar"); type KeytarModule = typeof import("keytar");
const KEYTAR_SERVICE = "QianjiangClaw"; const KEYTAR_SERVICE = "QianjiangClaw";
...@@ -29,7 +44,11 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = { ...@@ -29,7 +44,11 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = {
authToken: "cloud-auth-token", authToken: "cloud-auth-token",
imageModelApiKey: "image-model-api-key", imageModelApiKey: "image-model-api-key",
videoModelApiKey: "video-model-api-key", videoModelApiKey: "video-model-api-key",
copywritingModelApiKey: "copywriting-model-api-key" copywritingModelApiKey: "copywriting-model-api-key",
digitalHumanVolcAccessKey: "digital-human-volc-access-key",
digitalHumanVolcSecretKey: "digital-human-volc-secret-key",
digitalHumanQiniuAccessKey: "digital-human-qiniu-access-key",
digitalHumanQiniuSecretKey: "digital-human-qiniu-secret-key"
}; };
class FileSecretStore implements SecretAccessor { class FileSecretStore implements SecretAccessor {
...@@ -193,6 +212,38 @@ export class SecretManager { ...@@ -193,6 +212,38 @@ export class SecretManager {
return this.store.get("copywritingModelApiKey"); return this.store.get("copywritingModelApiKey");
} }
async setDigitalHumanVolcAccessKey(value?: string): Promise<void> {
await this.store.set("digitalHumanVolcAccessKey", value);
}
async getDigitalHumanVolcAccessKey(): Promise<string | undefined> {
return this.store.get("digitalHumanVolcAccessKey");
}
async setDigitalHumanVolcSecretKey(value?: string): Promise<void> {
await this.store.set("digitalHumanVolcSecretKey", value);
}
async getDigitalHumanVolcSecretKey(): Promise<string | undefined> {
return this.store.get("digitalHumanVolcSecretKey");
}
async setDigitalHumanQiniuAccessKey(value?: string): Promise<void> {
await this.store.set("digitalHumanQiniuAccessKey", value);
}
async getDigitalHumanQiniuAccessKey(): Promise<string | undefined> {
return this.store.get("digitalHumanQiniuAccessKey");
}
async setDigitalHumanQiniuSecretKey(value?: string): Promise<void> {
await this.store.set("digitalHumanQiniuSecretKey", value);
}
async getDigitalHumanQiniuSecretKey(): Promise<string | undefined> {
return this.store.get("digitalHumanQiniuSecretKey");
}
private async tryLoadKeytar(): Promise<KeytarModule | null> { private async tryLoadKeytar(): Promise<KeytarModule | null> {
try { try {
const imported = await import("keytar"); const imported = await import("keytar");
...@@ -203,7 +254,19 @@ export class SecretManager { ...@@ -203,7 +254,19 @@ export class SecretManager {
} }
private async migrateFallbackSecrets(): Promise<void> { private async migrateFallbackSecrets(): Promise<void> {
for (const secretName of ["apiKey", "gatewayToken", "deviceToken", "authToken", "imageModelApiKey", "videoModelApiKey", "copywritingModelApiKey"] as const) { for (const secretName of [
"apiKey",
"gatewayToken",
"deviceToken",
"authToken",
"imageModelApiKey",
"videoModelApiKey",
"copywritingModelApiKey",
"digitalHumanVolcAccessKey",
"digitalHumanVolcSecretKey",
"digitalHumanQiniuAccessKey",
"digitalHumanQiniuSecretKey"
] as const) {
const existing = await this.store.get(secretName); const existing = await this.store.get(secretName);
if (existing) { if (existing) {
continue; continue;
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QianjiangClaw</title> <title>千匠问天</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -13,15 +13,15 @@ ...@@ -13,15 +13,15 @@
- `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-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`
- `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` - `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` - `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` - `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 `千匠问天.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-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 `千匠问天.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` - `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`
- `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke - `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke
- `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall QianjiangClaw.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change` - `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall 千匠问天.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change`
- `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall QianjiangClaw.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue` - `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall 千匠问天.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue`
- `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh` - `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
- `project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory` - `project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory`
- `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile` - `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile`
......
...@@ -29,6 +29,23 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig { ...@@ -29,6 +29,23 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
cloudApiBaseUrl: "https://cloud.example.com", cloudApiBaseUrl: "https://cloud.example.com",
runtimeCloudApiBaseUrl: "https://cloud.example.com", runtimeCloudApiBaseUrl: "https://cloud.example.com",
runtimeMode: "bundled-runtime", runtimeMode: "bundled-runtime",
expertModelConfig: {
image: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
video: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
}
},
...overrides ...overrides
}; };
} }
......
...@@ -57,7 +57,7 @@ async function main(): Promise<void> { ...@@ -57,7 +57,7 @@ async function main(): Promise<void> {
const projectContextService = new ProjectContextService(projectStore); const projectContextService = new ProjectContextService(projectStore);
const systemSummary: SystemSummary = { const systemSummary: SystemSummary = {
appName: "QianjiangClaw", appName: "千匠问天",
appVersion: "0.1.0-smoke", appVersion: "0.1.0-smoke",
isPackaged: false, isPackaged: false,
platform: process.platform, platform: process.platform,
......
This diff is collapsed.
...@@ -149,6 +149,17 @@ $attachmentPayload = @( ...@@ -149,6 +149,17 @@ $attachmentPayload = @(
localPath = $attachmentFixturePath localPath = $attachmentFixturePath
} }
) )
$smokeSettingsConfig = [ordered]@{
image = [ordered]@{
apiKey = 'image-smoke-key'
}
video = [ordered]@{
apiKey = 'video-smoke-key'
}
copywriting = [ordered]@{
apiKey = 'copy-smoke-key'
}
}
$douyinSourceCandidates = @( $douyinSourceCandidates = @(
(Join-Path $repoRoot 'workspace\douyin') (Join-Path $repoRoot 'workspace\douyin')
) )
...@@ -192,6 +203,7 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'Douyin Project Bundle' ...@@ -192,6 +203,7 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'Douyin Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Douyin project bundle for expert-page smoke validation.' $env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Douyin 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) $env:QJCLAW_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = ($smokeSettingsConfig | ConvertTo-Json -Depth 10 -Compress)
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1' $env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1' $env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
...@@ -218,11 +230,35 @@ try { ...@@ -218,11 +230,35 @@ try {
$summary = & node -e @" $summary = & node -e @"
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv] = process.argv.slice(1); const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv, expectedImageBaseUrl, expectedImageApiKey, expectedImageModelId, expectedCopyBaseUrl, expectedCopyApiKey, expectedCopyModelId] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8')); 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 parseProjectEnv(filePath) {
const parsed = {};
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
for (const line of lines) {
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
if (!key) {
continue;
}
try {
parsed[key] = JSON.parse(rawValue);
} catch {
parsed[key] = rawValue;
}
}
return parsed;
}
function sanitizeAttachmentFileComponent(value) { function sanitizeAttachmentFileComponent(value) {
const trimmed = String(value || '').trim(); const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
...@@ -236,13 +272,20 @@ const workspaceSummary = finalState.workspaceSummary || {}; ...@@ -236,13 +272,20 @@ const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects) const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome) ? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: []; : [];
const settingsSave = sendResult.settingsSave || {};
const savedModelConfig = settingsSave.expertModelConfig || {};
const expertProjectIds = Array.isArray(finalState.expertProjectIds) const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort() ? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: []; : [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json'); const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
const projectEnvPath = path.join(userDataPath, 'projects', 'douyin', 'memory', 'project.env');
if (!fs.existsSync(bundleManifestPath)) { if (!fs.existsSync(bundleManifestPath)) {
throw new Error('Bundle manifest was not produced: ' + bundleManifestPath); throw new Error('Bundle manifest was not produced: ' + bundleManifestPath);
} }
if (!fs.existsSync(projectEnvPath)) {
throw new Error('project.env was not materialized: ' + projectEnvPath);
}
const projectEnv = parseProjectEnv(projectEnvPath);
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8')); const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest.douyin; const manifestRecord = bundleManifest.douyin;
if (!manifestRecord || typeof manifestRecord !== 'object') { if (!manifestRecord || typeof manifestRecord !== 'object') {
...@@ -272,6 +315,18 @@ if (String(sendResult.smokeProjectId || '') !== 'douyin') { ...@@ -272,6 +315,18 @@ if (String(sendResult.smokeProjectId || '') !== 'douyin') {
if (String(sendResult.prompt || '') !== expectedPrompt) { if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Smoke prompt mismatch.'); throw new Error('Smoke prompt mismatch.');
} }
if (String(savedModelConfig.image && savedModelConfig.image.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3/images/generations') {
throw new Error('Saved image baseUrl mismatch: ' + String(savedModelConfig.image && savedModelConfig.image.baseUrl || ''));
}
if (String(savedModelConfig.image && savedModelConfig.image.modelId || '') !== 'doubao-seedream-5-0-260128') {
throw new Error('Saved image modelId mismatch: ' + String(savedModelConfig.image && savedModelConfig.image.modelId || ''));
}
if (String(savedModelConfig.copywriting && savedModelConfig.copywriting.baseUrl || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1') {
throw new Error('Saved copywriting baseUrl mismatch: ' + String(savedModelConfig.copywriting && savedModelConfig.copywriting.baseUrl || ''));
}
if (String(savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || '') !== 'qwen3.5-plus') {
throw new Error('Saved copywriting modelId mismatch: ' + String(savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || ''));
}
if (String(workspaceSummary.currentProjectId || '') !== 'douyin') { if (String(workspaceSummary.currentProjectId || '') !== 'douyin') {
throw new Error('Final active project was not douyin: ' + String(workspaceSummary.currentProjectId || '')); throw new Error('Final active project was not douyin: ' + String(workspaceSummary.currentProjectId || ''));
} }
...@@ -322,6 +377,27 @@ if (!assistantContent.includes('Project attachments:')) { ...@@ -322,6 +377,27 @@ if (!assistantContent.includes('Project attachments:')) {
if (!assistantContent.includes(expectedAttachmentRelativePath)) { if (!assistantContent.includes(expectedAttachmentRelativePath)) {
throw new Error('Assistant content did not reference the materialized attachment path: ' + expectedAttachmentRelativePath); throw new Error('Assistant content did not reference the materialized attachment path: ' + expectedAttachmentRelativePath);
} }
if (String(projectEnv.DOUYIN_WRITER_LLM_BASE_URL || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions') {
throw new Error('project.env DOUYIN_WRITER_LLM_BASE_URL mismatch: ' + String(projectEnv.DOUYIN_WRITER_LLM_BASE_URL || ''));
}
if (String(projectEnv.DASHSCOPE_API_KEY || '') !== expectedCopyApiKey) {
throw new Error('project.env DASHSCOPE_API_KEY mismatch.');
}
if (String(projectEnv.QWEN_API_KEY || '') !== expectedCopyApiKey) {
throw new Error('project.env QWEN_API_KEY mismatch.');
}
if (String(projectEnv.DOUYIN_WRITER_LLM_MODEL || '') !== 'qwen3.5-plus') {
throw new Error('project.env DOUYIN_WRITER_LLM_MODEL mismatch: ' + String(projectEnv.DOUYIN_WRITER_LLM_MODEL || ''));
}
if (String(projectEnv.SEEDREAM_ARK_BASE_URL || '') !== 'https://ark.cn-beijing.volces.com/api/v3') {
throw new Error('project.env SEEDREAM_ARK_BASE_URL mismatch: ' + String(projectEnv.SEEDREAM_ARK_BASE_URL || ''));
}
if (String(projectEnv.SEEDREAM_ARK_API_KEY || '') !== expectedImageApiKey) {
throw new Error('project.env SEEDREAM_ARK_API_KEY mismatch.');
}
if (String(projectEnv.SEEDREAM_MODEL || '') !== 'doubao-seedream-5-0-260128') {
throw new Error('project.env SEEDREAM_MODEL mismatch: ' + String(projectEnv.SEEDREAM_MODEL || ''));
}
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
smokeOutput, smokeOutput,
...@@ -336,10 +412,13 @@ console.log(JSON.stringify({ ...@@ -336,10 +412,13 @@ console.log(JSON.stringify({
attachmentCount: smokeAttachments.length, attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath, attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath, attachmentRelativePath: expectedAttachmentRelativePath,
projectEnvPath,
savedImageModelId: savedModelConfig.image && savedModelConfig.image.modelId || null,
savedCopywritingModelId: savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || null,
statusLabels, statusLabels,
bundleManifestPath bundleManifestPath
}, null, 2)); }, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',') "@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',') $smokeSettingsConfig.image.baseUrl $smokeSettingsConfig.image.apiKey $smokeSettingsConfig.image.modelId $smokeSettingsConfig.copywriting.baseUrl $smokeSettingsConfig.copywriting.apiKey $smokeSettingsConfig.copywriting.modelId
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
...@@ -353,6 +432,7 @@ finally { ...@@ -353,6 +432,7 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON -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 Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
} }
...@@ -88,7 +88,7 @@ if (-not $BaseOutputDir) { ...@@ -88,7 +88,7 @@ if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\douyin-expert-manual-launch' $BaseOutputDir = Join-Path $repoRoot '.tmp\douyin-expert-manual-launch'
} }
if (-not $AppExePath) { if (-not $AppExePath) {
$AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\QianjiangClaw.exe' $AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\千匠问天.exe'
} }
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir) $BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
...@@ -109,10 +109,10 @@ if (-not (Test-Path $douyinSourceRoot)) { ...@@ -109,10 +109,10 @@ if (-not (Test-Path $douyinSourceRoot)) {
throw "Douyin workspace source was not found: $douyinSourceRoot" throw "Douyin workspace source was not found: $douyinSourceRoot"
} }
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue $runningDesktop = Get-Process -Name '千匠问天' -ErrorAction SilentlyContinue
if ($runningDesktop) { if ($runningDesktop) {
$processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', ' $processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', '
throw "QianjiangClaw is already running (PID: $processIds). Close the desktop app first, then rerun this launcher." throw "千匠问天 is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
} }
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
......
...@@ -377,15 +377,18 @@ if (smokeViewMode === 'skills') { ...@@ -377,15 +377,18 @@ if (smokeViewMode === 'skills') {
if (!settingsSave.expertModelConfig) { if (!settingsSave.expertModelConfig) {
throw new Error('Settings smoke did not report saved expertModelConfig.'); throw new Error('Settings smoke did not report saved expertModelConfig.');
} }
if (String(modelConfig.image && modelConfig.image.baseUrl || '') !== 'https://image-smoke.example.com/v1') { if (String(modelConfig.image && modelConfig.image.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3/images/generations') {
throw new Error('Settings smoke did not persist image model baseUrl.'); throw new Error('Settings smoke did not persist image model baseUrl.');
} }
if (String(modelConfig.video && modelConfig.video.baseUrl || '') !== 'https://video-smoke.example.com/v1') { if (String(modelConfig.video && modelConfig.video.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3') {
throw new Error('Settings smoke did not persist video model baseUrl.'); throw new Error('Settings smoke did not persist video model baseUrl.');
} }
if (String(modelConfig.copywriting && modelConfig.copywriting.baseUrl || '') !== 'https://copy-smoke.example.com/v1') { if (String(modelConfig.copywriting && modelConfig.copywriting.baseUrl || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1') {
throw new Error('Settings smoke did not persist copywriting model baseUrl.'); throw new Error('Settings smoke did not persist copywriting model baseUrl.');
} }
if (String(modelConfig.copywriting && modelConfig.copywriting.modelId || '') !== 'qwen3.5-plus') {
throw new Error('Settings smoke did not persist copywriting model modelId.');
}
if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) { if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) {
throw new Error('Settings smoke did not mark image model api key as configured.'); throw new Error('Settings smoke did not mark image model api key as configured.');
} }
...@@ -487,7 +490,7 @@ if (smokeViewMode === 'skills') { ...@@ -487,7 +490,7 @@ if (smokeViewMode === 'skills') {
if (!sendResult.smokeExpertEntryId && streamSmoke.fallbackUsed) { if (!sendResult.smokeExpertEntryId && streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.'); throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
} }
if (!sendResult.smokeExpertEntryId && !['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) { if (!sendResult.smokeExpertEntryId && executionPolicySource !== 'client-config') {
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource); throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
} }
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) { if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
......
...@@ -15,7 +15,7 @@ function assert(condition: unknown, message: string): asserts condition { ...@@ -15,7 +15,7 @@ function assert(condition: unknown, message: string): asserts condition {
function buildSystemSummary(repoRoot: string, userDataPath: string, logsPath: string): SystemSummary { function buildSystemSummary(repoRoot: string, userDataPath: string, logsPath: string): SystemSummary {
return { return {
appName: "QianjiangClaw", appName: "千匠问天",
appVersion: "0.1.0-smoke", appVersion: "0.1.0-smoke",
isPackaged: false, isPackaged: false,
platform: process.platform, platform: process.platform,
......
...@@ -36,8 +36,8 @@ if (Test-Path $BaseOutputDir) { ...@@ -36,8 +36,8 @@ if (Test-Path $BaseOutputDir) {
} }
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$initialInstallDir = Join-Path $BaseOutputDir 'existing\QianjiangClaw' $initialInstallDir = Join-Path $BaseOutputDir 'existing\千匠问天'
$relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\QianjiangClaw' $relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\千匠问天'
$initialResultPath = Join-Path $BaseOutputDir 'initial-install-result.json' $initialResultPath = Join-Path $BaseOutputDir 'initial-install-result.json'
$relocatedResultPath = Join-Path $BaseOutputDir 'relocated-install-result.json' $relocatedResultPath = Join-Path $BaseOutputDir 'relocated-install-result.json'
$summaryPath = Join-Path $BaseOutputDir 'installer-path-change-summary.json' $summaryPath = Join-Path $BaseOutputDir 'installer-path-change-summary.json'
......
...@@ -20,9 +20,6 @@ $ErrorActionPreference = 'Stop' ...@@ -20,9 +20,6 @@ $ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerDir = Join-Path $repoRoot 'dist\installer' $installerDir = Join-Path $repoRoot 'dist\installer'
$productName = 'QianjiangClaw'
$uninstallerFileName = "Uninstall $productName.exe"
$installedExeName = "$productName.exe"
if (-not $SetupExe) { if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' | $latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
...@@ -37,6 +34,17 @@ if (-not $SetupExe) { ...@@ -37,6 +34,17 @@ if (-not $SetupExe) {
} }
$SetupExe = (Resolve-Path $SetupExe).Path $SetupExe = (Resolve-Path $SetupExe).Path
$setupFileName = [System.IO.Path]::GetFileName($SetupExe)
$setupMarker = '-Setup-'
$setupMarkerIndex = $setupFileName.LastIndexOf($setupMarker, [System.StringComparison]::Ordinal)
if ($setupMarkerIndex -le 0) {
throw "Unable to derive product name from setup executable: $setupFileName"
}
$productName = $setupFileName.Substring(0, $setupMarkerIndex)
$uninstallerFileName = "Uninstall $productName.exe"
$expectedUninstallerFileNames = @($uninstallerFileName, 'Uninstall qjclaw.exe') | Select-Object -Unique
$installedExeName = "$productName.exe"
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
if (-not $InstallDir) { if (-not $InstallDir) {
...@@ -70,6 +78,10 @@ $runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime' ...@@ -70,6 +78,10 @@ $runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime'
$packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe' $packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe'
$packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json' $packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json'
$packagedWorkspaceTemplate = Join-Path $runtimeResourceDir 'openclaw\package\docs\reference\templates\AGENTS.md' $packagedWorkspaceTemplate = Join-Path $runtimeResourceDir 'openclaw\package\docs\reference\templates\AGENTS.md'
$expectedInstalledFileDescription = $productName
$expectedInstalledProductName = $productName
$expectedInstalledInternalName = $productName
$expectedInstalledOriginalFilename = $installedExeName
$installAttempts = @() $installAttempts = @()
$installerStageSummary = $null $installerStageSummary = $null
...@@ -95,6 +107,30 @@ function Write-JsonFile { ...@@ -95,6 +107,30 @@ function Write-JsonFile {
Write-Utf8File -FilePath $FilePath -Content ($Payload | ConvertTo-Json -Depth 20) Write-Utf8File -FilePath $FilePath -Content ($Payload | ConvertTo-Json -Depth 20)
} }
function Resolve-InstalledUninstallerPath {
param([string]$InstallPath)
if ([string]::IsNullOrWhiteSpace($InstallPath) -or -not (Test-Path -LiteralPath $InstallPath)) {
return ''
}
foreach ($candidateFileName in $expectedUninstallerFileNames) {
$candidatePath = Join-Path $InstallPath $candidateFileName
if (Test-Path -LiteralPath $candidatePath) {
return $candidatePath
}
}
$fallbackMatch = Get-ChildItem -LiteralPath $InstallPath -Filter 'Uninstall *.exe' -File -ErrorAction SilentlyContinue |
Sort-Object Name |
Select-Object -First 1
if ($fallbackMatch) {
return $fallbackMatch.FullName
}
return ''
}
function Get-InstallSnapshot { function Get-InstallSnapshot {
param([string]$Path) param([string]$Path)
...@@ -234,7 +270,7 @@ function Get-InstallerRelatedProcesses { ...@@ -234,7 +270,7 @@ function Get-InstallerRelatedProcesses {
$name = [string]$process.Name $name = [string]$process.Name
$isMatch = $false $isMatch = $false
if ($name -eq $installedExeName -or $name -eq $uninstallerFileName) { if ($name -eq $installedExeName -or $expectedUninstallerFileNames -contains $name) {
$isMatch = $true $isMatch = $true
} }
...@@ -301,7 +337,7 @@ function Get-InstallObservation { ...@@ -301,7 +337,7 @@ function Get-InstallObservation {
$snapshot = Get-InstallSnapshot -Path $Path $snapshot = Get-InstallSnapshot -Path $Path
$observedInstalledExe = Join-Path $Path $installedExeName $observedInstalledExe = Join-Path $Path $installedExeName
$observedUninstallerPath = Join-Path $Path $uninstallerFileName $observedUninstallerPath = Resolve-InstalledUninstallerPath -InstallPath $Path
$observedResourcesAsar = Join-Path $Path 'resources\app.asar' $observedResourcesAsar = Join-Path $Path 'resources\app.asar'
$observedRuntimeResourceDir = Join-Path $Path 'resources\vendor\openclaw-runtime' $observedRuntimeResourceDir = Join-Path $Path 'resources\vendor\openclaw-runtime'
$observedPackagedPythonExe = Join-Path $observedRuntimeResourceDir 'python\python.exe' $observedPackagedPythonExe = Join-Path $observedRuntimeResourceDir 'python\python.exe'
...@@ -314,7 +350,9 @@ function Get-InstallObservation { ...@@ -314,7 +350,9 @@ function Get-InstallObservation {
runtimeResourceDirExists = Test-Path $observedRuntimeResourceDir runtimeResourceDirExists = Test-Path $observedRuntimeResourceDir
packagedPythonExeExists = Test-Path $observedPackagedPythonExe packagedPythonExeExists = Test-Path $observedPackagedPythonExe
packagedPythonManifestExists = Test-Path $observedPackagedPythonManifest packagedPythonManifestExists = Test-Path $observedPackagedPythonManifest
uninstallerExists = Test-Path $observedUninstallerPath uninstallerExists = -not [string]::IsNullOrWhiteSpace($observedUninstallerPath)
uninstallerPath = $observedUninstallerPath
uninstallerFileName = if ($observedUninstallerPath) { [System.IO.Path]::GetFileName($observedUninstallerPath) } else { '' }
fileCount = [int]$snapshot.FileCount fileCount = [int]$snapshot.FileCount
totalBytes = [int64]$snapshot.TotalBytes totalBytes = [int64]$snapshot.TotalBytes
} }
...@@ -777,6 +815,37 @@ console.log(JSON.stringify(summary, null, 2)); ...@@ -777,6 +815,37 @@ console.log(JSON.stringify(summary, null, 2));
} }
} }
function Assert-InstalledExeBranding {
param()
if (-not (Test-Path -LiteralPath $installedExe)) {
throw "Installed executable not found for branding validation: $installedExe"
}
$versionInfo = (Get-Item -LiteralPath $installedExe).VersionInfo
if ([string]$versionInfo.FileDescription -ne $expectedInstalledFileDescription) {
throw "Installed executable FileDescription mismatch. expected=$expectedInstalledFileDescription actual=$($versionInfo.FileDescription)"
}
if ([string]$versionInfo.ProductName -ne $expectedInstalledProductName) {
throw "Installed executable ProductName mismatch. expected=$expectedInstalledProductName actual=$($versionInfo.ProductName)"
}
if ([string]$versionInfo.InternalName -ne $expectedInstalledInternalName) {
throw "Installed executable InternalName mismatch. expected=$expectedInstalledInternalName actual=$($versionInfo.InternalName)"
}
if ([string]$versionInfo.OriginalFilename -ne $expectedInstalledOriginalFilename) {
throw "Installed executable OriginalFilename mismatch. expected=$expectedInstalledOriginalFilename actual=$($versionInfo.OriginalFilename)"
}
return [ordered]@{
fileDescription = [string]$versionInfo.FileDescription
productName = [string]$versionInfo.ProductName
internalName = [string]$versionInfo.InternalName
originalFilename = [string]$versionInfo.OriginalFilename
companyName = [string]$versionInfo.CompanyName
}
}
function New-FinalSummary { function New-FinalSummary {
param( param(
[bool]$Ok, [bool]$Ok,
...@@ -798,7 +867,7 @@ function New-FinalSummary { ...@@ -798,7 +867,7 @@ function New-FinalSummary {
setupExe = $SetupExe setupExe = $SetupExe
installDir = $InstallDir installDir = $InstallDir
installedExe = $installedExe installedExe = $installedExe
uninstallerPath = $uninstallerPath uninstallerPath = if ($selectedAttempt.observation.uninstallerPath) { $selectedAttempt.observation.uninstallerPath } else { $uninstallerPath }
smokeOutput = $SmokeOutput smokeOutput = $SmokeOutput
appSmokeOutput = $appSmokeOutput appSmokeOutput = $appSmokeOutput
appSmokeTracePath = $appSmokeTracePath appSmokeTracePath = $appSmokeTracePath
...@@ -811,6 +880,9 @@ function New-FinalSummary { ...@@ -811,6 +880,9 @@ function New-FinalSummary {
$summary.installDirExists = $latestAttempt.observation.installDirExists $summary.installDirExists = $latestAttempt.observation.installDirExists
$summary.installedExeExists = $latestAttempt.observation.installedExeExists $summary.installedExeExists = $latestAttempt.observation.installedExeExists
$summary.uninstallerExists = $latestAttempt.observation.uninstallerExists $summary.uninstallerExists = $latestAttempt.observation.uninstallerExists
if ($latestAttempt.observation.uninstallerPath) {
$summary.uninstallerPath = $latestAttempt.observation.uninstallerPath
}
$summary.fileCount = $latestAttempt.observation.fileCount $summary.fileCount = $latestAttempt.observation.fileCount
$summary.totalBytes = [int64]$latestAttempt.observation.totalBytes $summary.totalBytes = [int64]$latestAttempt.observation.totalBytes
} }
...@@ -1030,6 +1102,13 @@ print(json.dumps(result)) ...@@ -1030,6 +1102,13 @@ print(json.dumps(result))
$installerStageSummary['runtimePayloadSizeBytes'] = [int64]$runtimePayloadSummary.sizeBytes $installerStageSummary['runtimePayloadSizeBytes'] = [int64]$runtimePayloadSummary.sizeBytes
$installerStageSummary['runtimePayloadTopLevelBreakdown'] = $runtimePayloadSummary.topLevelBreakdown $installerStageSummary['runtimePayloadTopLevelBreakdown'] = $runtimePayloadSummary.topLevelBreakdown
$failureStage = 'branding-validation'
$failureClassification = 'branding-validation-failure'
$installedExeBrandingSummary = Assert-InstalledExeBranding
$installerStageSummary['installedExeBranding'] = $installedExeBrandingSummary
$failureStage = ''
$failureClassification = ''
if ($SkipInstalledAppSmoke) { if ($SkipInstalledAppSmoke) {
$appStageSummary = [ordered]@{ $appStageSummary = [ordered]@{
ok = $true ok = $true
......
...@@ -23,6 +23,14 @@ if (-not $SetupExe) { ...@@ -23,6 +23,14 @@ if (-not $SetupExe) {
} }
$SetupExe = (Resolve-Path $SetupExe).Path $SetupExe = (Resolve-Path $SetupExe).Path
$setupFileName = [System.IO.Path]::GetFileName($SetupExe)
$setupMarker = '-Setup-'
$setupMarkerIndex = $setupFileName.LastIndexOf($setupMarker, [System.StringComparison]::Ordinal)
if ($setupMarkerIndex -le 0) {
throw "Unable to derive product name from setup executable: $setupFileName"
}
$productName = $setupFileName.Substring(0, $setupMarkerIndex)
if (-not $BaseOutputDir) { if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-target-residue-smoke' $BaseOutputDir = Join-Path $repoRoot '.tmp\installer-target-residue-smoke'
...@@ -34,9 +42,9 @@ if (Test-Path $BaseOutputDir) { ...@@ -34,9 +42,9 @@ if (Test-Path $BaseOutputDir) {
} }
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$installDir = Join-Path $BaseOutputDir 'target\QianjiangClaw' $installDir = Join-Path $BaseOutputDir (Join-Path 'target' $productName)
$resultPath = Join-Path $BaseOutputDir 'installer-target-residue-result.json' $resultPath = Join-Path $BaseOutputDir 'installer-target-residue-result.json'
$staleUninstallerPath = Join-Path $installDir 'Uninstall QianjiangClaw.exe' $staleUninstallerPath = Join-Path $installDir "Uninstall $productName.exe"
New-Item -ItemType Directory -Force -Path $installDir | Out-Null New-Item -ItemType Directory -Force -Path $installDir | Out-Null
[System.IO.File]::WriteAllText($staleUninstallerPath, 'stale residue', (New-Object System.Text.UTF8Encoding $false)) [System.IO.File]::WriteAllText($staleUninstallerPath, 'stale residue', (New-Object System.Text.UTF8Encoding $false))
......
...@@ -112,7 +112,7 @@ description: douyin script writing skill ...@@ -112,7 +112,7 @@ description: douyin script writing skill
const skillRouter = new ProjectSkillRouterService(projectStore); const skillRouter = new ProjectSkillRouterService(projectStore);
const projectContextService = new ProjectContextService(projectStore); const projectContextService = new ProjectContextService(projectStore);
const systemSummary: SystemSummary = { const systemSummary: SystemSummary = {
appName: "QianjiangClaw", appName: "千匠问天",
appVersion: "0.1.0-smoke", appVersion: "0.1.0-smoke",
isPackaged: false, isPackaged: false,
platform: process.platform, platform: process.platform,
......
...@@ -180,6 +180,17 @@ $attachmentPayload = @( ...@@ -180,6 +180,17 @@ $attachmentPayload = @(
localPath = $attachmentFixturePath localPath = $attachmentFixturePath
} }
) )
$smokeSettingsConfig = [ordered]@{
image = [ordered]@{
apiKey = 'image-smoke-key'
}
video = [ordered]@{
apiKey = 'video-smoke-key'
}
copywriting = [ordered]@{
apiKey = 'copy-smoke-key'
}
}
$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'),
...@@ -225,10 +236,9 @@ $env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'XHS Project Bundle' ...@@ -225,10 +236,9 @@ $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) $env:QJCLAW_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = ($smokeSettingsConfig | ConvertTo-Json -Depth 10 -Compress)
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig $xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($xhsImageProviderConfig.providers | ConvertTo-Json -Depth 10 -Compress) $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' $env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
...@@ -256,11 +266,35 @@ try { ...@@ -256,11 +266,35 @@ try {
$summary = & node -e @" $summary = & node -e @"
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv] = process.argv.slice(1); const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv, expectedImageBaseUrl, expectedImageApiKey, expectedImageModelId, expectedCopyBaseUrl, expectedCopyApiKey, expectedCopyModelId] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8')); 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 parseProjectEnv(filePath) {
const parsed = {};
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
for (const line of lines) {
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
if (!key) {
continue;
}
try {
parsed[key] = JSON.parse(rawValue);
} catch {
parsed[key] = rawValue;
}
}
return parsed;
}
function sanitizeAttachmentFileComponent(value) { function sanitizeAttachmentFileComponent(value) {
const trimmed = String(value || '').trim(); const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
...@@ -274,13 +308,20 @@ const workspaceSummary = finalState.workspaceSummary || {}; ...@@ -274,13 +308,20 @@ const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects) const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome) ? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: []; : [];
const settingsSave = sendResult.settingsSave || {};
const savedModelConfig = settingsSave.expertModelConfig || {};
const expertProjectIds = Array.isArray(finalState.expertProjectIds) const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort() ? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: []; : [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json'); const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
const projectEnvPath = path.join(userDataPath, 'projects', 'xhs', 'memory', 'project.env');
if (!fs.existsSync(bundleManifestPath)) { if (!fs.existsSync(bundleManifestPath)) {
throw new Error('Bundle manifest was not produced: ' + bundleManifestPath); throw new Error('Bundle manifest was not produced: ' + bundleManifestPath);
} }
if (!fs.existsSync(projectEnvPath)) {
throw new Error('project.env was not materialized: ' + projectEnvPath);
}
const projectEnv = parseProjectEnv(projectEnvPath);
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8')); const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest.xhs; const manifestRecord = bundleManifest.xhs;
if (!manifestRecord || typeof manifestRecord !== 'object') { if (!manifestRecord || typeof manifestRecord !== 'object') {
...@@ -310,6 +351,18 @@ if (String(sendResult.smokeProjectId || '') !== 'xhs') { ...@@ -310,6 +351,18 @@ if (String(sendResult.smokeProjectId || '') !== 'xhs') {
if (String(sendResult.prompt || '') !== expectedPrompt) { if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Smoke prompt mismatch.'); throw new Error('Smoke prompt mismatch.');
} }
if (String(savedModelConfig.image && savedModelConfig.image.baseUrl || '') !== 'https://ark.cn-beijing.volces.com/api/v3/images/generations') {
throw new Error('Saved image baseUrl mismatch: ' + String(savedModelConfig.image && savedModelConfig.image.baseUrl || ''));
}
if (String(savedModelConfig.image && savedModelConfig.image.modelId || '') !== 'doubao-seedream-5-0-260128') {
throw new Error('Saved image modelId mismatch: ' + String(savedModelConfig.image && savedModelConfig.image.modelId || ''));
}
if (String(savedModelConfig.copywriting && savedModelConfig.copywriting.baseUrl || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1') {
throw new Error('Saved copywriting baseUrl mismatch: ' + String(savedModelConfig.copywriting && savedModelConfig.copywriting.baseUrl || ''));
}
if (String(savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || '') !== 'qwen3.5-plus') {
throw new Error('Saved copywriting modelId mismatch: ' + String(savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || ''));
}
if (String(workspaceSummary.currentProjectId || '') !== 'xhs') { if (String(workspaceSummary.currentProjectId || '') !== 'xhs') {
throw new Error('Final active project was not xhs: ' + String(workspaceSummary.currentProjectId || '')); throw new Error('Final active project was not xhs: ' + String(workspaceSummary.currentProjectId || ''));
} }
...@@ -353,6 +406,30 @@ const attachmentStat = fs.statSync(expectedAttachmentPath); ...@@ -353,6 +406,30 @@ const attachmentStat = fs.statSync(expectedAttachmentPath);
if (!attachmentStat.isFile() || attachmentStat.size < 1) { if (!attachmentStat.isFile() || attachmentStat.size < 1) {
throw new Error('Materialized attachment file is empty: ' + expectedAttachmentPath); throw new Error('Materialized attachment file is empty: ' + expectedAttachmentPath);
} }
if (String(projectEnv.QWEN_BASE_URL || '') !== 'https://dashscope.aliyuncs.com/compatible-mode/v1') {
throw new Error('project.env QWEN_BASE_URL mismatch: ' + String(projectEnv.QWEN_BASE_URL || ''));
}
if (String(projectEnv.QWEN_API_KEY || '') !== expectedCopyApiKey) {
throw new Error('project.env QWEN_API_KEY mismatch.');
}
if (String(projectEnv.QWEN_MODEL || '') !== 'qwen3.5-plus') {
throw new Error('project.env QWEN_MODEL mismatch: ' + String(projectEnv.QWEN_MODEL || ''));
}
if (String(projectEnv.QWEN_VISION_MODEL || '') !== 'qwen3.5-plus') {
throw new Error('project.env QWEN_VISION_MODEL mismatch: ' + String(projectEnv.QWEN_VISION_MODEL || ''));
}
if (String(projectEnv.XHS_IMAGE_PROVIDER || '') !== 'env:xhsImage') {
throw new Error('project.env XHS_IMAGE_PROVIDER mismatch: ' + String(projectEnv.XHS_IMAGE_PROVIDER || ''));
}
if (String(projectEnv.XHS_IMAGE_BASE_URL || '') !== 'https://ark.cn-beijing.volces.com/api/v3/images/generations') {
throw new Error('project.env XHS_IMAGE_BASE_URL mismatch: ' + String(projectEnv.XHS_IMAGE_BASE_URL || ''));
}
if (String(projectEnv.XHS_IMAGE_API_KEY || '') !== expectedImageApiKey) {
throw new Error('project.env XHS_IMAGE_API_KEY mismatch.');
}
if (String(projectEnv.XHS_IMAGE_MODEL || '') !== expectedImageModelId) {
throw new Error('project.env XHS_IMAGE_MODEL mismatch: ' + String(projectEnv.XHS_IMAGE_MODEL || ''));
}
console.log(JSON.stringify({ console.log(JSON.stringify({
ok: true, ok: true,
smokeOutput, smokeOutput,
...@@ -367,10 +444,13 @@ console.log(JSON.stringify({ ...@@ -367,10 +444,13 @@ console.log(JSON.stringify({
attachmentCount: smokeAttachments.length, attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath, attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath, attachmentRelativePath: expectedAttachmentRelativePath,
projectEnvPath,
savedImageModelId: savedModelConfig.image && savedModelConfig.image.modelId || null,
savedCopywritingModelId: savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || null,
statusLabels, statusLabels,
bundleManifestPath bundleManifestPath
}, null, 2)); }, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',') "@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',') $smokeSettingsConfig.image.baseUrl $smokeSettingsConfig.image.apiKey $smokeSettingsConfig.image.modelId $smokeSettingsConfig.copywriting.baseUrl $smokeSettingsConfig.copywriting.apiKey $smokeSettingsConfig.copywriting.modelId
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
...@@ -384,9 +464,8 @@ finally { ...@@ -384,9 +464,8 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_EXTRA_MODEL_PROVIDERS_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 Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
......
...@@ -150,7 +150,7 @@ if (-not $BaseOutputDir) { ...@@ -150,7 +150,7 @@ if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-manual-launch' $BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-manual-launch'
} }
if (-not $AppExePath) { if (-not $AppExePath) {
$AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\QianjiangClaw.exe' $AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\千匠问天.exe'
} }
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir) $BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
...@@ -171,10 +171,10 @@ if (-not (Test-Path $xhsSourceRoot)) { ...@@ -171,10 +171,10 @@ if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot" throw "XHS workspace source was not found: $xhsSourceRoot"
} }
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue $runningDesktop = Get-Process -Name '千匠问天' -ErrorAction SilentlyContinue
if ($runningDesktop) { if ($runningDesktop) {
$processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', ' $processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', '
throw "QianjiangClaw is already running (PID: $processIds). Close the desktop app first, then rerun this launcher." throw "千匠问天 is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
} }
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
......
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