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
productName: QianjiangClaw
productName: 千匠问天
compression: store
asar: true
asarUnpack:
......@@ -8,13 +8,22 @@ asarUnpack:
directories:
output: ../../dist/installer
artifactName: ${productName}-Setup-${version}.${ext}
afterPack: build/hooks/after-pack-branding.cjs
files:
- dist/**/*
- package.json
extraResources:
- from: ../../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:
executableName: 千匠问天
icon: build/icons/brand-icon.ico
signAndEditExecutable: false
target:
- nsis
......
......@@ -2,8 +2,8 @@
"name": "@qjclaw/desktop",
"version": "0.1.0",
"private": true,
"description": "QianjiangClaw desktop client",
"author": "QianjiangClaw",
"description": "千匠问天 desktop client",
"author": "千匠问天",
"main": "dist/main/index.js",
"scripts": {
"build": "tsup --config tsup.config.ts",
......
......@@ -3,7 +3,7 @@ import { access, appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types";
import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SaveConfigInput, SystemSummary } from "@qjclaw/shared-types";
import { createMainWindow } from "./create-window.js";
import { registerDesktopIpc, type RegisteredDesktopIpc } from "./ipc.js";
import { AppConfigService } from "./services/app-config.js";
......@@ -95,6 +95,7 @@ interface RendererSmokeState {
copywriting?: {
baseUrl?: string;
apiKeyConfigured?: boolean;
modelId?: string;
};
} | null;
systemSummary: {
......@@ -142,11 +143,14 @@ interface RendererSmokeState {
};
}
const APP_DISPLAY_NAME = "千匠问天";
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim();
const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000;
let smokeTestInFlight = false;
app.setName(APP_DISPLAY_NAME);
if (forcedUserDataPath) {
app.setPath("userData", forcedUserDataPath);
app.setPath("sessionData", path.join(forcedUserDataPath, "session-data"));
......@@ -201,12 +205,25 @@ function resolveRequestedRuntimeMode(configMode: RuntimeModePreference): Runtime
return override === "bundled-runtime" || override === "external-gateway" ? override : configMode;
}
function buildDirectProviderManagedConfig(defaultConfig: Record<string, unknown>, config: AppConfig, apiKey: string): Record<string, unknown> {
function buildManagedConfigFromEndpoint(input: {
defaultConfig: Record<string, unknown>;
providerKey: string;
baseUrl: string;
apiKey: string;
modelId: string;
modelLabel?: string;
apiMode?: string;
}): Record<string, unknown> {
const {
defaultConfig,
providerKey,
baseUrl,
apiKey,
modelId,
modelLabel = modelId,
apiMode = "openai-completions"
} = input;
const nextConfig = structuredClone(defaultConfig);
const providerKey = "direct-provider";
const modelId = config.defaultModel || "gpt-5.4-mini";
const modelLabel = modelId;
const apiMode = config.provider === "anthropic" ? "anthropic-messages" : "openai-completions";
const modelsSection = (nextConfig.models && typeof nextConfig.models === "object" ? nextConfig.models : {}) as Record<string, unknown>;
const providers = (modelsSection.providers && typeof modelsSection.providers === "object" ? modelsSection.providers : {}) as Record<string, unknown>;
const existingProvider = (providers[providerKey] && typeof providers[providerKey] === "object" ? providers[providerKey] : {}) as Record<string, unknown>;
......@@ -226,7 +243,7 @@ function buildDirectProviderManagedConfig(defaultConfig: Record<string, unknown>
providers[providerKey] = {
...existingProvider,
baseUrl: config.baseUrl,
baseUrl,
apiKey,
api: apiMode,
models: [
......@@ -261,6 +278,36 @@ function buildDirectProviderManagedConfig(defaultConfig: Record<string, unknown>
return nextConfig;
}
function buildDirectProviderManagedConfig(defaultConfig: Record<string, unknown>, config: AppConfig, apiKey: string): Record<string, unknown> {
return buildManagedConfigFromEndpoint({
defaultConfig,
providerKey: "direct-provider",
baseUrl: config.baseUrl,
apiKey,
modelId: config.defaultModel || "gpt-5.4-mini",
apiMode: config.provider === "anthropic" ? "anthropic-messages" : "openai-completions"
});
}
function buildClientChatManagedConfig(defaultConfig: Record<string, unknown>, config: AppConfig, apiKey: string): Record<string, unknown> {
return buildManagedConfigFromEndpoint({
defaultConfig,
providerKey: "client-chat-model",
baseUrl: config.expertModelConfig.copywriting.baseUrl,
apiKey,
modelId: config.expertModelConfig.copywriting.modelId || "unknown-model"
});
}
function hasConfiguredClientChatModel(config: AppConfig, apiKey?: string | null): apiKey is string {
return Boolean(
apiKey?.trim()
&& config.expertModelConfig.copywriting.baseUrl.trim()
&& (config.expertModelConfig.copywriting.modelId ?? "").trim()
);
}
function resolveVendorRuntimeDir(systemSummary: SystemSummary): string {
if (systemSummary.isPackaged) {
return path.join(systemSummary.resourcesPath, "vendor", "openclaw-runtime");
......@@ -313,7 +360,8 @@ function matchesExpectedSmokeModelConfig(state: RendererSmokeState | null | unde
&& String(modelConfig.copywriting?.baseUrl || "") === "https://copy-smoke.example.com/v1"
&& Boolean(modelConfig.image?.apiKeyConfigured)
&& Boolean(modelConfig.video?.apiKeyConfigured)
&& Boolean(modelConfig.copywriting?.apiKeyConfigured);
&& Boolean(modelConfig.copywriting?.apiKeyConfigured)
&& String(modelConfig.copywriting?.modelId || "") === "qwen3.5-plus";
}
async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState> {
......@@ -477,6 +525,64 @@ function resolveSmokeAttachments(): Array<{
}
}
function resolveSmokeSettingsConfig(): SaveConfigInput["expertModelConfig"] | null {
const raw = process.env.QJCLAW_SMOKE_SETTINGS_CONFIG_JSON ?? "";
if (!raw.trim()) {
return null;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
const input = parsed as {
image?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
video?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
copywriting?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown };
};
type SmokeSettingsEntry = {
baseUrl: string;
apiKey?: string;
modelId?: string;
};
const normalizeEntry = (entry?: { baseUrl?: unknown; apiKey?: unknown; modelId?: unknown }) => {
if (!entry || typeof entry !== "object") {
return undefined;
}
if (typeof entry.baseUrl !== "string") {
return undefined;
}
const normalized: SmokeSettingsEntry = {
baseUrl: entry.baseUrl
};
if (typeof entry.apiKey === "string") {
normalized.apiKey = entry.apiKey;
}
if (typeof entry.modelId === "string") {
normalized.modelId = entry.modelId;
}
return Object.keys(normalized).length ? normalized : undefined;
};
const resolved = {
image: normalizeEntry(input.image),
video: normalizeEntry(input.video),
copywriting: normalizeEntry(input.copywriting)
};
return resolved.image || resolved.video || resolved.copywriting
? resolved
: null;
} catch {
return null;
}
}
async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Promise<void> {
if (pathsToCheck.length === 0) {
return;
......@@ -692,6 +798,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeSendAfterExpertEntry = process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1";
const smokeSuggestionAction = process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
const smokeSettingsConfig = resolveSmokeSettingsConfig();
await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
......@@ -712,6 +819,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeSuggestionAction = ${JSON.stringify(process.env.QJCLAW_SMOKE_SUGGESTION_ACTION?.trim() ?? "")};
const smokeExpertEntryId = ${JSON.stringify(process.env.QJCLAW_SMOKE_EXPERT_ENTRY_ID?.trim() ?? "")};
const smokeSendAfterExpertEntry = ${JSON.stringify(process.env.QJCLAW_SMOKE_SEND_AFTER_EXPERT_ENTRY === "1")};
const requestedSmokeSettingsConfig = ${JSON.stringify(smokeSettingsConfig)};
if (smokeBaseUrl) {
const current = await api.config.load();
await api.config.save({
......@@ -752,19 +860,48 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
lastError
};
};
const waitForGatewayStable = async () => {
let stableSamples = 0;
let lastSnapshot = null;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const [status, workspace] = await Promise.all([
api.gateway.status().catch(() => null),
api.workspace.getSummary().catch(() => null)
]);
lastSnapshot = {
gatewayState: status?.state ?? "unknown",
chatReady: Boolean(workspace?.chatReady),
startupPhase: workspace?.startupPhase ?? "unknown",
startupMessage: workspace?.startupMessage ?? ""
};
if (status?.state === "connected" && workspace?.chatReady) {
stableSamples += 1;
if (stableSamples >= 3) {
return lastSnapshot;
}
} else {
stableSamples = 0;
}
await sleep(500);
}
throw new Error("Gateway did not remain stable after settings save. lastState=" + JSON.stringify(lastSnapshot));
};
const waitForWorkspaceReady = async () => {
let workspace = await api.workspace.getSummary();
const deadline = Date.now() + 45000;
let warmupQueued = false;
while (Date.now() < deadline) {
const hasProject = Boolean(
workspace.chatReady
const chatReady = Boolean(workspace.chatReady);
const projectReady = Boolean(
chatReady
&& workspace.projectReady
&& workspace.currentProjectId
&& Array.isArray(workspace.projects)
&& workspace.projects.length > 0
);
if (hasProject) {
const homeChatReady = chatReady && !projectReady;
if (projectReady || homeChatReady) {
return workspace;
}
if (!warmupQueued) {
......@@ -826,6 +963,39 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
}
throw new Error("Settings view did not become ready for smoke validation.");
};
const defaultSmokeSettingsConfig = {
image: {
baseUrl: "https://image-smoke.example.com/v1",
apiKey: "image-smoke-key"
},
video: {
baseUrl: "https://video-smoke.example.com/v1",
apiKey: "video-smoke-key"
},
copywriting: {
baseUrl: "https://copy-smoke.example.com/v1",
apiKey: "copy-smoke-key",
modelId: "qwen3.5-plus"
}
};
const smokeSettingsConfig = requestedSmokeSettingsConfig || defaultSmokeSettingsConfig;
const smokeExpertSettingsConfig = smokeSettingsConfig
? {
image: smokeSettingsConfig.image,
video: smokeSettingsConfig.video,
copywriting: smokeSettingsConfig.copywriting
}
: null;
const applySmokeSettingsConfig = async () => {
if (!smokeExpertSettingsConfig) {
return null;
}
await actions.navigateToView("settings");
await waitForSettingsViewReady();
return await actions.saveSettingsConfig({
expertModelConfig: smokeExpertSettingsConfig
});
};
const runtimeCloudStatus = await api.runtimeCloud.getStatus();
let runtimeCloudFetch = runtimeCloudStatus;
let runtimeCloudFetchError = null;
......@@ -862,35 +1032,21 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
: undefined;
const system = await api.system.getSummary();
const settingsSave = await applySmokeSettingsConfig();
if (settingsSave) {
await api.runtime.start().catch(() => undefined);
await waitForGatewayReady();
await waitForGatewayStable();
}
const actionResult = smokeViewMode === "skills"
? await actions.navigateToView("skills")
: smokeViewMode === "settings"
? await (async () => {
await actions.navigateToView("settings");
await waitForSettingsViewReady();
const saved = await actions.saveSettingsConfig({
expertModelConfig: {
image: {
baseUrl: "https://image-smoke.example.com/v1",
apiKey: "image-smoke-key"
},
video: {
baseUrl: "https://video-smoke.example.com/v1",
apiKey: "video-smoke-key"
},
copywriting: {
baseUrl: "https://copy-smoke.example.com/v1",
apiKey: "copy-smoke-key"
}
}
});
return {
mode: "settings",
sessionId: "",
skillId: undefined,
settingsSave: saved
};
})()
? {
mode: "settings",
sessionId: "",
skillId: undefined,
settingsSave
}
: smokeExpertEntryId
? await (async () => {
const activated = await actions.activateExpertEntry(smokeExpertEntryId);
......@@ -904,7 +1060,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
...followup,
skillId: followup.skillId || selectedSkillId,
expertEntry: activated,
smokeExpertEntryAction: "activate-and-send"
smokeExpertEntryAction: "activate-and-send",
settingsSave
};
}
return {
......@@ -912,7 +1069,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
sessionId: "",
skillId: selectedSkillId,
expertEntry: activated,
smokeExpertEntryAction: "activate-only"
smokeExpertEntryAction: "activate-only",
settingsSave
};
})()
: smokeSuggestionAction
......@@ -929,7 +1087,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: continued
homeIntentActionResult: continued,
settingsSave
};
}
if (smokeSuggestionAction === "switch-expert") {
......@@ -940,7 +1099,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
skillId: selectedSkillId,
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: switched
homeIntentActionResult: switched,
settingsSave
};
}
if (smokeSuggestionAction === "dismiss") {
......@@ -952,17 +1112,24 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
homeIntentSuggestion: suggestionState,
homeIntentAction: smokeSuggestionAction,
homeIntentActionResult: dismissed,
homeIntentDismissed: true
homeIntentDismissed: true,
settingsSave
};
}
throw new Error("Unsupported smoke suggestion action: " + smokeSuggestionAction);
})()
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
});
: await (async () => {
const sent = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
});
return {
...sent,
settingsSave
};
})();
return {
prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)},
......@@ -1164,10 +1331,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
if (!api) {
throw new Error("Renderer is using mock desktop API.");
}
let sessions = state?.workspaceSummary?.sessions?.length ? state.workspaceSummary.sessions : await api.chat.listSessions();
let sessions = state?.workspaceSummary?.sessions?.length
? state.workspaceSummary.sessions
: await api.chat.listSessions().catch(() => []);
if ((!Array.isArray(sessions) || sessions.length === 0) && Array.isArray(state?.sessions)) {
sessions = state.sessions;
}
const sessionId = (state?.streamSmoke?.sessionId && sessions.some((session) => session.id === state.streamSmoke.sessionId)
? state.streamSmoke.sessionId
: sessions.find((session) => session.id === state?.activeSessionId)?.id ?? sessions[0]?.id);
: state?.activeSessionId
|| sessions.find((session) => session.id === state?.activeSessionId)?.id
|| sessions[0]?.id);
if (!sessionId) {
throw new Error("Renderer smoke state did not publish a project-scoped session.");
}
......@@ -1373,7 +1547,7 @@ async function bootstrap(): Promise<void> {
runtimeDataDir: path.join(systemSummary.userDataPath, "runtime"),
logFilePath: path.join(systemSummary.logsPath, "runtime-manager.log"),
requestedMode: resolveRequestedRuntimeMode(config.runtimeMode),
managedConfigResolver: async ({ action, defaultConfig }) => {
managedConfigResolver: async ({ defaultConfig }) => {
const latestConfig = await configService.load();
const apiKey = await secretManager.getApiKey();
if (latestConfig.setupMode === "direct-provider") {
......@@ -1385,7 +1559,11 @@ async function bootstrap(): Promise<void> {
if (!apiKey) {
return defaultConfig;
}
return runtimeCloudClient.buildManagedConfig(defaultConfig, action);
const chatModelApiKey = await secretManager.getCopywritingModelApiKey();
if (!hasConfiguredClientChatModel(latestConfig, chatModelApiKey)) {
return defaultConfig;
}
return buildClientChatManagedConfig(defaultConfig, latestConfig, chatModelApiKey);
},
strictBundledRuntime: systemSummary.isPackaged
});
......
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
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";
......@@ -46,7 +56,12 @@ interface LegacyConfig {
cloudApiBaseUrl?: string;
runtimeCloudApiBaseUrl?: string;
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 {
......@@ -130,20 +145,31 @@ function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
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 {
return {
image: {
baseUrl: "",
apiKeyConfigured: false
},
video: {
baseUrl: "",
apiKeyConfigured: false
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false
}
image: createFixedModelEndpointConfig("image"),
video: createFixedModelEndpointConfig("video"),
copywriting: createFixedModelEndpointConfig("copywriting"),
digitalHuman: createDefaultDigitalHumanModelConfig()
};
}
......@@ -153,16 +179,26 @@ function mergeExpertModelConfig(
): ExpertModelConfig {
return {
image: {
baseUrl: input?.image?.baseUrl?.trim() ?? current.image.baseUrl,
apiKeyConfigured: typeof input?.image?.apiKey === "string" ? Boolean(input.image.apiKey.trim()) : current.image.apiKeyConfigured
baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.image.baseUrl,
apiKeyConfigured: typeof input?.image?.apiKey === "string" ? Boolean(input.image.apiKey.trim()) : current.image.apiKeyConfigured,
modelId: FIXED_EXPERT_MODEL_ENDPOINTS.image.modelId
},
video: {
baseUrl: input?.video?.baseUrl?.trim() ?? current.video.baseUrl,
apiKeyConfigured: typeof input?.video?.apiKey === "string" ? Boolean(input.video.apiKey.trim()) : current.video.apiKeyConfigured
baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.video.baseUrl,
apiKeyConfigured: typeof input?.video?.apiKey === "string" ? Boolean(input.video.apiKey.trim()) : current.video.apiKeyConfigured,
modelId: FIXED_EXPERT_MODEL_ENDPOINTS.video.modelId
},
copywriting: {
baseUrl: input?.copywriting?.baseUrl?.trim() ?? current.copywriting.baseUrl,
apiKeyConfigured: typeof input?.copywriting?.apiKey === "string" ? Boolean(input.copywriting.apiKey.trim()) : current.copywriting.apiKeyConfigured
baseUrl: FIXED_EXPERT_MODEL_ENDPOINTS.copywriting.baseUrl,
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 {
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE),
expertModelConfig: {
image: {
baseUrl: config.expertModelConfig?.image?.baseUrl?.trim() ?? defaultExpertModelConfig.image.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.image?.apiKeyConfigured)
baseUrl: defaultExpertModelConfig.image.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.image?.apiKeyConfigured),
modelId: defaultExpertModelConfig.image.modelId
},
video: {
baseUrl: config.expertModelConfig?.video?.baseUrl?.trim() ?? defaultExpertModelConfig.video.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.video?.apiKeyConfigured)
baseUrl: defaultExpertModelConfig.video.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.video?.apiKeyConfigured),
modelId: defaultExpertModelConfig.video.modelId
},
copywriting: {
baseUrl: config.expertModelConfig?.copywriting?.baseUrl?.trim() ?? defaultExpertModelConfig.copywriting.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.copywriting?.apiKeyConfigured)
baseUrl: defaultExpertModelConfig.copywriting.baseUrl,
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 {
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");
}
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 {
......@@ -914,96 +911,7 @@ export class OpenClawConfigClient {
private mergeConfig(defaultConfig: Record<string, unknown>, payload: OpenClawEmployeeConfigPayload): Record<string, unknown> {
const nextConfig = cloneJson(defaultConfig);
const providerKey = "openclaw-cloud";
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;
void payload;
return mergeAdditionalModelProvidersFromEnv(nextConfig);
}
}
......
......@@ -93,6 +93,7 @@ export class DiagnosticsService {
provider: input.config.provider,
baseUrl: input.config.baseUrl,
defaultModel: input.config.defaultModel,
expertModelConfig: input.config.expertModelConfig,
workspacePath: input.config.workspacePath,
gatewayUrl: input.config.gatewayUrl,
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 {
userPrompt?: string;
attachments?: ProjectResolvedAttachment[];
runId?: string;
extraEnv?: Record<string, string>;
}
interface ProjectWorkspaceExecutionCallbacks {
......@@ -285,7 +286,8 @@ export class ProjectWorkspaceExecutorService {
].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 ?? {}),
...(input.extraEnv ?? {})
};
const spawnOptions = {
cwd: input.projectRoot,
......
......@@ -10,6 +10,10 @@ interface SecretRecord {
imageModelApiKey?: string;
videoModelApiKey?: string;
copywritingModelApiKey?: string;
digitalHumanVolcAccessKey?: string;
digitalHumanVolcSecretKey?: string;
digitalHumanQiniuAccessKey?: string;
digitalHumanQiniuSecretKey?: string;
}
interface SecretAccessor {
......@@ -17,7 +21,18 @@ interface SecretAccessor {
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");
const KEYTAR_SERVICE = "QianjiangClaw";
......@@ -29,7 +44,11 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = {
authToken: "cloud-auth-token",
imageModelApiKey: "image-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 {
......@@ -193,6 +212,38 @@ export class SecretManager {
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> {
try {
const imported = await import("keytar");
......@@ -203,7 +254,19 @@ export class SecretManager {
}
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);
if (existing) {
continue;
......
......@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QianjiangClaw</title>
<title>千匠问天</title>
</head>
<body>
<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 @@
- `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`
- `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`
- `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 `千匠问天.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`
- `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` 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-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-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 千匠问天.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-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`
......
......@@ -29,6 +29,23 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
cloudApiBaseUrl: "https://cloud.example.com",
runtimeCloudApiBaseUrl: "https://cloud.example.com",
runtimeMode: "bundled-runtime",
expertModelConfig: {
image: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
video: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
}
},
...overrides
};
}
......
......@@ -57,7 +57,7 @@ async function main(): Promise<void> {
const projectContextService = new ProjectContextService(projectStore);
const systemSummary: SystemSummary = {
appName: "QianjiangClaw",
appName: "千匠问天",
appVersion: "0.1.0-smoke",
isPackaged: false,
platform: process.platform,
......
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 180,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function New-ExpertFixtureProject {
param(
[string]$ProjectsRoot,
[string]$ProjectId,
[string]$ProjectName,
[string]$Platform,
[string]$Description,
[string]$ReadmeBody,
[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`n$ReadmeBody")
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nPassive expert fixture for desktop expert-entry smoke."
}
function Initialize-SmokeUserData {
param([string]$UserDataPath)
$projectsRoot = Join-Path $UserDataPath 'projects'
$manifestsRoot = Join-Path $UserDataPath 'manifests'
if (Test-Path $UserDataPath) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $projectsRoot, $manifestsRoot | Out-Null
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'content-account-planning-smoke' -ProjectName 'Content Account Planning Expert Workspace' -Platform 'content-account-planning' -Description 'Standalone expert fixture for account planning.' -ReadmeBody 'Used to validate standalone expert entry routing and session isolation.' -UpdatedAt '2026-04-16T00:00:00.000Z'
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'zhihu-smoke' -ProjectName 'Zhihu Expert Workspace' -Platform 'zhihu' -Description 'Standalone expert fixture for Zhihu.' -ReadmeBody 'Used to validate standalone expert entry routing and session isolation.' -UpdatedAt '2026-04-16T00:01:00.000Z'
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'douyin-expert-smoke' -ProjectName 'Douyin Expert Workspace' -Platform 'douyin' -Description 'Non-standalone expert fixture to verify experts page filtering.' -ReadmeBody 'Ensures workspace still contains other normal expert-like projects.' -UpdatedAt '2026-04-16T00:02:00.000Z'
Write-Utf8File (Join-Path $manifestsRoot 'active-project.json') (@{ projectId = 'douyin-expert-smoke' } | ConvertTo-Json -Depth 3)
}
function Invoke-ExpertEntryScenario {
param(
[string]$ScenarioName,
[string]$SmokeExpertEntryId,
[string]$BaseOutputDir,
[string]$ElectronSmokeScript,
[int]$SmokePort,
[string]$SmokeToken,
[int]$TimeoutSeconds
)
$scenarioRoot = Join-Path $BaseOutputDir $ScenarioName
$userDataPath = Join-Path $scenarioRoot 'user-data'
$logsPath = Join-Path $scenarioRoot 'logs'
$smokeOutput = Join-Path $scenarioRoot 'result.json'
Initialize-SmokeUserData -UserDataPath $userDataPath
if (Test-Path $logsPath) {
Remove-Item $logsPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $scenarioRoot, $logsPath | Out-Null
powershell -ExecutionPolicy Bypass -File $ElectronSmokeScript @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-PreserveUserData',
'-SmokeViewMode', 'chat',
'-SmokeExpertEntryId', $SmokeExpertEntryId,
'-TimeoutSeconds', $TimeoutSeconds
)
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$summaryScript = @"
const fs = require('fs');
const [scenarioName, smokeOutput, expectedExpertEntryId] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || (scenarioName + ' smoke failed.'));
}
const sendResult = result.sendResult || {};
const finalState = result.finalState || {};
const finalWorkspace = finalState.workspaceSummary || {};
const expertEntry = sendResult.expertEntry || {};
const experts = finalState.experts || {};
const standaloneIds = Array.isArray(experts.standaloneIds) ? experts.standaloneIds.map((value) => String(value || '')).sort() : [];
const homeShortcutIds = Array.isArray(experts.homeShortcutIds) ? experts.homeShortcutIds.map((value) => String(value || '')).sort() : [];
const standalonePromptAvailableIds = Array.isArray(experts.standalonePromptAvailableIds) ? experts.standalonePromptAvailableIds.map((value) => String(value || '')).sort() : [];
const expectedStandaloneIds = ['content-account-planning', 'zhihu'];
const expectedHomeShortcutIds = ['geo', 'poster', 'precision-leads', 'tiktok', 'wechat-official-account', 'x-platform'];
if (String(sendResult.smokeExpertEntryId || '') !== expectedExpertEntryId) {
throw new Error('Smoke did not report the expected expert entry id: ' + String(sendResult.smokeExpertEntryId || ''));
}
if (String(expertEntry.expertId || '') !== expectedExpertEntryId) {
throw new Error('Expert action result expertId mismatch: ' + String(expertEntry.expertId || ''));
}
if (standaloneIds.length !== 2) {
throw new Error('Unexpected standalone expert count: ' + standaloneIds.length);
}
if (homeShortcutIds.length !== 6) {
throw new Error('Unexpected home shortcut count: ' + homeShortcutIds.length);
}
if (standalonePromptAvailableIds.length !== 2) {
throw new Error('Unexpected standalone prompt-available count: ' + standalonePromptAvailableIds.length);
}
if (JSON.stringify(standaloneIds) !== JSON.stringify(expectedStandaloneIds)) {
throw new Error('Standalone expert ids mismatch: ' + JSON.stringify(standaloneIds));
}
if (JSON.stringify(homeShortcutIds) !== JSON.stringify(expectedHomeShortcutIds)) {
throw new Error('Home shortcut ids mismatch: ' + JSON.stringify(homeShortcutIds));
}
if (JSON.stringify(standalonePromptAvailableIds) !== JSON.stringify(expectedStandaloneIds)) {
throw new Error('Standalone prompt availability mismatch: ' + JSON.stringify(standalonePromptAvailableIds));
}
if (expectedStandaloneIds.includes(expectedExpertEntryId)) {
if (String(finalState.viewMode || '') !== 'experts') {
throw new Error('Standalone expert did not land on experts view.');
}
if (String(expertEntry.entryMode || '') !== 'standalone') {
throw new Error('Standalone expert entryMode mismatch: ' + String(expertEntry.entryMode || ''));
}
if (!String(expertEntry.currentProjectId || '').includes(expectedExpertEntryId)) {
throw new Error('Standalone expert did not resolve expected project: ' + String(expertEntry.currentProjectId || ''));
}
if (String(finalWorkspace.currentProjectId || '') !== String(expertEntry.currentProjectId || '')) {
throw new Error('Standalone expert final project mismatch: ' + String(finalWorkspace.currentProjectId || ''));
}
} else if (expectedHomeShortcutIds.includes(expectedExpertEntryId)) {
if (String(finalState.viewMode || '') !== 'chat') {
throw new Error('Home shortcut did not land on chat view.');
}
if (String(expertEntry.entryMode || '') !== 'home-chat-shortcut') {
throw new Error('Home shortcut entryMode mismatch: ' + String(expertEntry.entryMode || ''));
}
if (String(finalState.ui && finalState.ui.sessionScopeProjectId || '') !== 'home-chat') {
throw new Error('Home shortcut changed the session scope: ' + String(finalState.ui && finalState.ui.sessionScopeProjectId || ''));
}
if (String(expertEntry.sessionScopeProjectId || '') !== 'home-chat') {
throw new Error('Home shortcut post-action session scope mismatch: ' + String(expertEntry.sessionScopeProjectId || ''));
}
if (Number(sendResult.messageCount || 0) !== 0) {
throw new Error('Home shortcut unexpectedly created chat messages: ' + Number(sendResult.messageCount || 0));
}
if (String(sendResult.pendingHomeIntentPrompt || '')) {
throw new Error('Home shortcut unexpectedly left a pending home intent prompt.');
}
if (!String(expertEntry.prompt || '').trim()) {
throw new Error('Home shortcut did not surface a starter prompt.');
}
} else {
throw new Error('Unsupported scenario expectation: ' + expectedExpertEntryId);
}
console.log(JSON.stringify({
ok: true,
scenarioName,
smokeOutput,
expertEntryId: expectedExpertEntryId,
entryMode: expertEntry.entryMode || null,
finalViewMode: finalState.viewMode || null,
currentProjectId: finalWorkspace.currentProjectId || null,
standaloneIds,
homeShortcutIds,
standalonePromptAvailableIds
}, null, 2));
"@
$summary = & node -e $summaryScript $ScenarioName $smokeOutput $SmokeExpertEntryId
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Output $summary
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\desktop-expert-entry-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
Invoke-ExpertEntryScenario -ScenarioName 'standalone-content-account-planning' -SmokeExpertEntryId 'content-account-planning' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'standalone-zhihu' -SmokeExpertEntryId 'zhihu' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-wechat-official-account' -SmokeExpertEntryId 'wechat-official-account' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-x-platform' -SmokeExpertEntryId 'x-platform' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-tiktok' -SmokeExpertEntryId 'tiktok' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-poster' -SmokeExpertEntryId 'poster' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-geo' -SmokeExpertEntryId 'geo' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
Invoke-ExpertEntryScenario -ScenarioName 'home-shortcut-precision-leads' -SmokeExpertEntryId 'precision-leads' -BaseOutputDir $BaseOutputDir -ElectronSmokeScript $electronSmokeScript -SmokePort $SmokePort -SmokeToken $SmokeToken -TimeoutSeconds $TimeoutSeconds
......@@ -149,6 +149,17 @@ $attachmentPayload = @(
localPath = $attachmentFixturePath
}
)
$smokeSettingsConfig = [ordered]@{
image = [ordered]@{
apiKey = 'image-smoke-key'
}
video = [ordered]@{
apiKey = 'video-smoke-key'
}
copywriting = [ordered]@{
apiKey = 'copy-smoke-key'
}
}
$douyinSourceCandidates = @(
(Join-Path $repoRoot 'workspace\douyin')
)
......@@ -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_CONFIG_VERSION = $bundleConfigVersion
$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_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
......@@ -218,11 +230,35 @@ try {
$summary = & node -e @"
const fs = require('fs');
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'));
if (!result.ok) {
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) {
const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
......@@ -236,13 +272,20 @@ const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: [];
const settingsSave = sendResult.settingsSave || {};
const savedModelConfig = settingsSave.expertModelConfig || {};
const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
const projectEnvPath = path.join(userDataPath, 'projects', 'douyin', 'memory', 'project.env');
if (!fs.existsSync(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 manifestRecord = bundleManifest.douyin;
if (!manifestRecord || typeof manifestRecord !== 'object') {
......@@ -272,6 +315,18 @@ if (String(sendResult.smokeProjectId || '') !== 'douyin') {
if (String(sendResult.prompt || '') !== expectedPrompt) {
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') {
throw new Error('Final active project was not douyin: ' + String(workspaceSummary.currentProjectId || ''));
}
......@@ -322,6 +377,27 @@ if (!assistantContent.includes('Project attachments:')) {
if (!assistantContent.includes(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({
ok: true,
smokeOutput,
......@@ -336,10 +412,13 @@ console.log(JSON.stringify({
attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath,
projectEnvPath,
savedImageModelId: savedModelConfig.image && savedModelConfig.image.modelId || null,
savedCopywritingModelId: savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || null,
statusLabels,
bundleManifestPath
}, 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) {
exit $LASTEXITCODE
}
......@@ -353,6 +432,7 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_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_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
}
......@@ -88,7 +88,7 @@ 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'
$AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\千匠问天.exe'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
......@@ -109,10 +109,10 @@ if (-not (Test-Path $douyinSourceRoot)) {
throw "Douyin workspace source was not found: $douyinSourceRoot"
}
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
$runningDesktop = Get-Process -Name '千匠问天' -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."
throw "千匠问天 is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
}
if (Test-Path $BaseOutputDir) {
......
......@@ -377,15 +377,18 @@ if (smokeViewMode === 'skills') {
if (!settingsSave.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.');
}
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.');
}
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.');
}
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)) {
throw new Error('Settings smoke did not mark image model api key as configured.');
}
......@@ -487,7 +490,7 @@ if (smokeViewMode === 'skills') {
if (!sendResult.smokeExpertEntryId && streamSmoke.fallbackUsed) {
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);
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
......
......@@ -15,7 +15,7 @@ function assert(condition: unknown, message: string): asserts condition {
function buildSystemSummary(repoRoot: string, userDataPath: string, logsPath: string): SystemSummary {
return {
appName: "QianjiangClaw",
appName: "千匠问天",
appVersion: "0.1.0-smoke",
isPackaged: false,
platform: process.platform,
......
......@@ -36,8 +36,8 @@ if (Test-Path $BaseOutputDir) {
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir | Out-Null
$initialInstallDir = Join-Path $BaseOutputDir 'existing\QianjiangClaw'
$relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\QianjiangClaw'
$initialInstallDir = Join-Path $BaseOutputDir 'existing\千匠问天'
$relocatedInstallDir = Join-Path $BaseOutputDir 'relocated\千匠问天'
$initialResultPath = Join-Path $BaseOutputDir 'initial-install-result.json'
$relocatedResultPath = Join-Path $BaseOutputDir 'relocated-install-result.json'
$summaryPath = Join-Path $BaseOutputDir 'installer-path-change-summary.json'
......
......@@ -20,9 +20,6 @@ $ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$installerDir = Join-Path $repoRoot 'dist\installer'
$productName = 'QianjiangClaw'
$uninstallerFileName = "Uninstall $productName.exe"
$installedExeName = "$productName.exe"
if (-not $SetupExe) {
$latestSetup = Get-ChildItem -Path $installerDir -Filter '*-Setup-*.exe' |
......@@ -37,6 +34,17 @@ if (-not $SetupExe) {
}
$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'
if (-not $InstallDir) {
......@@ -70,6 +78,10 @@ $runtimeResourceDir = Join-Path $InstallDir 'resources\vendor\openclaw-runtime'
$packagedPythonExe = Join-Path $runtimeResourceDir 'python\python.exe'
$packagedPythonManifest = Join-Path $runtimeResourceDir 'python\python-manifest.json'
$packagedWorkspaceTemplate = Join-Path $runtimeResourceDir 'openclaw\package\docs\reference\templates\AGENTS.md'
$expectedInstalledFileDescription = $productName
$expectedInstalledProductName = $productName
$expectedInstalledInternalName = $productName
$expectedInstalledOriginalFilename = $installedExeName
$installAttempts = @()
$installerStageSummary = $null
......@@ -95,6 +107,30 @@ function Write-JsonFile {
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 {
param([string]$Path)
......@@ -234,7 +270,7 @@ function Get-InstallerRelatedProcesses {
$name = [string]$process.Name
$isMatch = $false
if ($name -eq $installedExeName -or $name -eq $uninstallerFileName) {
if ($name -eq $installedExeName -or $expectedUninstallerFileNames -contains $name) {
$isMatch = $true
}
......@@ -301,7 +337,7 @@ function Get-InstallObservation {
$snapshot = Get-InstallSnapshot -Path $Path
$observedInstalledExe = Join-Path $Path $installedExeName
$observedUninstallerPath = Join-Path $Path $uninstallerFileName
$observedUninstallerPath = Resolve-InstalledUninstallerPath -InstallPath $Path
$observedResourcesAsar = Join-Path $Path 'resources\app.asar'
$observedRuntimeResourceDir = Join-Path $Path 'resources\vendor\openclaw-runtime'
$observedPackagedPythonExe = Join-Path $observedRuntimeResourceDir 'python\python.exe'
......@@ -314,7 +350,9 @@ function Get-InstallObservation {
runtimeResourceDirExists = Test-Path $observedRuntimeResourceDir
packagedPythonExeExists = Test-Path $observedPackagedPythonExe
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
totalBytes = [int64]$snapshot.TotalBytes
}
......@@ -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 {
param(
[bool]$Ok,
......@@ -798,7 +867,7 @@ function New-FinalSummary {
setupExe = $SetupExe
installDir = $InstallDir
installedExe = $installedExe
uninstallerPath = $uninstallerPath
uninstallerPath = if ($selectedAttempt.observation.uninstallerPath) { $selectedAttempt.observation.uninstallerPath } else { $uninstallerPath }
smokeOutput = $SmokeOutput
appSmokeOutput = $appSmokeOutput
appSmokeTracePath = $appSmokeTracePath
......@@ -811,6 +880,9 @@ function New-FinalSummary {
$summary.installDirExists = $latestAttempt.observation.installDirExists
$summary.installedExeExists = $latestAttempt.observation.installedExeExists
$summary.uninstallerExists = $latestAttempt.observation.uninstallerExists
if ($latestAttempt.observation.uninstallerPath) {
$summary.uninstallerPath = $latestAttempt.observation.uninstallerPath
}
$summary.fileCount = $latestAttempt.observation.fileCount
$summary.totalBytes = [int64]$latestAttempt.observation.totalBytes
}
......@@ -1030,6 +1102,13 @@ print(json.dumps(result))
$installerStageSummary['runtimePayloadSizeBytes'] = [int64]$runtimePayloadSummary.sizeBytes
$installerStageSummary['runtimePayloadTopLevelBreakdown'] = $runtimePayloadSummary.topLevelBreakdown
$failureStage = 'branding-validation'
$failureClassification = 'branding-validation-failure'
$installedExeBrandingSummary = Assert-InstalledExeBranding
$installerStageSummary['installedExeBranding'] = $installedExeBrandingSummary
$failureStage = ''
$failureClassification = ''
if ($SkipInstalledAppSmoke) {
$appStageSummary = [ordered]@{
ok = $true
......
......@@ -23,6 +23,14 @@ if (-not $SetupExe) {
}
$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) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\installer-target-residue-smoke'
......@@ -34,9 +42,9 @@ if (Test-Path $BaseOutputDir) {
}
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'
$staleUninstallerPath = Join-Path $installDir 'Uninstall QianjiangClaw.exe'
$staleUninstallerPath = Join-Path $installDir "Uninstall $productName.exe"
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
[System.IO.File]::WriteAllText($staleUninstallerPath, 'stale residue', (New-Object System.Text.UTF8Encoding $false))
......
......@@ -112,7 +112,7 @@ description: douyin script writing skill
const skillRouter = new ProjectSkillRouterService(projectStore);
const projectContextService = new ProjectContextService(projectStore);
const systemSummary: SystemSummary = {
appName: "QianjiangClaw",
appName: "千匠问天",
appVersion: "0.1.0-smoke",
isPackaged: false,
platform: process.platform,
......
......@@ -180,6 +180,17 @@ $attachmentPayload = @(
localPath = $attachmentFixturePath
}
)
$smokeSettingsConfig = [ordered]@{
image = [ordered]@{
apiKey = 'image-smoke-key'
}
video = [ordered]@{
apiKey = 'video-smoke-key'
}
copywriting = [ordered]@{
apiKey = 'copy-smoke-key'
}
}
$xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\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'
$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_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$env:QJCLAW_SMOKE_SETTINGS_CONFIG_JSON = ($smokeSettingsConfig | ConvertTo-Json -Depth 10 -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_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
......@@ -256,11 +266,35 @@ try {
$summary = & node -e @"
const fs = require('fs');
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'));
if (!result.ok) {
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) {
const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
......@@ -274,13 +308,20 @@ const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: [];
const settingsSave = sendResult.settingsSave || {};
const savedModelConfig = settingsSave.expertModelConfig || {};
const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
const projectEnvPath = path.join(userDataPath, 'projects', 'xhs', 'memory', 'project.env');
if (!fs.existsSync(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 manifestRecord = bundleManifest.xhs;
if (!manifestRecord || typeof manifestRecord !== 'object') {
......@@ -310,6 +351,18 @@ if (String(sendResult.smokeProjectId || '') !== 'xhs') {
if (String(sendResult.prompt || '') !== expectedPrompt) {
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') {
throw new Error('Final active project was not xhs: ' + String(workspaceSummary.currentProjectId || ''));
}
......@@ -353,6 +406,30 @@ const attachmentStat = fs.statSync(expectedAttachmentPath);
if (!attachmentStat.isFile() || attachmentStat.size < 1) {
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({
ok: true,
smokeOutput,
......@@ -367,10 +444,13 @@ console.log(JSON.stringify({
attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath,
projectEnvPath,
savedImageModelId: savedModelConfig.image && savedModelConfig.image.modelId || null,
savedCopywritingModelId: savedModelConfig.copywriting && savedModelConfig.copywriting.modelId || null,
statusLabels,
bundleManifestPath
}, 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) {
exit $LASTEXITCODE
}
......@@ -384,9 +464,8 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_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: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_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
......
......@@ -150,7 +150,7 @@ if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-manual-launch'
}
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)
......@@ -171,10 +171,10 @@ if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
}
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
$runningDesktop = Get-Process -Name '千匠问天' -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."
throw "千匠问天 is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
}
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