Commit 61fd84f1 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): add settings model config smoke coverage

Add desktop settings persistence for expert model config and cover the new save flow with a dedicated settings smoke test.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent ee9c7dd7
......@@ -83,9 +83,18 @@ interface RendererSmokeState {
} | null;
skills: Array<{ id: string; name: string }>;
modelConfig: {
defaultChatModelLabel?: string;
routingMode?: string;
itemCount?: number;
image?: {
baseUrl?: string;
apiKeyConfigured?: boolean;
};
video?: {
baseUrl?: string;
apiKeyConfigured?: boolean;
};
copywriting?: {
baseUrl?: string;
apiKeyConfigured?: boolean;
};
} | null;
systemSummary: {
isPackaged?: boolean;
......@@ -280,6 +289,20 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
return null;
}
function matchesExpectedSmokeModelConfig(state: RendererSmokeState | null | undefined): boolean {
const modelConfig = state?.modelConfig;
if (!modelConfig) {
return false;
}
return String(modelConfig.image?.baseUrl || "") === "https://image-smoke.example.com/v1"
&& String(modelConfig.video?.baseUrl || "") === "https://video-smoke.example.com/v1"
&& String(modelConfig.copywriting?.baseUrl || "") === "https://copy-smoke.example.com/v1"
&& Boolean(modelConfig.image?.apiKeyConfigured)
&& Boolean(modelConfig.video?.apiKeyConfigured)
&& Boolean(modelConfig.copywriting?.apiKeyConfigured);
}
async function waitForRendererSmokeBootstrap(window: BrowserWindow, timeoutMs = 20000): Promise<RendererSmokeState> {
return await new Promise<RendererSmokeState>((resolve, reject) => {
let settled = false;
......@@ -648,6 +671,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
? "experts"
: requestedSmokeViewMode === "skills"
? "skills"
: requestedSmokeViewMode === "settings"
? "settings"
: "chat";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
......@@ -771,6 +796,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
runtimeState: workspace.runtimeState
}));
};
const waitForSettingsViewReady = async () => {
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
const rendererState = window.__QJC_SMOKE__;
if (rendererState?.viewMode === "settings" && rendererState.config && rendererState.modelConfig) {
return rendererState;
}
await sleep(250);
}
throw new Error("Settings view did not become ready for smoke validation.");
};
const runtimeCloudStatus = await api.runtimeCloud.getStatus();
let runtimeCloudFetch = runtimeCloudStatus;
let runtimeCloudFetchError = null;
......@@ -797,8 +833,10 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : [];
const skillsPageState = smokeViewMode === "skills" ? await waitForSkillsPageReady() : null;
const workspace = skillsPageState?.workspace ?? await waitForWorkspaceReady();
const readyWorkspaceSkills = workspace.skills.filter((skill) => skill.ready);
const workspace = smokeViewMode === "settings"
? await api.workspace.getSummary()
: skillsPageState?.workspace ?? await waitForWorkspaceReady();
const readyWorkspaceSkills = Array.isArray(workspace.skills) ? workspace.skills.filter((skill) => skill.ready) : [];
const readySkills = skills.filter((skill) => skill.ready);
const selectedSkillId = preferredSkillId
? (readyWorkspaceSkills.find((skill) => skill.id === preferredSkillId)?.id
......@@ -807,6 +845,33 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const system = await api.system.getSummary();
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
};
})()
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
......@@ -834,6 +899,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
skills,
selectedSkillId: actionResult.skillId || selectedSkillId,
initialSessionId: actionResult.sessionId,
settingsSave: actionResult.settingsSave,
system,
health: gatewayProbe.health,
status: gatewayProbe.status
......@@ -841,6 +907,33 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})()`);
await trace("runSmokeTest:send-script-finished");
if (smokeViewMode === "settings") {
let finalState: RendererSmokeState | null = null;
const started = Date.now();
while (Date.now() - started < 5000) {
finalState = await waitForRendererSmokeState(window, 1000);
if (finalState?.viewMode === "settings" && matchesExpectedSmokeModelConfig(finalState)) {
break;
}
await delay(100);
}
if (!finalState || finalState.viewMode !== "settings") {
throw new Error("Renderer smoke did not switch to settings view.");
}
if (!matchesExpectedSmokeModelConfig(finalState)) {
throw new Error("Renderer smoke did not publish the saved settings model config in time.");
}
result.sendResult = sendResult;
result.finalState = finalState;
result.ok = true;
await trace("runSmokeTest:settings-view-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
const streamState = smokeViewMode === "skills"
? await waitForRendererSmokeState(window, 5000)
: await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
......
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppConfig, RuntimeModePreference, SaveConfigInput, SetupMode } from "@qjclaw/shared-types";
import type { AppConfig, ExpertModelConfig, RuntimeModePreference, SaveConfigInput, SetupMode } from "@qjclaw/shared-types";
export type RuntimeCloudApiBaseUrlSource = "config" | "env" | "default";
......@@ -46,6 +46,7 @@ interface LegacyConfig {
cloudApiBaseUrl?: string;
runtimeCloudApiBaseUrl?: string;
runtimeMode?: RuntimeModePreference;
expertModelConfig?: Partial<Record<keyof ExpertModelConfig, Partial<ExpertModelConfig[keyof ExpertModelConfig]>>>;
}
function normalizeGatewayUrl(raw: string): string {
......@@ -98,6 +99,29 @@ function normalizeRuntimeCloudApiBaseUrl(raw?: string): string {
return normalizeCloudApiBaseUrl(raw ?? "");
}
function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget {
const configValue = migrateDeprecatedRuntimeCloudApiBaseUrl(raw);
if (configValue) {
return {
baseUrl: configValue,
source: "config"
};
}
const envValue = migrateDeprecatedRuntimeCloudApiBaseUrl(process.env.QJCLAW_RUNTIME_CLOUD_API_BASE_URL);
if (envValue) {
return {
baseUrl: envValue,
source: "env"
};
}
return {
baseUrl: DEFAULT_RUNTIME_CLOUD_API_BASE_URL,
source: "default"
};
}
function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized && DEPRECATED_RUNTIME_CLOUD_API_BASE_URLS.has(normalized)) {
......@@ -106,18 +130,41 @@ function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
return normalized;
}
function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized) {
return { baseUrl: normalized, source: "config" };
function createDefaultExpertModelConfig(): ExpertModelConfig {
return {
image: {
baseUrl: "",
apiKeyConfigured: false
},
video: {
baseUrl: "",
apiKeyConfigured: false
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false
}
};
}
const envValue = normalizeCloudApiBaseUrl(process.env.QJCLAW_RUNTIME_CLOUD_API_BASE_URL ?? "");
if (envValue) {
return { baseUrl: envValue, source: "env" };
function mergeExpertModelConfig(
current: ExpertModelConfig,
input?: SaveConfigInput["expertModelConfig"]
): 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
},
video: {
baseUrl: input?.video?.baseUrl?.trim() ?? current.video.baseUrl,
apiKeyConfigured: typeof input?.video?.apiKey === "string" ? Boolean(input.video.apiKey.trim()) : current.video.apiKeyConfigured
},
copywriting: {
baseUrl: input?.copywriting?.baseUrl?.trim() ?? current.copywriting.baseUrl,
apiKeyConfigured: typeof input?.copywriting?.apiKey === "string" ? Boolean(input.copywriting.apiKey.trim()) : current.copywriting.apiKeyConfigured
}
return { baseUrl: DEFAULT_RUNTIME_CLOUD_API_BASE_URL, source: "default" };
};
}
export function getRuntimeCloudApiTarget(config: Pick<AppConfig, "runtimeCloudApiBaseUrl">): RuntimeCloudApiTarget {
......@@ -151,7 +198,8 @@ export class AppConfigService {
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
runtimeMode: normalizeRuntimeMode(input.runtimeMode),
expertModelConfig: mergeExpertModelConfig(current.expertModelConfig, input.expertModelConfig)
};
await this.writeConfig(config);
......@@ -180,11 +228,13 @@ export class AppConfigService {
gatewayUrl: "ws://127.0.0.1:18789",
cloudApiBaseUrl: normalizeCloudApiBaseUrl(process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: "",
runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE)
runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE),
expertModelConfig: createDefaultExpertModelConfig()
};
}
private normalizeConfig(config: LegacyConfig): AppConfig {
const defaultExpertModelConfig = createDefaultExpertModelConfig();
return {
setupMode: normalizeSetupMode(config.setupMode),
provider: config.provider ?? "openai",
......@@ -197,7 +247,21 @@ export class AppConfigService {
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
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)
},
video: {
baseUrl: config.expertModelConfig?.video?.baseUrl?.trim() ?? defaultExpertModelConfig.video.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.video?.apiKeyConfigured)
},
copywriting: {
baseUrl: config.expertModelConfig?.copywriting?.baseUrl?.trim() ?? defaultExpertModelConfig.copywriting.baseUrl,
apiKeyConfigured: Boolean(config.expertModelConfig?.copywriting?.apiKeyConfigured)
}
}
};
}
......
......@@ -7,6 +7,9 @@ interface SecretRecord {
gatewayToken?: string;
deviceToken?: string;
authToken?: string;
imageModelApiKey?: string;
videoModelApiKey?: string;
copywritingModelApiKey?: string;
}
interface SecretAccessor {
......@@ -14,7 +17,7 @@ interface SecretAccessor {
set(secretName: SecretName, value?: string): Promise<void>;
}
type SecretName = "apiKey" | "gatewayToken" | "deviceToken" | "authToken";
type SecretName = "apiKey" | "gatewayToken" | "deviceToken" | "authToken" | "imageModelApiKey" | "videoModelApiKey" | "copywritingModelApiKey";
type KeytarModule = typeof import("keytar");
const KEYTAR_SERVICE = "QianjiangClaw";
......@@ -23,7 +26,10 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = {
apiKey: "provider-api-key",
gatewayToken: "gateway-token",
deviceToken: "gateway-device-token",
authToken: "cloud-auth-token"
authToken: "cloud-auth-token",
imageModelApiKey: "image-model-api-key",
videoModelApiKey: "video-model-api-key",
copywritingModelApiKey: "copywriting-model-api-key"
};
class FileSecretStore implements SecretAccessor {
......@@ -163,6 +169,30 @@ export class SecretManager {
return this.store.get("authToken");
}
async setImageModelApiKey(apiKey?: string): Promise<void> {
await this.store.set("imageModelApiKey", apiKey);
}
async getImageModelApiKey(): Promise<string | undefined> {
return this.store.get("imageModelApiKey");
}
async setVideoModelApiKey(apiKey?: string): Promise<void> {
await this.store.set("videoModelApiKey", apiKey);
}
async getVideoModelApiKey(): Promise<string | undefined> {
return this.store.get("videoModelApiKey");
}
async setCopywritingModelApiKey(apiKey?: string): Promise<void> {
await this.store.set("copywritingModelApiKey", apiKey);
}
async getCopywritingModelApiKey(): Promise<string | undefined> {
return this.store.get("copywritingModelApiKey");
}
private async tryLoadKeytar(): Promise<KeytarModule | null> {
try {
const imported = await import("keytar");
......@@ -173,7 +203,7 @@ export class SecretManager {
}
private async migrateFallbackSecrets(): Promise<void> {
for (const secretName of ["apiKey", "gatewayToken", "deviceToken", "authToken"] as const) {
for (const secretName of ["apiKey", "gatewayToken", "deviceToken", "authToken", "imageModelApiKey", "videoModelApiKey", "copywritingModelApiKey"] as const) {
const existing = await this.store.get(secretName);
if (existing) {
continue;
......
......@@ -344,6 +344,37 @@ if (smokeViewMode === 'skills') {
if (typeof sendResult.workspaceSkillCount !== 'number') {
throw new Error('Skills smoke did not report workspaceSkillCount.');
}
} else if (smokeViewMode === 'settings') {
if (String(finalState.viewMode || '') !== 'settings') {
throw new Error('Settings smoke did not end on settings view: ' + String(finalState.viewMode || ''));
}
const settingsSave = sendResult.settingsSave || {};
const modelConfig = finalState.modelConfig || {};
const config = finalState.config || {};
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') {
throw new Error('Settings smoke did not persist image model baseUrl.');
}
if (String(modelConfig.video && modelConfig.video.baseUrl || '') !== 'https://video-smoke.example.com/v1') {
throw new Error('Settings smoke did not persist video model baseUrl.');
}
if (String(modelConfig.copywriting && modelConfig.copywriting.baseUrl || '') !== 'https://copy-smoke.example.com/v1') {
throw new Error('Settings smoke did not persist copywriting model baseUrl.');
}
if (!Boolean(modelConfig.image && modelConfig.image.apiKeyConfigured)) {
throw new Error('Settings smoke did not mark image model api key as configured.');
}
if (!Boolean(modelConfig.video && modelConfig.video.apiKeyConfigured)) {
throw new Error('Settings smoke did not mark video model api key as configured.');
}
if (!Boolean(modelConfig.copywriting && modelConfig.copywriting.apiKeyConfigured)) {
throw new Error('Settings smoke did not mark copywriting model api key as configured.');
}
if (String(config.runtimeMode || '') !== 'bundled-runtime') {
throw new Error('Settings smoke unexpectedly changed runtimeMode: ' + String(config.runtimeMode || ''));
}
} else {
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
const statusLabels = Array.isArray(streamSmoke.statusLabels)
......@@ -394,7 +425,7 @@ const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH =
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.');
}
if (smokeViewMode !== 'skills') {
if (!['skills', 'settings'].includes(smokeViewMode)) {
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
......
param(
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 180
)
$ErrorActionPreference = 'Stop'
function Invoke-ElectronSmokeWithRetry {
param(
[string]$ScriptPath,
[string]$Label,
[string[]]$ArgumentList,
[int]$MaxAttempts = 2
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) {
powershell -ExecutionPolicy Bypass -File $ScriptPath @ArgumentList
if ($LASTEXITCODE -eq 0) {
return
}
if ($attempt -ge $MaxAttempts) {
exit $LASTEXITCODE
}
Write-Warning "$Label failed on attempt $attempt. Retrying..."
Start-Sleep -Seconds 2
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\settings-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$smokeOutput = Join-Path $BaseOutputDir 'result.json'
$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, $userDataPath, $logsPath | Out-Null
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'settings smoke' -ArgumentList @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-SmokeViewMode', 'settings',
'-TimeoutSeconds', $TimeoutSeconds
)
......@@ -39,6 +39,20 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
cloudApiBaseUrl: "https://cloud.example.com",
runtimeCloudApiBaseUrl: "https://cloud.example.com",
runtimeMode: "bundled-runtime",
expertModelConfig: {
image: {
baseUrl: "",
apiKeyConfigured: false
},
video: {
baseUrl: "",
apiKeyConfigured: false
},
copywriting: {
baseUrl: "",
apiKeyConfigured: false
}
},
...overrides
};
}
......
......@@ -24,6 +24,7 @@
"launch:xhs-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1",
"launch:douyin-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-manual-launch.ps1",
"smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1",
"smoke:settings": "powershell -ExecutionPolicy Bypass -File build/scripts/settings-smoke.ps1",
"smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1",
"smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1",
"smoke:project-isolation": "powershell -ExecutionPolicy Bypass -File build/scripts/project-isolation-smoke.ps1",
......
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