From d017617917e668a768e0bf1aa72a0f3b6bf3d7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Wed, 22 Apr 2026 21:46:29 +0800 Subject: [PATCH 1/6] feat(providers): add image generation client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 杨峻骁 --- packages/providers/src/images.test.ts | 116 +++++++++++++ packages/providers/src/images.ts | 238 ++++++++++++++++++++++++++ packages/providers/src/index.ts | 11 ++ 3 files changed, 365 insertions(+) create mode 100644 packages/providers/src/images.test.ts create mode 100644 packages/providers/src/images.ts diff --git a/packages/providers/src/images.test.ts b/packages/providers/src/images.test.ts new file mode 100644 index 00000000..a2644c92 --- /dev/null +++ b/packages/providers/src/images.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defaultImageModel, generateImage } from './images'; + +describe('generateImage', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('calls OpenAI image generations and normalizes b64_json', async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + data: [{ b64_json: 'aW1hZ2U=', revised_prompt: 'A clean hero image' }], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await generateImage({ + provider: 'openai', + apiKey: 'sk-test', + prompt: 'hero image', + model: 'gpt-image-2', + size: '1536x1024', + quality: 'high', + outputFormat: 'png', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.openai.com/v1/images/generations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: 'gpt-image-2', + prompt: 'hero image', + n: 1, + size: '1536x1024', + quality: 'high', + output_format: 'png', + }), + }), + ); + expect(result).toMatchObject({ + provider: 'openai', + model: 'gpt-image-2', + mimeType: 'image/png', + dataUrl: 'data:image/png;base64,aW1hZ2U=', + revisedPrompt: 'A clean hero image', + }); + }); + + it('calls OpenRouter chat completions with image modalities', async () => { + const fetchMock = vi.fn(async () => { + return new Response( + JSON.stringify({ + choices: [ + { + message: { + images: [ + { + type: 'image_url', + image_url: { url: 'data:image/webp;base64,d2VicA==' }, + }, + ], + }, + }, + ], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await generateImage({ + provider: 'openrouter', + apiKey: 'sk-or-test', + prompt: 'poster', + aspectRatio: '16:9', + outputFormat: 'webp', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://openrouter.ai/api/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: defaultImageModel('openrouter'), + messages: [{ role: 'user', content: 'poster' }], + modalities: ['image', 'text'], + stream: false, + image_config: { + aspect_ratio: '16:9', + output_format: 'webp', + }, + }), + }), + ); + expect(result).toMatchObject({ + provider: 'openrouter', + model: defaultImageModel('openrouter'), + mimeType: 'image/webp', + base64: 'd2VicA==', + }); + }); + + it('rejects missing API keys before making a request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + generateImage({ provider: 'openai', apiKey: '', prompt: 'hero image' }), + ).rejects.toThrow(/Missing image generation API key/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/providers/src/images.ts b/packages/providers/src/images.ts new file mode 100644 index 00000000..530c1252 --- /dev/null +++ b/packages/providers/src/images.ts @@ -0,0 +1,238 @@ +import { CodesignError, ERROR_CODES } from '@open-codesign/shared'; + +export type ImageGenerationProvider = 'openai' | 'openrouter'; +export type ImageOutputFormat = 'png' | 'jpeg' | 'webp'; +export type ImageQuality = 'auto' | 'low' | 'medium' | 'high'; +export type ImageSize = 'auto' | '1024x1024' | '1536x1024' | '1024x1536'; +export type ImageAspectRatio = '1:1' | '16:9' | '9:16' | '4:3' | '3:4'; + +export interface GenerateImageOptions { + provider: ImageGenerationProvider; + apiKey: string; + prompt: string; + model?: string | undefined; + baseUrl?: string | undefined; + size?: ImageSize | undefined; + aspectRatio?: ImageAspectRatio | undefined; + quality?: ImageQuality | undefined; + outputFormat?: ImageOutputFormat | undefined; + background?: 'auto' | 'transparent' | 'opaque' | undefined; + signal?: AbortSignal | undefined; + httpHeaders?: Record | undefined; +} + +export interface GenerateImageResult { + dataUrl: string; + mimeType: string; + base64: string; + model: string; + provider: ImageGenerationProvider; + revisedPrompt?: string | undefined; +} + +interface OpenAIImageResponse { + data?: Array<{ + b64_json?: unknown; + url?: unknown; + revised_prompt?: unknown; + }>; +} + +interface OpenRouterImageResponse { + choices?: Array<{ + message?: { + content?: unknown; + images?: Array<{ + type?: unknown; + image_url?: { + url?: unknown; + }; + imageUrl?: { + url?: unknown; + }; + }>; + }; + }>; +} + +const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; +const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +const DEFAULT_OPENAI_IMAGE_MODEL = 'gpt-image-2'; +const DEFAULT_OPENROUTER_IMAGE_MODEL = 'openai/gpt-5.4-image-2'; + +export function defaultImageModel(provider: ImageGenerationProvider): string { + return provider === 'openrouter' ? DEFAULT_OPENROUTER_IMAGE_MODEL : DEFAULT_OPENAI_IMAGE_MODEL; +} + +export function defaultImageBaseUrl(provider: ImageGenerationProvider): string { + return provider === 'openrouter' ? DEFAULT_OPENROUTER_BASE_URL : DEFAULT_OPENAI_BASE_URL; +} + +export async function generateImage(options: GenerateImageOptions): Promise { + if (!options.apiKey.trim()) { + throw new CodesignError('Missing image generation API key', ERROR_CODES.PROVIDER_AUTH_MISSING); + } + const prompt = options.prompt.trim(); + if (prompt.length === 0) { + throw new CodesignError('Image prompt cannot be empty', ERROR_CODES.INPUT_EMPTY_PROMPT); + } + return options.provider === 'openrouter' + ? generateOpenRouterImage({ ...options, prompt }) + : generateOpenAIImage({ ...options, prompt }); +} + +async function generateOpenAIImage( + options: GenerateImageOptions & { prompt: string }, +): Promise { + const model = options.model?.trim() || DEFAULT_OPENAI_IMAGE_MODEL; + const body: Record = { + model, + prompt: options.prompt, + n: 1, + }; + if (options.size !== undefined) body['size'] = options.size; + if (options.quality !== undefined) body['quality'] = options.quality; + if (options.outputFormat !== undefined) body['output_format'] = options.outputFormat; + if (options.background !== undefined) body['background'] = options.background; + + const json = await postJson( + joinEndpoint(options.baseUrl ?? DEFAULT_OPENAI_BASE_URL, 'images/generations'), + body, + options, + ); + const first = json.data?.[0]; + if (first === undefined) { + throw new CodesignError( + 'OpenAI image response did not include data', + ERROR_CODES.PROVIDER_ERROR, + ); + } + const revisedPrompt = + typeof first.revised_prompt === 'string' && first.revised_prompt.length > 0 + ? first.revised_prompt + : undefined; + if (typeof first.b64_json === 'string' && first.b64_json.length > 0) { + const mimeType = mimeFromFormat(options.outputFormat ?? 'png'); + return { + dataUrl: `data:${mimeType};base64,${first.b64_json}`, + base64: first.b64_json, + mimeType, + model, + provider: 'openai', + ...(revisedPrompt !== undefined ? { revisedPrompt } : {}), + }; + } + if (typeof first.url === 'string' && first.url.startsWith('data:')) { + return { + ...parseDataUrl(first.url), + model, + provider: 'openai', + ...(revisedPrompt !== undefined ? { revisedPrompt } : {}), + }; + } + throw new CodesignError( + 'OpenAI image response did not include base64 image data', + ERROR_CODES.PROVIDER_ERROR, + ); +} + +async function generateOpenRouterImage( + options: GenerateImageOptions & { prompt: string }, +): Promise { + const model = options.model?.trim() || DEFAULT_OPENROUTER_IMAGE_MODEL; + const imageConfig: Record = {}; + if (options.aspectRatio !== undefined) imageConfig['aspect_ratio'] = options.aspectRatio; + if (options.quality !== undefined && options.quality !== 'auto') + imageConfig['quality'] = options.quality; + if (options.outputFormat !== undefined) imageConfig['output_format'] = options.outputFormat; + + const body: Record = { + model, + messages: [{ role: 'user', content: options.prompt }], + modalities: ['image', 'text'], + stream: false, + }; + if (Object.keys(imageConfig).length > 0) body['image_config'] = imageConfig; + + const json = await postJson( + joinEndpoint(options.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL, 'chat/completions'), + body, + options, + ); + const message = json.choices?.[0]?.message; + const image = message?.images?.[0]; + const url = image?.image_url?.url ?? image?.imageUrl?.url; + if (typeof url === 'string' && url.startsWith('data:')) { + return { + ...parseDataUrl(url), + model, + provider: 'openrouter', + }; + } + throw new CodesignError( + 'OpenRouter image response did not include generated image data', + ERROR_CODES.PROVIDER_ERROR, + ); +} + +async function postJson( + url: string, + body: Record, + options: GenerateImageOptions, +): Promise { + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + ...(options.signal !== undefined ? { signal: options.signal } : {}), + headers: { + authorization: `Bearer ${options.apiKey}`, + 'content-type': 'application/json', + accept: 'application/json', + ...(options.httpHeaders ?? {}), + }, + body: JSON.stringify(body), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CodesignError( + `Image generation request failed: ${message}`, + ERROR_CODES.PROVIDER_ERROR, + { + cause: err, + }, + ); + } + if (!res.ok) { + const text = await safeResponseText(res); + throw new CodesignError( + `Image generation failed with HTTP ${res.status}${text.length > 0 ? `: ${text}` : ''}`, + ERROR_CODES.PROVIDER_ERROR, + ); + } + return (await res.json()) as T; +} + +function joinEndpoint(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; +} + +function mimeFromFormat(format: ImageOutputFormat): string { + return format === 'jpeg' ? 'image/jpeg' : `image/${format}`; +} + +function parseDataUrl(dataUrl: string): { dataUrl: string; mimeType: string; base64: string } { + const match = /^data:([^;,]+);base64,(.+)$/i.exec(dataUrl); + if (!match || match[1] === undefined || match[2] === undefined) { + throw new CodesignError('Generated image data URL is malformed', ERROR_CODES.PROVIDER_ERROR); + } + return { dataUrl, mimeType: match[1], base64: match[2] }; +} + +async function safeResponseText(res: Response): Promise { + try { + return (await res.text()).slice(0, 500); + } catch { + return ''; + } +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 940b103e..872f0681 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -491,6 +491,17 @@ export { looksLikeGatewayMissingMessagesApi } from './gateway-compat'; export { injectSkillsIntoMessages, formatSkillsForPrompt, filterActive } from './skill-injector'; +export { defaultImageBaseUrl, defaultImageModel, generateImage } from './images'; +export type { + GenerateImageOptions, + GenerateImageResult, + ImageAspectRatio, + ImageGenerationProvider, + ImageOutputFormat, + ImageQuality, + ImageSize, +} from './images'; + // Tier 2 surface (not yet implemented): // structuredComplete(model, schema, messages, opts): Promise // streamArtifacts(model, messages, opts): AsyncIterable From 42487a110dd28208655c3ab1e6ed3c66859d12ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Wed, 22 Apr 2026 21:48:17 +0800 Subject: [PATCH 2/6] feat(core): add image asset generation tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 杨峻骁 --- .../src/main/image-generation-settings.ts | 237 ++++++++++++++++++ apps/desktop/src/main/index.ts | 95 ++++++- packages/core/src/agent.ts | 38 ++- packages/core/src/index.ts | 7 + .../src/tools/generate-image-asset.test.ts | 73 ++++++ .../core/src/tools/generate-image-asset.ts | 126 ++++++++++ packages/shared/src/config.test.ts | 44 ++++ packages/shared/src/config.ts | 33 +++ packages/shared/src/index.ts | 13 + 9 files changed, 662 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/main/image-generation-settings.ts create mode 100644 packages/core/src/tools/generate-image-asset.test.ts create mode 100644 packages/core/src/tools/generate-image-asset.ts diff --git a/apps/desktop/src/main/image-generation-settings.ts b/apps/desktop/src/main/image-generation-settings.ts new file mode 100644 index 00000000..d28a9d54 --- /dev/null +++ b/apps/desktop/src/main/image-generation-settings.ts @@ -0,0 +1,237 @@ +import { + type GenerateImageOptions, + defaultImageBaseUrl, + defaultImageModel, +} from '@open-codesign/providers'; +import { + CodesignError, + type Config, + ERROR_CODES, + IMAGE_GENERATION_SCHEMA_VERSION, + type ImageGenerationCredentialMode, + ImageGenerationCredentialModeSchema, + type ImageGenerationOutputFormat, + ImageGenerationOutputFormatSchema, + type ImageGenerationProvider, + ImageGenerationProviderSchema, + type ImageGenerationQuality, + ImageGenerationQualitySchema, + type ImageGenerationSettings, + ImageGenerationSettingsSchema, + type ImageGenerationSize, + ImageGenerationSizeSchema, + hydrateConfig, +} from '@open-codesign/shared'; +import { writeConfig } from './config'; +import { ipcMain } from './electron-runtime'; +import { buildSecretRef, decryptSecret } from './keychain'; +import { getApiKeyForProvider, getCachedConfig, setCachedConfig } from './onboarding-ipc'; + +export interface ImageGenerationSettingsView { + enabled: boolean; + provider: ImageGenerationProvider; + credentialMode: ImageGenerationCredentialMode; + model: string; + baseUrl: string; + quality: ImageGenerationQuality; + size: ImageGenerationSize; + outputFormat: ImageGenerationOutputFormat; + hasCustomKey: boolean; + maskedKey: string | null; +} + +interface ImageGenerationUpdateInput { + enabled?: boolean; + provider?: ImageGenerationProvider; + credentialMode?: ImageGenerationCredentialMode; + model?: string; + baseUrl?: string; + quality?: ImageGenerationQuality; + size?: ImageGenerationSize; + outputFormat?: ImageGenerationOutputFormat; + apiKey?: string; +} + +export interface ResolvedImageGenerationConfig { + provider: ImageGenerationProvider; + apiKey: string; + model: string; + baseUrl: string; + quality: ImageGenerationQuality; + size: ImageGenerationSize; + outputFormat: ImageGenerationOutputFormat; +} + +export function defaultImageGenerationSettings(): ImageGenerationSettings { + return { + schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION, + enabled: false, + provider: 'openai', + credentialMode: 'inherit', + model: defaultImageModel('openai'), + quality: 'high', + size: '1536x1024', + outputFormat: 'png', + }; +} + +export function imageSettingsToView( + settings: ImageGenerationSettings | undefined, +): ImageGenerationSettingsView { + const parsed = ImageGenerationSettingsSchema.parse(settings ?? defaultImageGenerationSettings()); + return { + enabled: parsed.enabled, + provider: parsed.provider, + credentialMode: parsed.credentialMode, + model: parsed.model, + baseUrl: parsed.baseUrl ?? defaultImageBaseUrl(parsed.provider), + quality: parsed.quality, + size: parsed.size, + outputFormat: parsed.outputFormat, + hasCustomKey: parsed.apiKey !== undefined, + maskedKey: parsed.apiKey?.mask ?? null, + }; +} + +export function resolveImageGenerationConfig(cfg: Config): ResolvedImageGenerationConfig | null { + const settings = cfg.imageGeneration; + if (settings === undefined || settings.enabled !== true) return null; + const parsed = ImageGenerationSettingsSchema.parse(settings); + let apiKey: string; + if (parsed.credentialMode === 'custom') { + if (parsed.apiKey === undefined) return null; + apiKey = decryptSecret(parsed.apiKey.ciphertext); + } else { + try { + apiKey = getApiKeyForProvider(parsed.provider); + } catch { + return null; + } + } + const inheritedBaseUrl = + parsed.credentialMode === 'inherit' ? cfg.providers[parsed.provider]?.baseUrl : undefined; + return { + provider: parsed.provider, + apiKey, + model: parsed.model, + baseUrl: parsed.baseUrl ?? inheritedBaseUrl ?? defaultImageBaseUrl(parsed.provider), + quality: parsed.quality, + size: parsed.size, + outputFormat: parsed.outputFormat, + }; +} + +export function toGenerateImageOptions( + config: ResolvedImageGenerationConfig, + prompt: string, + signal?: AbortSignal, +): GenerateImageOptions { + return { + provider: config.provider, + apiKey: config.apiKey, + model: config.model, + baseUrl: config.baseUrl, + prompt, + quality: config.quality, + size: config.size, + outputFormat: config.outputFormat, + ...(signal !== undefined ? { signal } : {}), + }; +} + +function parseUpdate(raw: unknown): ImageGenerationUpdateInput { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError( + 'image-generation:v1:update expects an object', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + const r = raw as Record; + const out: ImageGenerationUpdateInput = {}; + if (typeof r['enabled'] === 'boolean') out.enabled = r['enabled']; + if (typeof r['provider'] === 'string') { + out.provider = ImageGenerationProviderSchema.parse(r['provider']); + } + if (typeof r['credentialMode'] === 'string') { + out.credentialMode = ImageGenerationCredentialModeSchema.parse(r['credentialMode']); + } + if (typeof r['model'] === 'string') { + const model = r['model'].trim(); + if (model.length > 0) out.model = model; + } + if (typeof r['baseUrl'] === 'string') { + const baseUrl = r['baseUrl'].trim(); + if (baseUrl.length > 0) out.baseUrl = baseUrl; + } + if (typeof r['quality'] === 'string') { + out.quality = ImageGenerationQualitySchema.parse(r['quality']); + } + if (typeof r['size'] === 'string') { + out.size = ImageGenerationSizeSchema.parse(r['size']); + } + if (typeof r['outputFormat'] === 'string') { + out.outputFormat = ImageGenerationOutputFormatSchema.parse(r['outputFormat']); + } + if (typeof r['apiKey'] === 'string') out.apiKey = r['apiKey']; + return out; +} + +async function updateImageGenerationSettings( + patch: ImageGenerationUpdateInput, +): Promise { + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError('No configuration found', ERROR_CODES.CONFIG_MISSING); + } + const current = ImageGenerationSettingsSchema.parse( + cfg.imageGeneration ?? defaultImageGenerationSettings(), + ); + const { apiKey: apiKeyPatch, ...safePatch } = patch; + const provider = patch.provider ?? current.provider; + const providerChanged = patch.provider !== undefined && patch.provider !== current.provider; + let next: ImageGenerationSettings = { + ...current, + ...safePatch, + provider, + model: patch.model ?? (providerChanged ? defaultImageModel(provider) : current.model), + }; + if (patch.baseUrl === undefined && providerChanged) { + next.baseUrl = defaultImageBaseUrl(provider); + } + if (apiKeyPatch !== undefined) { + const trimmed = apiKeyPatch.trim(); + if (trimmed.length === 0) { + const { apiKey: _removed, ...rest } = next; + next = rest; + } else { + next.apiKey = buildSecretRef(trimmed); + } + } + const parsed = ImageGenerationSettingsSchema.parse(next); + const config = hydrateConfig({ + version: 3, + activeProvider: cfg.activeProvider, + activeModel: cfg.activeModel, + secrets: cfg.secrets, + providers: cfg.providers, + ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + imageGeneration: parsed, + }); + await writeConfig(config); + setCachedConfig(config); + return imageSettingsToView(parsed); +} + +export function registerImageGenerationSettingsIpc(): void { + ipcMain.handle('image-generation:v1:get', async (): Promise => { + const cfg = getCachedConfig(); + return imageSettingsToView(cfg?.imageGeneration); + }); + + ipcMain.handle( + 'image-generation:v1:update', + async (_e, raw: unknown): Promise => { + return updateImageGenerationSettings(parseUpdate(raw)); + }, + ); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d6dbe8c4..14304390 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,12 +7,14 @@ import { type CoreLogger, DESIGN_SKILLS, FRAME_TEMPLATES, + type GenerateImageAssetRequest, + type GenerateImageAssetResult, applyComment, generate, generateTitle, generateViaAgent, } from '@open-codesign/core'; -import { detectProviderFromKey } from '@open-codesign/providers'; +import { detectProviderFromKey, generateImage } from '@open-codesign/providers'; import { ApplyCommentPayload, BRAND, @@ -48,6 +50,11 @@ import { cancelGenerationRequest, extractGenerationTimeoutError, } from './generation-ipc'; +import { + registerImageGenerationSettingsIpc, + resolveImageGenerationConfig, + toGenerateImageOptions, +} from './image-generation-settings'; import { maybeAbortIfRunningFromDmg } from './install-check'; import { registerLocaleIpc } from './locale-ipc'; import { getLogPath, getLogger, initLogger } from './logger'; @@ -207,6 +214,49 @@ function resolveApiKeyForActive(providerId: string, allowKeyless: boolean): Prom }); } +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function resolveLocalAssetRefs(source: string, files: Map): string { + let resolved = source; + for (const [path, content] of files.entries()) { + if (!path.startsWith('assets/') || !content.startsWith('data:')) continue; + resolved = resolved.replace(new RegExp(escapeRegExp(path), 'g'), content); + } + return resolved; +} + +function extensionFromMimeType(mimeType: string): string { + if (mimeType === 'image/jpeg') return 'jpg'; + if (mimeType === 'image/webp') return 'webp'; + return 'png'; +} + +function sanitizeAssetStem(input: string | undefined, fallback: string): string { + const raw = input?.trim() || fallback; + const stem = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return stem.length > 0 ? stem : 'image-asset'; +} + +function allocateAssetPath( + files: Map, + request: GenerateImageAssetRequest, + mimeType: string, +): string { + const stem = sanitizeAssetStem(request.filenameHint, request.purpose); + const ext = extensionFromMimeType(mimeType); + let path = `assets/${stem}.${ext}`; + for (let i = 2; files.has(path); i++) { + path = `assets/${stem}-${i}.${ext}`; + } + return path; +} + function registerIpcHandlers(db: Database | null): void { const logIpc = getLogger('main:ipc'); @@ -324,6 +374,27 @@ function registerIpcHandlers(db: Database | null): void { if (previousHtml && previousHtml.trim().length > 0) { fsMap.set('index.html', previousHtml); } + const cfg = getCachedConfig(); + const imageConfig = cfg ? resolveImageGenerationConfig(cfg) : null; + const generateImageAsset = imageConfig + ? async ( + request: GenerateImageAssetRequest, + signal?: AbortSignal, + ): Promise => { + const image = await generateImage( + toGenerateImageOptions(imageConfig, request.prompt, signal), + ); + const path = allocateAssetPath(fsMap, request, image.mimeType); + return { + path, + dataUrl: image.dataUrl, + mimeType: image.mimeType, + model: image.model, + provider: image.provider, + ...(image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {}), + }; + } + : undefined; // Seed the virtual fs with optional device-frame starter templates. The // agent decides whether to view/use them based on the brief — there is // no keyword detection here. See packages/core/src/frames/README.md. @@ -345,6 +416,7 @@ function registerIpcHandlers(db: Database | null): void { create(path: string, content: string) { fsMap.set(path, content); emitFsUpdated(path, content); + emitIndexIfAssetChanged(path); return { path }; }, strReplace(path: string, oldStr: string, newStr: string) { @@ -358,6 +430,7 @@ function registerIpcHandlers(db: Database | null): void { const next = current.slice(0, idx) + newStr + current.slice(idx + oldStr.length); fsMap.set(path, next); emitFsUpdated(path, next); + emitIndexIfAssetChanged(path); return { path }; }, insert(path: string, line: number, text: string) { @@ -368,6 +441,7 @@ function registerIpcHandlers(db: Database | null): void { const next = lines.join('\n'); fsMap.set(path, next); emitFsUpdated(path, next); + emitIndexIfAssetChanged(path); return { path }; }, listDir(dir: string) { @@ -390,7 +464,14 @@ function registerIpcHandlers(db: Database | null): void { // (no preview pane to update). function emitFsUpdated(path: string, content: string): void { if (designId === null) return; - sendEvent({ ...baseCtx, type: 'fs_updated', path, content }); + const resolved = path === 'index.html' ? resolveLocalAssetRefs(content, fsMap) : content; + sendEvent({ ...baseCtx, type: 'fs_updated', path, content: resolved }); + } + + function emitIndexIfAssetChanged(path: string): void { + if (!path.startsWith('assets/')) return; + const index = fsMap.get('index.html'); + if (index !== undefined) emitFsUpdated('index.html', index); } // Per-turn counters so we can emit a single summary line at turn_end @@ -401,6 +482,7 @@ function registerIpcHandlers(db: Database | null): void { return generateViaAgent(input, { fs, runtimeVerify, + ...(generateImageAsset !== undefined ? { generateImageAsset } : {}), onEvent: (event: AgentEvent) => { // High-signal only. Skip per-token deltas and inner message_* // markers. Emit a concise summary at turn_end. @@ -492,7 +574,13 @@ function registerIpcHandlers(db: Database | null): void { return; } }, - }); + }).then((result) => ({ + ...result, + artifacts: result.artifacts.map((artifact) => ({ + ...artifact, + content: resolveLocalAssetRefs(artifact.content, fsMap), + })), + })); }; /** In-flight requests: generationId → AbortController */ @@ -1170,6 +1258,7 @@ void app.whenReady().then(async () => { registerOnboardingIpc(); registerCodexOAuthIpc(); registerPreferencesIpc(); + registerImageGenerationSettingsIpc(); registerExporterIpc(() => mainWindow); registerDiagnosticsIpc(diagnosticsDb); setupAutoUpdater(); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 1e95384d..45334598 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -62,6 +62,10 @@ import { type CoreLogger, NOOP_LOGGER } from './logger.js'; import { composeSystemPrompt } from './prompts/index.js'; import { makeDeclareTweakSchemaTool } from './tools/declare-tweak-schema.js'; import { type DoneRuntimeVerifier, makeDoneTool } from './tools/done.js'; +import { + type GenerateImageAssetFn, + makeGenerateImageAssetTool, +} from './tools/generate-image-asset.js'; import { makeListFilesTool } from './tools/list-files.js'; import { makeReadDesignSystemTool } from './tools/read-design-system.js'; import { makeReadUrlTool } from './tools/read-url.js'; @@ -601,6 +605,21 @@ const AGENTIC_TOOL_GUIDANCE = [ 'italic serif numbers visually collide and feel low-quality.', ].join('\n'); +const IMAGE_ASSET_TOOL_GUIDANCE = [ + '## Bitmap asset generation', + '', + 'You also have `generate_image_asset` for high-quality bitmap assets.', + 'Use it when the brief asks for, or clearly benefits from, a generated hero image, product image, poster illustration, painterly/photo background, marketing visual, or brand/logo-like bitmap.', + '', + 'Do NOT call it for simple icons, charts, decorative gradients, UI controls, geometric patterns, or anything better made with HTML/CSS/SVG.', + '', + 'When you use it:', + '- Call it with a production-ready visual prompt: subject, medium/style, composition, lighting, palette, and any text constraints.', + '- Use the returned local `assets/...` path in `index.html`, e.g. `...` or `backgroundImage: "url(\'assets/hero.png\')"`. The host resolves those local paths for preview and persistence.', + '- Keep alt text concise and meaningful.', + '- Prefer one strong asset over many weak assets unless the user explicitly asks for a set.', +].join('\n'); + // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -641,6 +660,12 @@ export interface GenerateViaAgentDeps { * to static lint only. */ runtimeVerify?: DoneRuntimeVerifier | undefined; + /** + * Optional bitmap asset generator. When provided, the default toolset adds + * `generate_image_asset`; the main design agent decides when a hero/product/ + * poster/background asset is worth generating. + */ + generateImageAsset?: GenerateImageAssetFn | undefined; } /** @@ -730,10 +755,21 @@ export async function generateViaAgent( makeDoneTool(deps.fs, deps.runtimeVerify) as unknown as AgentTool, ); } + if (deps.generateImageAsset) { + defaultTools.push( + makeGenerateImageAssetTool(deps.generateImageAsset, deps.fs) as unknown as AgentTool< + TSchema, + unknown + >, + ); + } const tools = deps.tools ?? defaultTools; const encourageToolUse = deps.encourageToolUse ?? tools.length > 0; + const activeGuidance = deps.generateImageAsset + ? `${AGENTIC_TOOL_GUIDANCE}\n\n${IMAGE_ASSET_TOOL_GUIDANCE}` + : AGENTIC_TOOL_GUIDANCE; const augmentedSystemPrompt = encourageToolUse - ? `${systemPrompt}\n\n${AGENTIC_TOOL_GUIDANCE}` + ? `${systemPrompt}\n\n${activeGuidance}` : systemPrompt; // Seed the transcript with prior history (already in ChatMessage shape). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 329336d5..99649f8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,13 @@ export { export { makeSetTodosTool, type SetTodosDetails } from './tools/set-todos.js'; export { makeListFilesTool, type ListFilesDetails } from './tools/list-files.js'; export { makeReadUrlTool, type ReadUrlDetails } from './tools/read-url.js'; +export { + makeGenerateImageAssetTool, + type GenerateImageAssetDetails, + type GenerateImageAssetFn, + type GenerateImageAssetRequest, + type GenerateImageAssetResult, +} from './tools/generate-image-asset.js'; export { makeReadDesignSystemTool, type ReadDesignSystemDetails, diff --git a/packages/core/src/tools/generate-image-asset.test.ts b/packages/core/src/tools/generate-image-asset.test.ts new file mode 100644 index 00000000..564af291 --- /dev/null +++ b/packages/core/src/tools/generate-image-asset.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { makeGenerateImageAssetTool } from './generate-image-asset'; +import type { TextEditorFsCallbacks } from './text-editor'; + +function memoryFs(): TextEditorFsCallbacks & { files: Map } { + const files = new Map(); + return { + files, + view(path) { + const content = files.get(path); + if (content === undefined) return null; + return { content, numLines: content.split('\n').length }; + }, + create(path, content) { + files.set(path, content); + return { path }; + }, + strReplace(path, oldStr, newStr) { + const current = files.get(path); + if (current === undefined) throw new Error('missing'); + const next = current.replace(oldStr, newStr); + files.set(path, next); + return { path }; + }, + insert(path, line, text) { + const lines = (files.get(path) ?? '').split('\n'); + lines.splice(line, 0, text); + files.set(path, lines.join('\n')); + return { path }; + }, + listDir() { + return [...files.keys()]; + }, + }; +} + +describe('generate_image_asset tool', () => { + it('stores generated assets in the virtual filesystem and returns a local path', async () => { + const fs = memoryFs(); + const generate = vi.fn(async () => ({ + path: 'assets/hero.png', + dataUrl: 'data:image/png;base64,aW1n', + mimeType: 'image/png', + model: 'gpt-image-2', + provider: 'openai', + })); + const tool = makeGenerateImageAssetTool(generate, fs); + + const result = await tool.execute('tool-1', { + prompt: 'A cinematic ink-wash hero background', + purpose: 'hero', + filenameHint: 'hero', + aspectRatio: '16:9', + alt: 'Ink-wash mountains', + }); + + expect(generate).toHaveBeenCalledWith( + { + prompt: 'A cinematic ink-wash hero background', + purpose: 'hero', + filenameHint: 'hero', + aspectRatio: '16:9', + alt: 'Ink-wash mountains', + }, + undefined, + ); + expect(fs.files.get('assets/hero.png')).toBe('data:image/png;base64,aW1n'); + expect(result.details.path).toBe('assets/hero.png'); + const content = result.content[0]; + expect(content?.type).toBe('text'); + expect(content?.type === 'text' ? content.text : '').toContain('src="assets/hero.png"'); + }); +}); diff --git a/packages/core/src/tools/generate-image-asset.ts b/packages/core/src/tools/generate-image-asset.ts new file mode 100644 index 00000000..c8d2f1f4 --- /dev/null +++ b/packages/core/src/tools/generate-image-asset.ts @@ -0,0 +1,126 @@ +import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'; +import { Type } from '@sinclair/typebox'; +import type { TextEditorFsCallbacks } from './text-editor'; + +const GenerateImageAssetParams = Type.Object({ + prompt: Type.String(), + purpose: Type.Union([ + Type.Literal('hero'), + Type.Literal('product'), + Type.Literal('poster'), + Type.Literal('background'), + Type.Literal('illustration'), + Type.Literal('logo'), + Type.Literal('other'), + ]), + filenameHint: Type.Optional(Type.String()), + aspectRatio: Type.Optional( + Type.Union([ + Type.Literal('1:1'), + Type.Literal('16:9'), + Type.Literal('9:16'), + Type.Literal('4:3'), + Type.Literal('3:4'), + ]), + ), + alt: Type.Optional(Type.String()), +}); + +export type ImageAssetPurpose = + | 'hero' + | 'product' + | 'poster' + | 'background' + | 'illustration' + | 'logo' + | 'other'; + +export interface GenerateImageAssetRequest { + prompt: string; + purpose: ImageAssetPurpose; + filenameHint?: string | undefined; + aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4' | undefined; + alt?: string | undefined; +} + +export interface GenerateImageAssetResult { + path: string; + dataUrl: string; + mimeType: string; + model: string; + provider: string; + revisedPrompt?: string | undefined; +} + +export interface GenerateImageAssetDetails { + path: string; + purpose: ImageAssetPurpose; + mimeType: string; + model: string; + provider: string; + alt: string; + revisedPrompt?: string | undefined; +} + +export type GenerateImageAssetFn = ( + request: GenerateImageAssetRequest, + signal?: AbortSignal, +) => Promise; + +export function makeGenerateImageAssetTool( + generateAsset: GenerateImageAssetFn, + fs: TextEditorFsCallbacks | undefined, +): AgentTool { + return { + name: 'generate_image_asset', + label: 'Generate image asset', + description: + 'Generate one high-quality bitmap asset for the design, such as a hero image, ' + + 'product render, poster illustration, textured background, or marketing visual. ' + + 'Use this only when a generated bitmap would materially improve the artifact. ' + + 'Do not use it for simple icons, charts, gradients, or UI chrome that can be ' + + 'drawn with HTML/CSS/SVG. The tool returns a local assets/... path to reference.', + parameters: GenerateImageAssetParams, + async execute( + _toolCallId, + params, + signal, + ): Promise> { + const prompt = params.prompt.trim(); + if (prompt.length === 0) throw new Error('Image asset prompt cannot be empty'); + const request: GenerateImageAssetRequest = { + prompt, + purpose: params.purpose, + ...(params.filenameHint !== undefined ? { filenameHint: params.filenameHint } : {}), + ...(params.aspectRatio !== undefined ? { aspectRatio: params.aspectRatio } : {}), + ...(params.alt !== undefined ? { alt: params.alt } : {}), + }; + const asset = await generateAsset(request, signal); + if (fs !== undefined) { + fs.create(asset.path, asset.dataUrl); + } + const alt = params.alt?.trim() || `${params.purpose} image`; + const revised = asset.revisedPrompt ? `\nRevised prompt: ${asset.revisedPrompt}` : ''; + return { + content: [ + { + type: 'text', + text: + `Generated local bitmap asset at ${asset.path} (${asset.mimeType}). ` + + `Reference this path in index.html, for example src="${asset.path}" ` + + `or backgroundImage: "url('${asset.path}')". Alt text: ${alt}.${revised}`, + }, + ], + details: { + path: asset.path, + purpose: params.purpose, + mimeType: asset.mimeType, + model: asset.model, + provider: asset.provider, + alt, + ...(asset.revisedPrompt !== undefined ? { revisedPrompt: asset.revisedPrompt } : {}), + }, + }; + }, + }; +} diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index fd1f893b..c05b0cd2 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -38,6 +38,29 @@ describe('config v3 schema', () => { }; expect(() => ConfigV3Schema.parse(bad)).toThrow(); }); + + it('parses schema-versioned image generation settings', () => { + const parsed = ConfigV3Schema.parse({ + version: 3, + activeProvider: 'openai', + activeModel: 'gpt-4o', + secrets: {}, + providers: { + openai: BUILTIN_PROVIDERS.openai, + }, + imageGeneration: { + schemaVersion: 1, + enabled: true, + provider: 'openrouter', + credentialMode: 'custom', + model: 'openai/gpt-5.4-image-2', + apiKey: { ciphertext: 'plain:sk-test', mask: 'sk-***test' }, + }, + }); + expect(parsed.imageGeneration?.enabled).toBe(true); + expect(parsed.imageGeneration?.quality).toBe('high'); + expect(parsed.imageGeneration?.size).toBe('1536x1024'); + }); }); describe('migrateLegacyToV3', () => { @@ -175,4 +198,25 @@ describe('hydrateConfig / toPersistedV3', () => { expect(persisted).not.toHaveProperty('baseUrls'); expect(persisted.version).toBe(3); }); + + it('preserves image generation settings when stripping derived fields', () => { + const hydrated = hydrateConfig({ + version: 3, + activeProvider: 'openai', + activeModel: 'gpt-4o', + secrets: {}, + providers: { openai: BUILTIN_PROVIDERS.openai }, + imageGeneration: { + schemaVersion: 1, + enabled: true, + provider: 'openai', + credentialMode: 'inherit', + model: 'gpt-image-2', + quality: 'high', + size: '1536x1024', + outputFormat: 'png', + }, + }); + expect(toPersistedV3(hydrated).imageGeneration?.model).toBe('gpt-image-2'); + }); }); diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index e5808ba9..c30a77bc 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -96,6 +96,37 @@ export type StoredDesignSystem = z.infer; export const ReasoningLevelSchema = z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']); export type ReasoningLevel = z.infer; +export const IMAGE_GENERATION_SCHEMA_VERSION = 1 as const; + +export const ImageGenerationProviderSchema = z.enum(['openai', 'openrouter']); +export type ImageGenerationProvider = z.infer; + +export const ImageGenerationCredentialModeSchema = z.enum(['inherit', 'custom']); +export type ImageGenerationCredentialMode = z.infer; + +export const ImageGenerationQualitySchema = z.enum(['auto', 'low', 'medium', 'high']); +export type ImageGenerationQuality = z.infer; + +export const ImageGenerationSizeSchema = z.enum(['auto', '1024x1024', '1536x1024', '1024x1536']); +export type ImageGenerationSize = z.infer; + +export const ImageGenerationOutputFormatSchema = z.enum(['png', 'jpeg', 'webp']); +export type ImageGenerationOutputFormat = z.infer; + +export const ImageGenerationSettingsSchema = z.object({ + schemaVersion: z.literal(IMAGE_GENERATION_SCHEMA_VERSION), + enabled: z.boolean().default(false), + provider: ImageGenerationProviderSchema.default('openai'), + credentialMode: ImageGenerationCredentialModeSchema.default('inherit'), + model: z.string().min(1).default('gpt-image-2'), + baseUrl: z.string().url().optional(), + apiKey: SecretRef.optional(), + quality: ImageGenerationQualitySchema.default('high'), + size: ImageGenerationSizeSchema.default('1536x1024'), + outputFormat: ImageGenerationOutputFormatSchema.default('png'), +}); +export type ImageGenerationSettings = z.infer; + export const ProviderEntrySchema = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -186,6 +217,7 @@ export const ConfigV3Schema = z.object({ secrets: z.record(z.string(), SecretRef).default({}), providers: z.record(z.string(), ProviderEntrySchema).default({}), designSystem: StoredDesignSystem.optional(), + imageGeneration: ImageGenerationSettingsSchema.optional(), }); export type ConfigV3 = z.infer; @@ -299,6 +331,7 @@ export function toPersistedV3(cfg: Config | ConfigV3): ConfigV3 { secrets: cfg.secrets, providers: cfg.providers, ...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}), + ...(cfg.imageGeneration !== undefined ? { imageGeneration: cfg.imageGeneration } : {}), }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8d6a394b..920f5ddc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -237,7 +237,14 @@ export { CHATGPT_CODEX_PROVIDER_ID, ConfigSchema, ConfigV3Schema, + IMAGE_GENERATION_SCHEMA_VERSION, PROVIDER_SHORTLIST, + ImageGenerationCredentialModeSchema, + ImageGenerationOutputFormatSchema, + ImageGenerationProviderSchema, + ImageGenerationQualitySchema, + ImageGenerationSettingsSchema, + ImageGenerationSizeSchema, ProviderEntrySchema, ReasoningLevelSchema, SUPPORTED_ONBOARDING_PROVIDERS, @@ -255,6 +262,12 @@ export { export type { Config, ConfigV3, + ImageGenerationCredentialMode, + ImageGenerationOutputFormat, + ImageGenerationProvider, + ImageGenerationQuality, + ImageGenerationSettings, + ImageGenerationSize, OnboardingState, ProviderEntry, ProviderShortlist, From a19b76a1aad1a8f708cc37efb7f09c4d41c33f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Wed, 22 Apr 2026 21:51:03 +0800 Subject: [PATCH 3/6] feat(desktop): add image generation settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 杨峻骁 --- apps/desktop/src/preload/index.ts | 11 + .../src/renderer/src/components/Settings.tsx | 243 +++++++++++++++++- 2 files changed, 253 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ee91b8e9..3ca00eff 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -33,10 +33,12 @@ import type { ModelsListResponse, TestEndpointResponse, } from '../main/connection-ipc'; +import type { ImageGenerationSettingsView } from '../main/image-generation-settings'; export type { ConnectionTestError, ConnectionTestResult, ModelsListResponse, TestEndpointResponse }; export type { ClaudeCodeUserType, ExternalConfigsDetection }; export type { CodexOAuthStatus }; +export type { ImageGenerationSettingsView }; export interface ValidateKeyResult { ok: true; @@ -338,6 +340,15 @@ const api = { update: (patch: Partial) => ipcRenderer.invoke('preferences:v1:update', patch) as Promise, }, + imageGeneration: { + get: () => + ipcRenderer.invoke('image-generation:v1:get') as Promise, + update: (patch: Partial & { apiKey?: string }) => + ipcRenderer.invoke( + 'image-generation:v1:update', + patch, + ) as Promise, + }, codexOAuth: { status: () => ipcRenderer.invoke('codex-oauth:v1:status') as Promise, login: () => ipcRenderer.invoke('codex-oauth:v1:login') as Promise, diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 9bc3f1c7..c15f22d9 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -14,6 +14,7 @@ import { Cpu, FolderOpen, Globe, + Image as ImageIcon, Loader2, MoreHorizontal, Palette, @@ -24,7 +25,13 @@ import { Trash2, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index'; +import type { + AppPaths, + ImageGenerationSettingsView, + Preferences, + ProviderRow, + StorageKind, +} from '../../../preload/index'; import { recordAction } from '../lib/action-timeline'; import { useCodesignStore } from '../store'; import { AddCustomProviderModal } from './AddCustomProviderModal'; @@ -856,6 +863,239 @@ function WarningsList({ warnings }: { warnings: string[] }) { ); } +function defaultImageModelFor(provider: ImageGenerationSettingsView['provider']): string { + return provider === 'openrouter' ? 'openai/gpt-5.4-image-2' : 'gpt-image-2'; +} + +function defaultImageBaseUrlFor(provider: ImageGenerationSettingsView['provider']): string { + return provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1'; +} + +function ImageGenerationPanel() { + const t = useT(); + const pushToast = useCodesignStore((s) => s.pushToast); + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + + useEffect(() => { + if (!window.codesign?.imageGeneration) return; + void window.codesign.imageGeneration + .get() + .then((next) => { + setSettings(next); + setModel(next.model); + setBaseUrl(next.baseUrl); + }) + .catch((err) => { + pushToast({ + variant: 'error', + title: t('settings.imageGen.toast.loadFailed', { + defaultValue: 'Image generation settings failed to load', + }), + description: err instanceof Error ? err.message : t('settings.common.unknownError'), + }); + }); + }, [pushToast, t]); + + async function save(patch: Partial & { apiKey?: string }) { + if (!window.codesign?.imageGeneration) return; + setSaving(true); + try { + const next = await window.codesign.imageGeneration.update(patch); + setSettings(next); + setModel(next.model); + setBaseUrl(next.baseUrl); + setApiKey(''); + pushToast({ + variant: 'success', + title: t('settings.imageGen.toast.saved', { defaultValue: 'Image generation saved' }), + }); + } catch (err) { + pushToast({ + variant: 'error', + title: t('settings.imageGen.toast.saveFailed', { + defaultValue: 'Image generation settings failed to save', + }), + description: err instanceof Error ? err.message : t('settings.common.unknownError'), + }); + } finally { + setSaving(false); + } + } + + if (settings === null) { + return ( +
+ {t('settings.common.loading')} +
+ ); + } + + return ( +
+
+
+ +
+ + {t('settings.imageGen.title', { defaultValue: 'Image generation assist' })} + +

+ {t('settings.imageGen.hint', { + defaultValue: + 'Let the agent call GPT Image for hero, product, poster, logo, and background bitmap assets.', + })} +

+
+
+ void save({ enabled: e.target.checked })} + className="h-4 w-4 accent-[var(--color-accent)]" + aria-label={t('settings.imageGen.enabled', { + defaultValue: 'Enable image generation assist', + })} + /> +
+ +
+ + { + const provider = value as ImageGenerationSettingsView['provider']; + void save({ + provider, + model: defaultImageModelFor(provider), + baseUrl: defaultImageBaseUrlFor(provider), + }); + }} + /> + + + void save({ credentialMode })} + /> + +
+ + {settings.credentialMode === 'custom' ? ( +
+ setApiKey(e.target.value)} + placeholder={ + settings.maskedKey + ? t('settings.imageGen.keyPlaceholder', { + defaultValue: 'Leave empty to keep {{mask}}', + mask: settings.maskedKey, + }) + : t('settings.imageGen.newKeyPlaceholder', { + defaultValue: 'Paste API key', + }) + } + className="min-w-0 flex-1 h-8 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50" + /> + +
+ ) : null} + +
+ + + +
+ +
+ + + void save({ quality: quality as ImageGenerationSettingsView['quality'] }) + } + /> + + + void save({ size: size as ImageGenerationSettingsView['size'] })} + /> + +
+
+ ); +} + function ModelsTab() { const t = useT(); const config = useCodesignStore((s) => s.config); @@ -1313,6 +1553,7 @@ function ModelsTab() {
+ {externalConfigs !== null && (externalConfigs.codex !== undefined || externalConfigs.claudeCode !== undefined || From 9c01ba18fa6b8e7760cebe1e419effaef8f0f60e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Thu, 23 Apr 2026 02:32:48 +0800 Subject: [PATCH 4/6] test: make platform-sensitive tests pass on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX-specific assertions were baked into six tests, which made the full `pnpm test` suite fail on Windows even though the production code is already cross-platform: - token-store: `0o600` mode bits aren't enforceable on NTFS (reports 0o666); guard the assertion with `process.platform !== 'win32'`. - skills/loader: `new URL(...).pathname` yields `/D:/...` on Windows, so `readdir` sees zero files; use `fileURLToPath()` instead. - opencode-config, locale-ipc, preferences-ipc: replace hard-coded forward-slash path strings with `path.join()`-built expectations that mirror whatever separator the host OS uses. - boot-fallback: `/dev/null/...` is only guaranteed-unwritable on POSIX; build a parent-is-a-regular-file path instead so `mkdirSync` throws ENOTDIR on both platforms. All 10 workspace packages' tests now pass on Windows. Made-with: Cursor Signed-off-by: 杨峻骁 --- apps/desktop/src/main/boot-fallback.test.ts | 22 ++++++++++++------ .../src/main/imports/opencode-config.test.ts | 23 ++++++++++++------- apps/desktop/src/main/locale-ipc.test.ts | 7 ++++-- apps/desktop/src/main/preferences-ipc.test.ts | 7 ++++-- packages/core/src/skills/loader.test.ts | 3 ++- .../providers/src/codex/token-store.test.ts | 8 +++++-- 6 files changed, 48 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/boot-fallback.test.ts b/apps/desktop/src/main/boot-fallback.test.ts index eeaf8633..00508822 100644 --- a/apps/desktop/src/main/boot-fallback.test.ts +++ b/apps/desktop/src/main/boot-fallback.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; @@ -54,12 +54,20 @@ describe('writeBootErrorSync', () => { }); it('falls back to the OS tmpdir when the primary path is unwritable', () => { - // Give a path we cannot create (under /dev/null). mkdirSync will throw, - // and writeBootErrorSync must catch and redirect to tmpdir. - const bogus = '/dev/null/does-not-exist/logs'; - const out = writeBootErrorSync(mkCtx({ logsDir: bogus })); - expect(out).toBe(join(tmpdir(), 'boot-errors.log')); - expect(existsSync(out)).toBe(true); + // Build a path whose parent is a regular file — mkdirSync then throws + // ENOTDIR on both POSIX and Windows, exercising the tmpdir fallback in a + // platform-agnostic way. + const scratchDir = mkdtempSync(join(tmpdir(), 'boot-fallback-bad-')); + const blocker = join(scratchDir, 'not-a-dir'); + writeFileSync(blocker, 'blocker'); + try { + const bogus = join(blocker, 'logs'); + const out = writeBootErrorSync(mkCtx({ logsDir: bogus })); + expect(out).toBe(join(tmpdir(), 'boot-errors.log')); + expect(existsSync(out)).toBe(true); + } finally { + rmSync(scratchDir, { recursive: true, force: true }); + } }); }); diff --git a/apps/desktop/src/main/imports/opencode-config.test.ts b/apps/desktop/src/main/imports/opencode-config.test.ts index 9a05dc4d..4a319fa2 100644 --- a/apps/desktop/src/main/imports/opencode-config.test.ts +++ b/apps/desktop/src/main/imports/opencode-config.test.ts @@ -28,22 +28,29 @@ async function writeConfig(home: string, filename: string, body: string): Promis } describe('opencodeAuthPath', () => { + // Paths are computed with native path.join so the test must mirror the host + // separator (Windows uses \, POSIX uses /) — OpenCode itself uses the same + // native join on each platform. it('defaults to ~/.local/share/opencode/auth.json on all platforms', () => { - const home = '/home/alice'; - expect(opencodeAuthPath(home, {})).toBe('/home/alice/.local/share/opencode/auth.json'); + const home = join('/home', 'alice'); + expect(opencodeAuthPath(home, {})).toBe(join(home, '.local', 'share', 'opencode', 'auth.json')); }); it('honors XDG_DATA_HOME when set', () => { - const path = opencodeAuthPath('/home/alice', { XDG_DATA_HOME: '/custom/data' }); - expect(path).toBe('/custom/data/opencode/auth.json'); + const home = join('/home', 'alice'); + const xdg = join('/custom', 'data'); + const path = opencodeAuthPath(home, { XDG_DATA_HOME: xdg }); + expect(path).toBe(join(xdg, 'opencode', 'auth.json')); }); it('lists jsonc/json/config.json candidates for config', () => { - const paths = opencodeConfigCandidatePaths('/home/alice', {}); + const home = join('/home', 'alice'); + const paths = opencodeConfigCandidatePaths(home, {}); + const dir = join(home, '.config', 'opencode'); expect(paths).toEqual([ - '/home/alice/.config/opencode/opencode.jsonc', - '/home/alice/.config/opencode/opencode.json', - '/home/alice/.config/opencode/config.json', + join(dir, 'opencode.jsonc'), + join(dir, 'opencode.json'), + join(dir, 'config.json'), ]); }); }); diff --git a/apps/desktop/src/main/locale-ipc.test.ts b/apps/desktop/src/main/locale-ipc.test.ts index c7e605eb..9a7533fa 100644 --- a/apps/desktop/src/main/locale-ipc.test.ts +++ b/apps/desktop/src/main/locale-ipc.test.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; vi.mock('./electron-runtime', () => ({ @@ -24,7 +26,8 @@ import { registerLocaleIpc } from './locale-ipc'; describe('locale-ipc XDG_CONFIG_HOME', () => { it('writes locale.json under XDG_CONFIG_HOME when set', async () => { const prev = process.env['XDG_CONFIG_HOME']; - process.env['XDG_CONFIG_HOME'] = '/tmp/xdg-locale-test'; + const xdg = join(tmpdir(), 'xdg-locale-test'); + process.env['XDG_CONFIG_HOME'] = xdg; try { writeFileMock.mockClear(); const handlers = new Map unknown>(); @@ -38,7 +41,7 @@ describe('locale-ipc XDG_CONFIG_HOME', () => { await setHandler({}, 'zh-CN'); expect(writeFileMock).toHaveBeenCalled(); const firstCall = writeFileMock.mock.calls[0]; - expect(firstCall?.[0]).toBe('/tmp/xdg-locale-test/open-codesign/locale.json'); + expect(firstCall?.[0]).toBe(join(xdg, 'open-codesign', 'locale.json')); } finally { if (prev === undefined) process.env['XDG_CONFIG_HOME'] = undefined; else process.env['XDG_CONFIG_HOME'] = prev; diff --git a/apps/desktop/src/main/preferences-ipc.test.ts b/apps/desktop/src/main/preferences-ipc.test.ts index 8f6c88af..f090627a 100644 --- a/apps/desktop/src/main/preferences-ipc.test.ts +++ b/apps/desktop/src/main/preferences-ipc.test.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { CodesignError } from '@open-codesign/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -57,13 +59,14 @@ describe('readPersisted()', () => { it('honors XDG_CONFIG_HOME when computing the persisted file path', async () => { const prev = process.env['XDG_CONFIG_HOME']; - process.env['XDG_CONFIG_HOME'] = '/tmp/xdg-test-home'; + const xdg = join(tmpdir(), 'xdg-test-home'); + process.env['XDG_CONFIG_HOME'] = xdg; const notFound = Object.assign(new Error('no such file'), { code: 'ENOENT' }); readFileMock.mockRejectedValueOnce(notFound); try { await readPersisted(); expect(readFileMock).toHaveBeenLastCalledWith( - '/tmp/xdg-test-home/open-codesign/preferences.json', + join(xdg, 'open-codesign', 'preferences.json'), 'utf8', ); } finally { diff --git a/packages/core/src/skills/loader.test.ts b/packages/core/src/skills/loader.test.ts index 71abde52..8bd07e69 100644 --- a/packages/core/src/skills/loader.test.ts +++ b/packages/core/src/skills/loader.test.ts @@ -1,6 +1,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { CodesignError } from '@open-codesign/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { loadAllSkills, loadSkillsFromDir } from './loader.js'; @@ -55,7 +56,7 @@ Full skill body here. describe('loadSkillsFromDir()', () => { it('loads 4 builtin skills from the real builtin directory', async () => { - const builtinDir = new URL('./builtin', import.meta.url).pathname; + const builtinDir = fileURLToPath(new URL('./builtin', import.meta.url)); const skills = await loadSkillsFromDir(builtinDir, 'builtin'); expect(skills.length).toBe(4); const ids = skills.map((s) => s.id).sort(); diff --git a/packages/providers/src/codex/token-store.test.ts b/packages/providers/src/codex/token-store.test.ts index 4586b3a8..ff94fe58 100644 --- a/packages/providers/src/codex/token-store.test.ts +++ b/packages/providers/src/codex/token-store.test.ts @@ -71,8 +71,12 @@ describe('CodexTokenStore', () => { const loaded = await store2.read(); expect(loaded).toEqual(auth); - const s = await stat(filePath); - expect(s.mode & 0o777).toBe(0o600); + // POSIX mode bits aren't enforceable on Windows NTFS (always reports 0o666), + // so only assert the 0o600 bit pattern on platforms that honor it. + if (process.platform !== 'win32') { + const s = await stat(filePath); + expect(s.mode & 0o777).toBe(0o600); + } }); it('write auto-creates parent directory', async () => { From 02f85a41c11bb81dd7aafa2af1ccc9e50351a929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Thu, 23 Apr 2026 02:33:59 +0800 Subject: [PATCH 5/6] fix(image-gen): harden image-asset pipeline and polish settings UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor Signed-off-by: 杨峻骁 --- .../main/image-generation-settings.test.ts | 115 ++++++++++++++ .../src/main/image-generation-settings.ts | 75 ++++++++- apps/desktop/src/main/index.ts | 58 +++++-- .../src/renderer/src/components/Settings.tsx | 142 +++++++++++------- packages/core/src/agent.test.ts | 25 +++ packages/core/src/agent.ts | 28 +++- .../src/tools/generate-image-asset.test.ts | 86 ++++++++++- .../core/src/tools/generate-image-asset.ts | 85 ++++++++++- packages/i18n/src/locales/en.json | 28 ++++ packages/i18n/src/locales/zh-CN.json | 28 ++++ 10 files changed, 583 insertions(+), 87 deletions(-) create mode 100644 apps/desktop/src/main/image-generation-settings.test.ts diff --git a/apps/desktop/src/main/image-generation-settings.test.ts b/apps/desktop/src/main/image-generation-settings.test.ts new file mode 100644 index 00000000..63b0e758 --- /dev/null +++ b/apps/desktop/src/main/image-generation-settings.test.ts @@ -0,0 +1,115 @@ +import { + type Config, + IMAGE_GENERATION_SCHEMA_VERSION, + type ProviderEntry, + hydrateConfig, +} from '@open-codesign/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + imageSettingsToView, + isGenerateImageAssetEnabled, + resolveImageGenerationConfig, +} from './image-generation-settings'; + +const getApiKeyForProviderMock = vi.fn<(provider: string) => string>(); + +vi.mock('./onboarding-ipc', () => ({ + getApiKeyForProvider: (provider: string) => getApiKeyForProviderMock(provider), + getCachedConfig: () => null, + setCachedConfig: () => {}, +})); + +vi.mock('./keychain', () => ({ + buildSecretRef: (value: string) => ({ ciphertext: value, mask: '***' }), + decryptSecret: (value: string) => value, +})); + +vi.mock('./logger', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +function makeConfig(imageEnabled: boolean): Config { + const providers: Record = { + openai: { + id: 'openai', + name: 'OpenAI', + builtin: true, + wire: 'openai-chat', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-5.4', + }, + }; + return hydrateConfig({ + version: 3, + activeProvider: 'openai', + activeModel: 'gpt-5.4', + providers, + secrets: {}, + imageGeneration: { + schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION, + enabled: imageEnabled, + provider: 'openai', + credentialMode: 'inherit', + model: 'gpt-image-2', + quality: 'high', + size: '1536x1024', + outputFormat: 'png', + }, + }); +} + +describe('image generation enablement', () => { + afterEach(() => { + getApiKeyForProviderMock.mockReset(); + }); + + it('disables generate_image_asset when image generation is turned off', () => { + const cfg = makeConfig(false); + expect(isGenerateImageAssetEnabled(cfg)).toBe(false); + expect(resolveImageGenerationConfig(cfg)).toBeNull(); + }); + + it('enables generate_image_asset when image generation is on and key is available', () => { + getApiKeyForProviderMock.mockReturnValue('sk-openai'); + const cfg = makeConfig(true); + expect(isGenerateImageAssetEnabled(cfg)).toBe(true); + expect(resolveImageGenerationConfig(cfg)).toMatchObject({ + provider: 'openai', + model: 'gpt-image-2', + apiKey: 'sk-openai', + }); + }); + + it('keeps generate_image_asset disabled when image generation is on but key is unavailable', () => { + getApiKeyForProviderMock.mockImplementation(() => { + throw new Error('missing key'); + }); + const cfg = makeConfig(true); + expect(isGenerateImageAssetEnabled(cfg)).toBe(false); + expect(resolveImageGenerationConfig(cfg)).toBeNull(); + }); + + it('reports inheritedKeyAvailable=false in the view when the provider key is missing', () => { + getApiKeyForProviderMock.mockImplementation(() => { + throw new Error('missing key'); + }); + const cfg = makeConfig(true); + const view = imageSettingsToView(cfg.imageGeneration); + expect(view.enabled).toBe(true); + expect(view.credentialMode).toBe('inherit'); + expect(view.inheritedKeyAvailable).toBe(false); + expect(view.hasCustomKey).toBe(false); + }); + + it('reports inheritedKeyAvailable=true in the view when the provider key exists', () => { + getApiKeyForProviderMock.mockReturnValue('sk-openai'); + const cfg = makeConfig(true); + const view = imageSettingsToView(cfg.imageGeneration); + expect(view.inheritedKeyAvailable).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/image-generation-settings.ts b/apps/desktop/src/main/image-generation-settings.ts index d28a9d54..1ca27b08 100644 --- a/apps/desktop/src/main/image-generation-settings.ts +++ b/apps/desktop/src/main/image-generation-settings.ts @@ -25,8 +25,11 @@ import { import { writeConfig } from './config'; import { ipcMain } from './electron-runtime'; import { buildSecretRef, decryptSecret } from './keychain'; +import { getLogger } from './logger'; import { getApiKeyForProvider, getCachedConfig, setCachedConfig } from './onboarding-ipc'; +const log = getLogger('image-generation'); + export interface ImageGenerationSettingsView { enabled: boolean; provider: ImageGenerationProvider; @@ -38,6 +41,7 @@ export interface ImageGenerationSettingsView { outputFormat: ImageGenerationOutputFormat; hasCustomKey: boolean; maskedKey: string | null; + inheritedKeyAvailable: boolean; } interface ImageGenerationUpdateInput { @@ -79,6 +83,13 @@ export function imageSettingsToView( settings: ImageGenerationSettings | undefined, ): ImageGenerationSettingsView { const parsed = ImageGenerationSettingsSchema.parse(settings ?? defaultImageGenerationSettings()); + let inheritedKeyAvailable = false; + try { + getApiKeyForProvider(parsed.provider); + inheritedKeyAvailable = true; + } catch { + inheritedKeyAvailable = false; + } return { enabled: parsed.enabled, provider: parsed.provider, @@ -90,26 +101,44 @@ export function imageSettingsToView( outputFormat: parsed.outputFormat, hasCustomKey: parsed.apiKey !== undefined, maskedKey: parsed.apiKey?.mask ?? null, + inheritedKeyAvailable, }; } export function resolveImageGenerationConfig(cfg: Config): ResolvedImageGenerationConfig | null { const settings = cfg.imageGeneration; - if (settings === undefined || settings.enabled !== true) return null; + if (settings === undefined) return null; + if (settings.enabled !== true) return null; const parsed = ImageGenerationSettingsSchema.parse(settings); let apiKey: string; if (parsed.credentialMode === 'custom') { - if (parsed.apiKey === undefined) return null; + if (parsed.apiKey === undefined) { + log.warn('resolve.skipped', { + reason: 'custom_key_missing', + provider: parsed.provider, + }); + return null; + } apiKey = decryptSecret(parsed.apiKey.ciphertext); } else { try { apiKey = getApiKeyForProvider(parsed.provider); - } catch { + } catch (err) { + log.warn('resolve.skipped', { + reason: 'inherit_key_missing', + provider: parsed.provider, + message: err instanceof Error ? err.message : String(err), + }); return null; } } const inheritedBaseUrl = parsed.credentialMode === 'inherit' ? cfg.providers[parsed.provider]?.baseUrl : undefined; + log.info('resolve.ok', { + provider: parsed.provider, + model: parsed.model, + credentialMode: parsed.credentialMode, + }); return { provider: parsed.provider, apiKey, @@ -121,11 +150,31 @@ export function resolveImageGenerationConfig(cfg: Config): ResolvedImageGenerati }; } +export function isGenerateImageAssetEnabled(cfg: Config): boolean { + return resolveImageGenerationConfig(cfg) !== null; +} + +export function imageGenerationKeyAvailable(cfg: Config | null): boolean { + if (cfg === null) return false; + const settings = cfg.imageGeneration; + if (settings === undefined) return false; + const parsed = ImageGenerationSettingsSchema.parse(settings); + if (parsed.credentialMode === 'custom') return parsed.apiKey !== undefined; + try { + getApiKeyForProvider(parsed.provider); + return true; + } catch { + return false; + } +} + export function toGenerateImageOptions( config: ResolvedImageGenerationConfig, prompt: string, signal?: AbortSignal, + aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4', ): GenerateImageOptions { + const size = resolveImageSize(config.size, aspectRatio); return { provider: config.provider, apiKey: config.apiKey, @@ -133,12 +182,30 @@ export function toGenerateImageOptions( baseUrl: config.baseUrl, prompt, quality: config.quality, - size: config.size, + size, outputFormat: config.outputFormat, + ...(aspectRatio !== undefined ? { aspectRatio } : {}), ...(signal !== undefined ? { signal } : {}), }; } +/** + * Map a caller-provided aspectRatio hint onto the OpenAI image API's discrete + * `size` enum. When the caller did not supply an aspect ratio we keep the + * user-configured default from Settings (`config.size`). The OpenRouter path + * also receives `aspect_ratio` directly, so this mapping only matters for + * backends that need a fixed bucketed size. + */ +export function resolveImageSize( + configured: ImageGenerationSize, + aspectRatio: '1:1' | '16:9' | '9:16' | '4:3' | '3:4' | undefined, +): ImageGenerationSize { + if (aspectRatio === undefined) return configured; + if (aspectRatio === '1:1') return '1024x1024'; + if (aspectRatio === '16:9' || aspectRatio === '4:3') return '1536x1024'; + return '1024x1536'; +} + function parseUpdate(raw: unknown): ImageGenerationUpdateInput { if (typeof raw !== 'object' || raw === null) { throw new CodesignError( diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 14304390..3211b238 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -376,23 +376,59 @@ function registerIpcHandlers(db: Database | null): void { } const cfg = getCachedConfig(); const imageConfig = cfg ? resolveImageGenerationConfig(cfg) : null; + const imageLog = getLogger('image-generation'); const generateImageAsset = imageConfig ? async ( request: GenerateImageAssetRequest, signal?: AbortSignal, ): Promise => { - const image = await generateImage( - toGenerateImageOptions(imageConfig, request.prompt, signal), + const started = Date.now(); + const options = toGenerateImageOptions( + imageConfig, + request.prompt, + signal, + request.aspectRatio, ); - const path = allocateAssetPath(fsMap, request, image.mimeType); - return { - path, - dataUrl: image.dataUrl, - mimeType: image.mimeType, - model: image.model, - provider: image.provider, - ...(image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {}), - }; + imageLog.info('provider.request', { + generationId: id, + provider: options.provider, + model: options.model, + size: options.size, + aspectRatio: request.aspectRatio ?? 'default', + purpose: request.purpose, + quality: options.quality, + outputFormat: options.outputFormat, + promptChars: options.prompt.length, + }); + try { + const image = await generateImage(options); + const path = allocateAssetPath(fsMap, request, image.mimeType); + imageLog.info('provider.ok', { + generationId: id, + provider: image.provider, + model: image.model, + path, + ms: Date.now() - started, + revised: image.revisedPrompt !== undefined, + }); + return { + path, + dataUrl: image.dataUrl, + mimeType: image.mimeType, + model: image.model, + provider: image.provider, + ...(image.revisedPrompt !== undefined ? { revisedPrompt: image.revisedPrompt } : {}), + }; + } catch (err) { + imageLog.warn('provider.fail', { + generationId: id, + provider: options.provider, + model: options.model, + ms: Date.now() - started, + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } } : undefined; // Seed the virtual fs with optional device-frame starter templates. The diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index c15f22d9..8d458f25 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -38,10 +38,11 @@ import { AddCustomProviderModal } from './AddCustomProviderModal'; import { ChatgptLoginCard } from './ChatgptLoginCard'; import { DiagnosticsPanel } from './settings/DiagnosticsPanel'; -type Tab = 'models' | 'appearance' | 'storage' | 'diagnostics' | 'advanced'; +type Tab = 'models' | 'images' | 'appearance' | 'storage' | 'diagnostics' | 'advanced'; const TABS: ReadonlyArray<{ id: Tab; icon: typeof Cpu }> = [ { id: 'models', icon: Cpu }, + { id: 'images', icon: ImageIcon }, { id: 'appearance', icon: Palette }, { id: 'storage', icon: FolderOpen }, { id: 'diagnostics', icon: AlertCircle }, @@ -934,37 +935,56 @@ function ImageGenerationPanel() { ); } + const keyAvailable = + settings.credentialMode === 'custom' ? settings.hasCustomKey : settings.inheritedKeyAvailable; + const status: 'ready' | 'needsKey' | 'disabled' = !settings.enabled + ? 'disabled' + : keyAvailable + ? 'ready' + : 'needsKey'; + + const statusStyles: Record = { + ready: + 'bg-[color-mix(in_oklab,var(--color-success,#16a34a)_14%,transparent)] text-[var(--color-success,#16a34a)] border-[color-mix(in_oklab,var(--color-success,#16a34a)_32%,transparent)]', + needsKey: + 'bg-[color-mix(in_oklab,var(--color-warning,#d97706)_14%,transparent)] text-[var(--color-warning,#d97706)] border-[color-mix(in_oklab,var(--color-warning,#d97706)_32%,transparent)]', + disabled: + 'bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] border-[var(--color-border-muted)]', + }; + return ( -
-
-
- +
+
+
+
- - {t('settings.imageGen.title', { defaultValue: 'Image generation assist' })} - +
+ {t('settings.imageGen.title')} + + {t(`settings.imageGen.status.${status}`)} + +

- {t('settings.imageGen.hint', { - defaultValue: - 'Let the agent call GPT Image for hero, product, poster, logo, and background bitmap assets.', - })} + {t('settings.imageGen.hint')}

- void save({ enabled: e.target.checked })} - className="h-4 w-4 accent-[var(--color-accent)]" - aria-label={t('settings.imageGen.enabled', { - defaultValue: 'Enable image generation assist', - })} - /> +
-
- +
+ - + void save({ credentialMode })} /> @@ -1010,13 +1024,8 @@ function ImageGenerationPanel() { onChange={(e) => setApiKey(e.target.value)} placeholder={ settings.maskedKey - ? t('settings.imageGen.keyPlaceholder', { - defaultValue: 'Leave empty to keep {{mask}}', - mask: settings.maskedKey, - }) - : t('settings.imageGen.newKeyPlaceholder', { - defaultValue: 'Paste API key', - }) + ? t('settings.imageGen.keyPlaceholder', { mask: settings.maskedKey }) + : t('settings.imageGen.newKeyPlaceholder') } className="min-w-0 flex-1 h-8 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50" /> @@ -1031,9 +1040,9 @@ function ImageGenerationPanel() {
) : null} -
+
-
-
- +
+ - +
+ +
+ +
+
+ ); +} + +function ImageGenerationTab() { + const t = useT(); + return ( +
+
+ {t('settings.imageGen.tabTitle')} +

+ {t('settings.imageGen.tabHint')} +

+
+
); } @@ -1553,7 +1585,6 @@ function ModelsTab() {
- {externalConfigs !== null && (externalConfigs.codex !== undefined || externalConfigs.claudeCode !== undefined || @@ -2437,6 +2468,7 @@ export function Settings() {
{tab === 'models' ? : null} + {tab === 'images' ? : null} {tab === 'appearance' ? : null} {tab === 'storage' ? : null} {tab === 'diagnostics' ? : null} diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 0235baa8..6e41bae8 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -518,6 +518,31 @@ describe('generateViaAgent() — Phase 1 pass-through', () => { expect(sys).toContain('str_replace_based_edit_tool'); expect(sys).toContain('Do NOT emit ``'); }); + + it('adds explicit bitmap trigger guidance when image asset tool is enabled', async () => { + scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; + await generateViaAgent( + { + prompt: 'design a landing page with a hand-painted background illustration', + history: [], + model: MODEL, + apiKey: 'sk-test', + }, + { + generateImageAsset: async () => ({ + path: 'assets/hero.png', + dataUrl: 'data:image/png;base64,aW1n', + mimeType: 'image/png', + model: 'gpt-image-2', + provider: 'openai', + }), + }, + ); + const sys = agentCalls[0]?.options.initialState?.systemPrompt as string; + expect(sys).toContain('MANDATORY asset inventory'); + expect(sys).toContain('One call per named asset'); + expect(sys).toContain("`purpose='logo'`"); + }); }); describe('generateViaAgent() — first-turn retry', () => { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 45334598..12ef140b 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -611,13 +611,29 @@ const IMAGE_ASSET_TOOL_GUIDANCE = [ 'You also have `generate_image_asset` for high-quality bitmap assets.', 'Use it when the brief asks for, or clearly benefits from, a generated hero image, product image, poster illustration, painterly/photo background, marketing visual, or brand/logo-like bitmap.', '', - 'Do NOT call it for simple icons, charts, decorative gradients, UI controls, geometric patterns, or anything better made with HTML/CSS/SVG.', + 'MANDATORY asset inventory (do this BEFORE any `str_replace_based_edit_tool` call that writes `index.html`):', + '1. Re-read the user brief and list every distinct visual asset it names or strongly implies: background / hero / logo / product / illustration / poster / mascot / texture / avatar, etc.', + '2. For each item in that list, decide exactly one of: `generate_image_asset` (bitmap), inline `` (pure geometric / flat brand-mark / icon), or pure CSS (gradients, patterns). Record the decision.', + '3. Emit ALL chosen `generate_image_asset` calls together in a single assistant turn — do NOT start writing or editing `index.html` until every required bitmap asset has been requested.', '', - 'When you use it:', - '- Call it with a production-ready visual prompt: subject, medium/style, composition, lighting, palette, and any text constraints.', + 'When the brief explicitly asks for a bitmap for a given slot (e.g. "生图做 bg 和 logo", "generate a hero image and a product shot"), you MUST call `generate_image_asset` for each of those slots. One call per named asset. Do NOT collapse multiple named assets into a single call, and do NOT silently substitute SVG/CSS for one of them and bitmap for the other — that violates the brief.', + '', + 'Default choices when the brief is ambiguous:', + "- Logo: if the user asked for it to be *generated* / *illustrated* / *rendered* / any language implying a painted or photographic mark → `generate_image_asset` with `purpose='logo'`, `aspectRatio='1:1'`. Only fall back to inline SVG when the user clearly wants a flat geometric wordmark or when no logo was requested at all.", + '- Background / hero / poster / marketing illustration: always `generate_image_asset` unless the brief explicitly says "no images" or "CSS-only".', + '- Decorative gradients, UI chrome, charts, simple icons (search, menu, arrow, etc.): use HTML/CSS/SVG, never `generate_image_asset`.', + '', + 'Timing: each call is synchronous and takes ~20–60 seconds. To minimise wall-clock time:', + '- Finish the asset inventory above FIRST, then emit every `generate_image_asset` call in ONE turn before touching `index.html`.', + '- The host runs tool calls back-to-back within a turn, so batching N image calls costs ~N × 30s of wall clock, but sprinkling them across turns costs N × (image time + LLM round-trip) which is much slower.', + '- Never interleave one image call with HTML edits — that serialises the waits across many LLM round trips.', + '', + 'When you call it:', + '- Provide a production-ready visual prompt: subject, medium/style, composition, lighting, palette, and any text constraints.', + '- Pick the most accurate `purpose` (hero / product / poster / background / illustration / logo / other) — the host appends structural constraints (composition, overlay-safety, no-text) based on it.', + '- Set `aspectRatio` to match where the image lands (16:9 heroes, 9:16 mobile, 1:1 logos, etc.) — the host maps it to a concrete size.', + '- Provide a meaningful `alt` and optional `filenameHint` (used as the asset stem).', '- Use the returned local `assets/...` path in `index.html`, e.g. `...` or `backgroundImage: "url(\'assets/hero.png\')"`. The host resolves those local paths for preview and persistence.', - '- Keep alt text concise and meaningful.', - '- Prefer one strong asset over many weak assets unless the user explicitly asks for a set.', ].join('\n'); // --------------------------------------------------------------------------- @@ -757,7 +773,7 @@ export async function generateViaAgent( } if (deps.generateImageAsset) { defaultTools.push( - makeGenerateImageAssetTool(deps.generateImageAsset, deps.fs) as unknown as AgentTool< + makeGenerateImageAssetTool(deps.generateImageAsset, deps.fs, log) as unknown as AgentTool< TSchema, unknown >, diff --git a/packages/core/src/tools/generate-image-asset.test.ts b/packages/core/src/tools/generate-image-asset.test.ts index 564af291..6d220315 100644 --- a/packages/core/src/tools/generate-image-asset.test.ts +++ b/packages/core/src/tools/generate-image-asset.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { makeGenerateImageAssetTool } from './generate-image-asset'; +import type { CoreLogger } from '../logger.js'; +import { enrichImagePromptForPurpose, makeGenerateImageAssetTool } from './generate-image-asset'; import type { TextEditorFsCallbacks } from './text-editor'; function memoryFs(): TextEditorFsCallbacks & { files: Map } { @@ -54,14 +55,19 @@ describe('generate_image_asset tool', () => { alt: 'Ink-wash mountains', }); + expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith( - { - prompt: 'A cinematic ink-wash hero background', + expect.objectContaining({ purpose: 'hero', - filenameHint: 'hero', aspectRatio: '16:9', - alt: 'Ink-wash mountains', - }, + prompt: expect.stringContaining('Editorial hero composition'), + }), + undefined, + ); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching(/^A cinematic ink-wash hero background/), + }), undefined, ); expect(fs.files.get('assets/hero.png')).toBe('data:image/png;base64,aW1n'); @@ -70,4 +76,72 @@ describe('generate_image_asset tool', () => { expect(content?.type).toBe('text'); expect(content?.type === 'text' ? content.text : '').toContain('src="assets/hero.png"'); }); + + it('emits structured start/ok logs with prompt preview and duration', async () => { + const fs = memoryFs(); + const events: Array<{ event: string; data?: Record }> = []; + const logger: CoreLogger = { + info: (event, data) => events.push({ event, ...(data ? { data } : {}) }), + warn: () => {}, + error: () => {}, + }; + const generate = vi.fn(async () => ({ + path: 'assets/hero.png', + dataUrl: 'data:image/png;base64,aW1n', + mimeType: 'image/png', + model: 'gpt-image-2', + provider: 'openai', + })); + const tool = makeGenerateImageAssetTool(generate, fs, logger); + await tool.execute('t', { + prompt: 'hero shot with shallow DOF', + purpose: 'hero', + aspectRatio: '16:9', + }); + const names = events.map((e) => e.event); + expect(names).toContain('[image_asset] step=start'); + expect(names).toContain('[image_asset] step=ok'); + const start = events.find((e) => e.event === '[image_asset] step=start'); + expect(start?.data?.['purpose']).toBe('hero'); + expect(start?.data?.['aspectRatio']).toBe('16:9'); + expect(typeof start?.data?.['promptPreview']).toBe('string'); + const ok = events.find((e) => e.event === '[image_asset] step=ok'); + expect(ok?.data?.['path']).toBe('assets/hero.png'); + expect(typeof ok?.data?.['ms']).toBe('number'); + }); + + it('emits a fail log when the backend throws', async () => { + const fs = memoryFs(); + const errors: Array<{ event: string; data?: Record }> = []; + const logger: CoreLogger = { + info: () => {}, + warn: () => {}, + error: (event, data) => errors.push({ event, ...(data ? { data } : {}) }), + }; + const generate = vi.fn(async () => { + throw new Error('rate limited'); + }); + const tool = makeGenerateImageAssetTool(generate, fs, logger); + await expect(tool.execute('t', { prompt: 'x', purpose: 'background' })).rejects.toThrow( + 'rate limited', + ); + expect(errors.map((e) => e.event)).toContain('[image_asset] step=fail'); + }); +}); + +describe('enrichImagePromptForPurpose', () => { + it('appends a purpose-specific suffix for known purposes', () => { + const out = enrichImagePromptForPurpose('a misty mountain', 'background'); + expect(out.startsWith('a misty mountain')).toBe(true); + expect(out).toContain('Seamless full-bleed'); + }); + + it('returns the prompt unchanged for purpose=other', () => { + expect(enrichImagePromptForPurpose('raw', 'other')).toBe('raw'); + }); + + it('skips duplicate enrichment when the prompt already mentions the marker', () => { + const prompt = 'Editorial hero composition of two cyclists'; + expect(enrichImagePromptForPurpose(prompt, 'hero')).toBe(prompt); + }); }); diff --git a/packages/core/src/tools/generate-image-asset.ts b/packages/core/src/tools/generate-image-asset.ts index c8d2f1f4..1b63068b 100644 --- a/packages/core/src/tools/generate-image-asset.ts +++ b/packages/core/src/tools/generate-image-asset.ts @@ -1,5 +1,6 @@ import type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'; import { Type } from '@sinclair/typebox'; +import { type CoreLogger, NOOP_LOGGER } from '../logger.js'; import type { TextEditorFsCallbacks } from './text-editor'; const GenerateImageAssetParams = Type.Object({ @@ -67,9 +68,53 @@ export type GenerateImageAssetFn = ( signal?: AbortSignal, ) => Promise; +const PURPOSE_STYLE_SUFFIX: Record = { + hero: + 'Editorial hero composition: clear focal subject, strong depth, soft diffused lighting, ' + + 'generous negative space on one side for overlay copy, no legible text in the image.', + product: + 'Commercial product photography: centered subject on a clean or minimally textured surface, ' + + 'even studio lighting, subtle shadow, true-to-life colors, no watermarks or text.', + poster: + 'Poster-style illustration: bold silhouette, confident color palette, strong graphic ' + + 'composition with breathing room for a title, no embedded text unless explicitly requested.', + background: + 'Seamless full-bleed background texture/scene: uniform density, low-contrast in the ' + + 'center-top to preserve readability when overlaid with UI content, no legible text, no ' + + 'hard vignettes, safe to crop from multiple edges.', + illustration: + 'Hand-crafted editorial illustration: cohesive palette, clear subject hierarchy, subtle ' + + 'grain, no legible text in the image.', + logo: + 'Logo-style mark: centered composition on neutral background, clean vector-like silhouette, ' + + 'limited palette, square aspect, no surrounding context, no embedded text unless the prompt ' + + 'explicitly lists the wordmark.', + other: '', +}; + +/** + * Append a short constraint/style suffix derived from the `purpose` so the + * bitmap model receives a production-shaped prompt even when the main agent + * wrote only a terse brief. The agent's original phrasing leads; the suffix + * only adds structural guarantees (composition, safety for overlay, no text). + */ +export function enrichImagePromptForPurpose(prompt: string, purpose: ImageAssetPurpose): string { + const base = prompt.trim(); + const suffix = PURPOSE_STYLE_SUFFIX[purpose]; + if (suffix.length === 0) return base; + const already = base.toLowerCase(); + // Simple duplication guard — avoid re-appending if the agent already + // included near-identical guidance (e.g. "seamless background"). Cheap + // substring match; exact-match matters less than keeping prompts short. + const marker = suffix.split(':')[0]?.toLowerCase() ?? ''; + if (marker.length > 0 && already.includes(marker)) return base; + return `${base}\n\n${suffix}`; +} + export function makeGenerateImageAssetTool( generateAsset: GenerateImageAssetFn, fs: TextEditorFsCallbacks | undefined, + logger: CoreLogger = NOOP_LOGGER, ): AgentTool { return { name: 'generate_image_asset', @@ -79,27 +124,57 @@ export function makeGenerateImageAssetTool( 'product render, poster illustration, textured background, or marketing visual. ' + 'Use this only when a generated bitmap would materially improve the artifact. ' + 'Do not use it for simple icons, charts, gradients, or UI chrome that can be ' + - 'drawn with HTML/CSS/SVG. The tool returns a local assets/... path to reference.', + 'drawn with HTML/CSS/SVG. The call is synchronous and takes ~20-60s per image, ' + + 'so prefer batching all needed assets in one assistant turn before writing index.html. ' + + 'The tool returns a local assets/... path to reference.', parameters: GenerateImageAssetParams, async execute( _toolCallId, params, signal, ): Promise> { - const prompt = params.prompt.trim(); - if (prompt.length === 0) throw new Error('Image asset prompt cannot be empty'); + const rawPrompt = params.prompt.trim(); + if (rawPrompt.length === 0) throw new Error('Image asset prompt cannot be empty'); + const enrichedPrompt = enrichImagePromptForPurpose(rawPrompt, params.purpose); const request: GenerateImageAssetRequest = { - prompt, + prompt: enrichedPrompt, purpose: params.purpose, ...(params.filenameHint !== undefined ? { filenameHint: params.filenameHint } : {}), ...(params.aspectRatio !== undefined ? { aspectRatio: params.aspectRatio } : {}), ...(params.alt !== undefined ? { alt: params.alt } : {}), }; - const asset = await generateAsset(request, signal); + const started = Date.now(); + logger.info('[image_asset] step=start', { + purpose: params.purpose, + aspectRatio: params.aspectRatio ?? 'default', + promptChars: enrichedPrompt.length, + promptPreview: enrichedPrompt.slice(0, 160), + enriched: enrichedPrompt.length !== rawPrompt.length, + }); + let asset: GenerateImageAssetResult; + try { + asset = await generateAsset(request, signal); + } catch (err) { + logger.error('[image_asset] step=fail', { + purpose: params.purpose, + ms: Date.now() - started, + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } if (fs !== undefined) { fs.create(asset.path, asset.dataUrl); } const alt = params.alt?.trim() || `${params.purpose} image`; + logger.info('[image_asset] step=ok', { + purpose: params.purpose, + path: asset.path, + provider: asset.provider, + model: asset.model, + mimeType: asset.mimeType, + ms: Date.now() - started, + revised: asset.revisedPrompt !== undefined, + }); const revised = asset.revisedPrompt ? `\nRevised prompt: ${asset.revisedPrompt}` : ''; return { content: [ diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 06644d25..84ad9c53 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -151,11 +151,39 @@ "title": "Settings", "tabs": { "models": "Models", + "images": "Image API", "appearance": "Appearance", "storage": "Storage", "diagnostics": "Diagnostics", "advanced": "Advanced" }, + "imageGen": { + "tabTitle": "Image generation", + "tabHint": "Let the design agent call an image model (e.g. gpt-image-2) for heroes, posters, and background bitmaps. Credentials stay local.", + "title": "Image generation assist", + "hint": "When enabled, the agent can invoke generate_image_asset during a run.", + "enabled": "Enable image generation assist", + "provider": "Provider", + "credentials": "Credentials", + "inherit": "Inherit from model provider", + "customKey": "Use a separate key", + "keyPlaceholder": "Leave empty to keep {{mask}}", + "newKeyPlaceholder": "Paste API key", + "model": "Image model", + "baseUrl": "Base URL", + "quality": "Quality", + "size": "Size", + "status": { + "ready": "Ready", + "needsKey": "Needs API key", + "disabled": "Disabled" + }, + "toast": { + "loadFailed": "Failed to load image generation settings", + "saved": "Image generation settings saved", + "saveFailed": "Failed to save image generation settings" + } + }, "shell": { "back": "Workspace", "backAria": "Back to workspace" diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index a96c37e1..de659f2c 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -151,11 +151,39 @@ "title": "设置", "tabs": { "models": "模型", + "images": "图像 API", "appearance": "外观", "storage": "存储", "diagnostics": "诊断", "advanced": "高级" }, + "imageGen": { + "tabTitle": "图像生成", + "tabHint": "允许设计 Agent 调用图像模型(如 gpt-image-2)生成 hero、海报或背景位图。凭据仅保存在本机。", + "title": "图像生成辅助", + "hint": "开启后,Agent 在生成设计时可以调用 generate_image_asset 工具。", + "enabled": "启用图像生成辅助", + "provider": "服务商", + "credentials": "凭据方式", + "inherit": "沿用模型服务商", + "customKey": "单独设置 Key", + "keyPlaceholder": "留空则保留 {{mask}}", + "newKeyPlaceholder": "粘贴 API Key", + "model": "图像模型", + "baseUrl": "接入地址", + "quality": "画质", + "size": "尺寸", + "status": { + "ready": "可用", + "needsKey": "缺少 API Key", + "disabled": "未启用" + }, + "toast": { + "loadFailed": "加载图像生成设置失败", + "saved": "图像生成设置已保存", + "saveFailed": "保存图像生成设置失败" + } + }, "shell": { "back": "工作区", "backAria": "返回工作区" From 958e25f7df953f40a19ff65b635379bfa4d65a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=B3=BB=E9=AA=81?= Date: Thu, 23 Apr 2026 11:42:23 +0800 Subject: [PATCH 6/6] fix(providers): eliminate polynomial regex in joinEndpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the anchored-quantifier pair `/\/+$/` + `/^\/+/` inside `joinEndpoint` (packages/providers/src/images.ts) as a potential ReDoS on library-supplied input. Replace both regex calls with explicit single-pass scans over the trailing/leading `/` characters — same behaviour, trivially linear, no CodeQL alert. Also unblock the Windows test run on this branch: - `token-store.test.ts`: a new 0o600-mode assertion added on main fails on NTFS (always reports 0o666); guard it the same way the existing sibling assertion is guarded. - `safe-read.test.ts`: the symlink-acceptance case requires admin / Developer Mode on Windows and otherwise throws EPERM; skip the case when symlink creation is denied, keeping full coverage on POSIX CI. Signed-off-by: 杨峻骁 Made-with: Cursor --- apps/desktop/src/main/imports/safe-read.test.ts | 15 +++++++++++++-- packages/providers/src/codex/token-store.test.ts | 8 ++++++-- packages/providers/src/images.ts | 10 +++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/imports/safe-read.test.ts b/apps/desktop/src/main/imports/safe-read.test.ts index 2561dc6f..36f0a50a 100644 --- a/apps/desktop/src/main/imports/safe-read.test.ts +++ b/apps/desktop/src/main/imports/safe-read.test.ts @@ -36,12 +36,23 @@ describe('safeReadImportFile', () => { expect(await safeReadImportFile(path)).toBeNull(); }); - it('accepts a symlink to a regular small file (legitimate dotfile repo pattern)', async () => { + it('accepts a symlink to a regular small file (legitimate dotfile repo pattern)', async (ctx) => { const dir = await freshPath(); const target = join(dir, 'target.env'); await writeFile(target, 'GEMINI_API_KEY=AIzaSy...', 'utf8'); const link = join(dir, 'link.env'); - await symlink(target, link); + try { + await symlink(target, link); + } catch (err) { + // Windows users without Developer Mode / admin rights cannot create + // symlinks at all (EPERM). Skip rather than fail — the production code + // is POSIX-pattern-agnostic, and CI on Linux/macOS keeps real coverage. + if ((err as NodeJS.ErrnoException).code === 'EPERM') { + ctx.skip(); + return; + } + throw err; + } expect(await safeReadImportFile(link)).toBe('GEMINI_API_KEY=AIzaSy...'); }); }); diff --git a/packages/providers/src/codex/token-store.test.ts b/packages/providers/src/codex/token-store.test.ts index ff94fe58..c82964ed 100644 --- a/packages/providers/src/codex/token-store.test.ts +++ b/packages/providers/src/codex/token-store.test.ts @@ -270,8 +270,12 @@ describe('CodexTokenStore', () => { await store.write(auth); const body = JSON.parse(await readFile(filePath, 'utf8')) as StoredCodexAuth; expect(body).toEqual(auth); - const s = await stat(filePath); - expect(s.mode & 0o777).toBe(0o600); + // POSIX mode bits aren't enforceable on Windows NTFS (always reports + // 0o666), so only assert the 0o600 bit pattern on platforms that honor it. + if (process.platform !== 'win32') { + const s = await stat(filePath); + expect(s.mode & 0o777).toBe(0o600); + } const leftovers = (await readdir(dirname(filePath))).filter((n) => n.startsWith(`${filePath.split('/').pop()}.tmp.`), ); diff --git a/packages/providers/src/images.ts b/packages/providers/src/images.ts index 530c1252..41154c70 100644 --- a/packages/providers/src/images.ts +++ b/packages/providers/src/images.ts @@ -214,7 +214,15 @@ async function postJson( } function joinEndpoint(baseUrl: string, path: string): string { - return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + // Trim trailing `/` on baseUrl and leading `/` on path with explicit loops + // instead of /\/+$/ + /^\/+/. CodeQL flags the anchored-quantifier regex + // form as polynomial ReDoS on library input, and a simple scan is both + // linear in the worst case and easier to reason about. + let end = baseUrl.length; + while (end > 0 && baseUrl.charCodeAt(end - 1) === 47) end--; + let start = 0; + while (start < path.length && path.charCodeAt(start) === 47) start++; + return `${baseUrl.slice(0, end)}/${path.slice(start)}`; } function mimeFromFormat(format: ImageOutputFormat): string {