Skip to content

Commit 1377b4f

Browse files
authored
feat: support gpt-image-2 for asset generation (#193)
## Summary Lets the agent call gpt-image-2 (or OpenRouter image models) on demand to generate bitmap assets (logos, hero images, illustrations) while producing a design, and embeds them seamlessly in preview + exports. Off by default; users opt in from Settings. ## Type of change - [x] New feature ## Linked issue <!-- "Closes #123" or "Refs #123" --> ## Checklist - [x] I read [`docs/VISION.md`](../docs/VISION.md), [`docs/PRINCIPLES.md`](../docs/PRINCIPLES.md), and [`CLAUDE.md`](../CLAUDE.md) before starting - [x] Commits are signed with DCO (`git commit -s`) - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [x] Updated docs if behavior changed ## Dependency additions (if any) <!-- For each new prod dependency: name, install size, license, why-not-alternatives. Delete this section if no new deps. --> ## Screenshots / recordings (UI changes) --------- Signed-off-by: 杨峻骁 <yangjunx21@mails.tsinghua.edu.cn>
1 parent 803a735 commit 1377b4f

25 files changed

Lines changed: 1852 additions & 32 deletions

apps/desktop/src/main/boot-fallback.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
1+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { describe, expect, it, vi } from 'vitest';
@@ -54,12 +54,20 @@ describe('writeBootErrorSync', () => {
5454
});
5555

5656
it('falls back to the OS tmpdir when the primary path is unwritable', () => {
57-
// Give a path we cannot create (under /dev/null). mkdirSync will throw,
58-
// and writeBootErrorSync must catch and redirect to tmpdir.
59-
const bogus = '/dev/null/does-not-exist/logs';
60-
const out = writeBootErrorSync(mkCtx({ logsDir: bogus }));
61-
expect(out).toBe(join(tmpdir(), 'boot-errors.log'));
62-
expect(existsSync(out)).toBe(true);
57+
// Build a path whose parent is a regular file — mkdirSync then throws
58+
// ENOTDIR on both POSIX and Windows, exercising the tmpdir fallback in a
59+
// platform-agnostic way.
60+
const scratchDir = mkdtempSync(join(tmpdir(), 'boot-fallback-bad-'));
61+
const blocker = join(scratchDir, 'not-a-dir');
62+
writeFileSync(blocker, 'blocker');
63+
try {
64+
const bogus = join(blocker, 'logs');
65+
const out = writeBootErrorSync(mkCtx({ logsDir: bogus }));
66+
expect(out).toBe(join(tmpdir(), 'boot-errors.log'));
67+
expect(existsSync(out)).toBe(true);
68+
} finally {
69+
rmSync(scratchDir, { recursive: true, force: true });
70+
}
6371
});
6472
});
6573

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
type Config,
3+
IMAGE_GENERATION_SCHEMA_VERSION,
4+
type ProviderEntry,
5+
hydrateConfig,
6+
} from '@open-codesign/shared';
7+
import { afterEach, describe, expect, it, vi } from 'vitest';
8+
import {
9+
imageSettingsToView,
10+
isGenerateImageAssetEnabled,
11+
resolveImageGenerationConfig,
12+
} from './image-generation-settings';
13+
14+
const getApiKeyForProviderMock = vi.fn<(provider: string) => string>();
15+
16+
vi.mock('./onboarding-ipc', () => ({
17+
getApiKeyForProvider: (provider: string) => getApiKeyForProviderMock(provider),
18+
getCachedConfig: () => null,
19+
setCachedConfig: () => {},
20+
}));
21+
22+
vi.mock('./keychain', () => ({
23+
buildSecretRef: (value: string) => ({ ciphertext: value, mask: '***' }),
24+
decryptSecret: (value: string) => value,
25+
}));
26+
27+
vi.mock('./logger', () => ({
28+
getLogger: () => ({
29+
debug: vi.fn(),
30+
info: vi.fn(),
31+
warn: vi.fn(),
32+
error: vi.fn(),
33+
}),
34+
}));
35+
36+
function makeConfig(imageEnabled: boolean): Config {
37+
const providers: Record<string, ProviderEntry> = {
38+
openai: {
39+
id: 'openai',
40+
name: 'OpenAI',
41+
builtin: true,
42+
wire: 'openai-chat',
43+
baseUrl: 'https://api.openai.com/v1',
44+
defaultModel: 'gpt-5.4',
45+
},
46+
};
47+
return hydrateConfig({
48+
version: 3,
49+
activeProvider: 'openai',
50+
activeModel: 'gpt-5.4',
51+
providers,
52+
secrets: {},
53+
imageGeneration: {
54+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
55+
enabled: imageEnabled,
56+
provider: 'openai',
57+
credentialMode: 'inherit',
58+
model: 'gpt-image-2',
59+
quality: 'high',
60+
size: '1536x1024',
61+
outputFormat: 'png',
62+
},
63+
});
64+
}
65+
66+
describe('image generation enablement', () => {
67+
afterEach(() => {
68+
getApiKeyForProviderMock.mockReset();
69+
});
70+
71+
it('disables generate_image_asset when image generation is turned off', () => {
72+
const cfg = makeConfig(false);
73+
expect(isGenerateImageAssetEnabled(cfg)).toBe(false);
74+
expect(resolveImageGenerationConfig(cfg)).toBeNull();
75+
});
76+
77+
it('enables generate_image_asset when image generation is on and key is available', () => {
78+
getApiKeyForProviderMock.mockReturnValue('sk-openai');
79+
const cfg = makeConfig(true);
80+
expect(isGenerateImageAssetEnabled(cfg)).toBe(true);
81+
expect(resolveImageGenerationConfig(cfg)).toMatchObject({
82+
provider: 'openai',
83+
model: 'gpt-image-2',
84+
apiKey: 'sk-openai',
85+
});
86+
});
87+
88+
it('keeps generate_image_asset disabled when image generation is on but key is unavailable', () => {
89+
getApiKeyForProviderMock.mockImplementation(() => {
90+
throw new Error('missing key');
91+
});
92+
const cfg = makeConfig(true);
93+
expect(isGenerateImageAssetEnabled(cfg)).toBe(false);
94+
expect(resolveImageGenerationConfig(cfg)).toBeNull();
95+
});
96+
97+
it('reports inheritedKeyAvailable=false in the view when the provider key is missing', () => {
98+
getApiKeyForProviderMock.mockImplementation(() => {
99+
throw new Error('missing key');
100+
});
101+
const cfg = makeConfig(true);
102+
const view = imageSettingsToView(cfg.imageGeneration);
103+
expect(view.enabled).toBe(true);
104+
expect(view.credentialMode).toBe('inherit');
105+
expect(view.inheritedKeyAvailable).toBe(false);
106+
expect(view.hasCustomKey).toBe(false);
107+
});
108+
109+
it('reports inheritedKeyAvailable=true in the view when the provider key exists', () => {
110+
getApiKeyForProviderMock.mockReturnValue('sk-openai');
111+
const cfg = makeConfig(true);
112+
const view = imageSettingsToView(cfg.imageGeneration);
113+
expect(view.inheritedKeyAvailable).toBe(true);
114+
});
115+
});

0 commit comments

Comments
 (0)