dsh-image-gen-plugin 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1323 @@
1
+ window.__ModuleLoader__.load({
2
+ // Must equal package.json's name; a mismatch is refused and the card has no renderer.
3
+ id: 'dsh-image-gen-plugin',
4
+ factory(require) {
5
+ const React = require('react');
6
+ const h = React.createElement;
7
+ const { useState, useEffect, useLayoutEffect, useCallback } = React;
8
+
9
+ const useSyncExternalStore =
10
+ typeof React.useSyncExternalStore === 'function'
11
+ ? React.useSyncExternalStore
12
+ : (_subscribe, getSnapshot) => getSnapshot();
13
+
14
+ const NS = 'dsh-image-generation';
15
+ const SECTION_ID = 'image-generation';
16
+
17
+ const RECORDS_ROUTE = '/dsh-image-generation/records';
18
+ const LOCALE_NS = 'dsh-image-generation';
19
+
20
+ const BUILTIN_MODELS = [
21
+ 'gpt-image-2.5-flare',
22
+ 'gpt-image-2.5-flare-2026-09-08',
23
+ 'gpt-image-2.5-sunburst',
24
+ 'gpt-image-2',
25
+ 'gpt-image-2-2026-04-21',
26
+ ];
27
+ const FORMATS = ['png', 'jpeg', 'webp'];
28
+ const MODERATIONS = ['auto', 'low'];
29
+ const BACKGROUNDS = ['auto', 'transparent', 'opaque'];
30
+ const COUNTS = [1, 2, 3, 4];
31
+
32
+ const NAME_TOKENS = ['{time}', '{prompt}', '{index}', '{uniq}'];
33
+ const NAME_PRESETS = ['{time}-{prompt}', '{prompt}-{time}', '{time}', '{prompt}'];
34
+ const RESOLUTION_PRESETS = [
35
+ 'auto',
36
+ '1024x1024',
37
+ '1536x1024',
38
+ '1024x1536',
39
+ '2048x2048',
40
+ '2560x1440',
41
+ '1440x2560',
42
+ '3840x2160',
43
+ '2160x3840',
44
+ ];
45
+ const FIELDS = [
46
+ 'apiUrl',
47
+ 'model',
48
+ 'count',
49
+ 'outputFormat',
50
+ 'outputCompression',
51
+ 'background',
52
+ 'moderation',
53
+ 'user',
54
+ 'defaultResolution',
55
+ 'maxEdge',
56
+ 'outputDir',
57
+ 'fileNameFormat',
58
+ 'requestTimeoutSec',
59
+ 'sendsModeration',
60
+ 'sendsOutputCompression',
61
+ ];
62
+
63
+ const EN = {
64
+ section: 'Image generation',
65
+ title: 'Image generation',
66
+ subtitle:
67
+ 'Where calls are sent, and what they fall back to. The model and endpoint decide whether a call can work at all; every default below only applies when a call leaves that argument out.',
68
+ labelModel: 'Model',
69
+ hintModel: 'The model id exactly as this endpoint names it. The quick picks cover the GPT Image 2.5 and 2 families; type in anything else your gateway offers.',
70
+ labelApiUrl: 'API URL',
71
+ hintApiUrl: 'Base URL of an OpenAI-compatible API. /images/generations is appended to it.',
72
+ labelApiKey: 'API key',
73
+ hintApiKeySet: 'A key is stored under a neutral field name in the profile config, and the settings API never sends it back. Leave this empty to keep it; type a new one to replace it.',
74
+ hintApiKeyEmpty: 'Sent as the Bearer token. It is stored in the profile configuration under a neutral field name and never leaves this machine.',
75
+ sectionConnection: 'Connection',
76
+ sectionMaintenance: 'Records',
77
+ clearButton: 'Clear image records',
78
+ clearHint: 'Which files the plugin wrote, so their cards can find them again.',
79
+ clearWarnTitle: 'Clear the image records?',
80
+ clearWarnBody:
81
+ 'This empties the record of which files were written. Every card already in the conversation will lose its image, because the plugin will no longer know where those files are. The image files themselves are NOT deleted, and new generations record themselves again. This cannot be undone.',
82
+ clearConfirm: 'Yes, clear the records',
83
+ clearCancel: 'Cancel',
84
+ clearDone: 'Cleared {n} records. New generations will record themselves again.',
85
+ clearFailed: 'Could not clear the records',
86
+ sectionDefaults: 'Default parameters',
87
+ hintDefaults: 'Used when a call leaves the matching argument out. A call that passes the argument wins, so nothing here has to be changed to make one call differ.',
88
+ labelCount: 'Count',
89
+ hintCount: 'Used when a call does not pass its own count. The API accepts 1 to 10.',
90
+ labelFormat: 'Output format',
91
+ hintFormat: 'Transparent backgrounds need png or webp; compression applies to jpeg and webp.',
92
+ labelCompression: 'Compression',
93
+ labelBackground: 'Background',
94
+ hintBackground: 'The default a call inherits when it names no background.',
95
+ labelModeration: 'Moderation',
96
+ hintModeration: 'Ignored by the 2.5 family, which rejects the parameter.',
97
+ labelUser: 'User',
98
+ hintUser: 'Optional end-user identifier the provider receives with each request.',
99
+ labelOutputDir: 'Output directory',
100
+ hintOutputDir: 'Where images are written when location is not passed. Empty uses the session workspace, then the plugin folder as a last resort.',
101
+ outputDirPlaceholder: 'leave empty for the session workspace',
102
+ labelFileName: 'File name',
103
+ hintFileName: 'The pattern every file is named with. {time} sorts, {prompt} is readable, {index} separates a batch, {uniq} guarantees a fresh name. A name that already exists gets a numeric suffix, so nothing is overwritten.',
104
+ fileNamePlaceholder: '{time}-{prompt}',
105
+ labelTimeout: 'Timeout',
106
+ hintTimeout: 'Seconds to wait for one generation before giving up.',
107
+ labelResolution: 'Default size', hintResolution: 'Used when a call names no size. The endpoint wants each side to be a multiple of 16, the longest edge under the cap, 655,360 to 8,294,400 pixels in total, and a ratio no wider than 3:1.',
108
+ labelMaxEdge: 'Size cap per side',
109
+ hintMaxEdge: 'The model accepts a frame up to this value on its longest edge. 3840 matches the model default.',
110
+ resolutionPlaceholder: 'WxH, e.g. 1536x864',
111
+ resolutionAuto: 'auto',
112
+ sizeFormatError: 'Write the size as WIDTHxHEIGHT, for example 1536x864, or leave it empty for auto.',
113
+ sizeStepError: 'Each side must be a multiple of 16.',
114
+ sizeCapError: 'The longest edge must stay at or below {cap}.',
115
+ sizeRatioError: 'The endpoint refuses a ratio wider than 3:1.',
116
+ sizeFloorError: 'The endpoint needs at least {min} pixels.',
117
+ sizeCeilingError: 'The endpoint accepts at most {max} pixels; a 3840 square is over that, 2880x2880 is the largest square.',
118
+ labelCompatibility: 'Request fields',
119
+ toggleModeration: 'Send moderation',
120
+ toggleCompression: 'Send output_compression',
121
+ hintCompatibility: 'Turn a field off when the model behind this endpoint rejects it. The 2.5 family always drops moderation.',
122
+ save: 'Save',
123
+ saving: 'Saving...',
124
+ reload: 'Reload',
125
+ saved: 'Saved',
126
+ conflict: 'Settings changed elsewhere; the form was refreshed, apply again.',
127
+ loading: 'Loading settings...',
128
+ unavailable: 'The settings API is not reachable from this page, so the plugin config in cordis.patch.yml stays in charge.',
129
+ noSettingsApi: 'This page cannot reach the settings API, so the form cannot load.',
130
+ missing: 'This plugin has no configuration entry in the active profile.',
131
+ readOnly: 'This profile is read-only, so the form cannot be saved.',
132
+ cardTitle: 'Image generation',
133
+ running: 'generating...',
134
+ images: 'image(s)',
135
+ expandPrompt: 'Show prompt',
136
+ livePrompt: 'Prompt',
137
+ receivedChars: '{n} characters received',
138
+ expandParams: 'Parameters',
139
+ paramResolution: 'Size',
140
+ paramQuality: 'Quality',
141
+ paramCount: 'Count',
142
+ paramBackground: 'Background',
143
+ paramLocation: 'Location',
144
+ paramFiles: 'Files written',
145
+ paramImages: 'Source images',
146
+ paramMask: 'Mask',
147
+ imageAlt: 'generated image',
148
+ cardFailed: 'This card could not be drawn',
149
+ };
150
+
151
+ const ZH = {
152
+ section: '图片生成',
153
+ title: '图片生成',
154
+ subtitle: '调用发往哪里,以及没有自带参数时用什么。模型和端点决定调用能不能成功;下面的每一项默认值,只在调用省略了对应参数时生效。',
155
+ labelModel: '模型',
156
+ hintModel: '按这个端点自己的叫法填模型 id。快捷选项覆盖 GPT Image 2.5 和 2 两个系列;网关还有别的模型,直接输入即可。',
157
+ labelApiUrl: 'API 地址',
158
+ hintApiUrl: '兼容 OpenAI 的接口基地址,会在后面拼接 /images/generations。',
159
+ labelApiKey: 'API 密钥',
160
+ hintApiKeySet: '密钥已保存,配置里用的是中性字段名,设置接口不会把它回传。留空表示不改动;输入新值即可替换。',
161
+ hintApiKeyEmpty: '作为 Bearer 令牌发送。保存在本机配置里,字段名是中性的,不会外传。',
162
+ sectionConnection: '连接',
163
+ sectionMaintenance: '记录',
164
+ clearButton: '清除图片记录',
165
+ clearHint: '插件写过哪些文件,卡片靠它找回图片。',
166
+ clearWarnTitle: '确定清除图片记录?',
167
+ clearWarnBody:
168
+ '这会清空「写过哪些文件」的记录。对话里已有的卡片将不再显示图片,因为插件不再知道那些文件在哪。图片文件本身不会被删除,之后新生成的图会重新记录。此操作无法撤销。',
169
+ clearConfirm: '确定清除',
170
+ clearCancel: '取消',
171
+ clearDone: '已清除 {n} 条记录。之后新生成的图会重新记录。',
172
+ clearFailed: '清除失败',
173
+ sectionDefaults: '默认参数',
174
+ hintDefaults: '调用没有传对应参数时使用。调用传了就以调用为准,所以想让某一次不一样,不需要改这里的任何东西。',
175
+ labelCount: '数量',
176
+ hintCount: '调用未提供数量时使用。接口允许 1 到 10。',
177
+ labelFormat: '输出格式',
178
+ hintFormat: '透明背景需要 png 或 webp;压缩率对 jpeg 和 webp 生效。',
179
+ labelCompression: '压缩率',
180
+ labelBackground: '背景',
181
+ hintBackground: '调用未指定背景时继承的默认值。',
182
+ labelModeration: '内容审查',
183
+ hintModeration: '2.5 系列会忽略并拒绝该参数。',
184
+ labelUser: '用户标识',
185
+ hintUser: '可选的终端用户标识,随每次请求发给服务端。',
186
+ labelOutputDir: '输出目录',
187
+ hintOutputDir: '调用没传 location 时,图片写到这里。留空则优先会话工作区,最后才用插件包内目录。',
188
+ outputDirPlaceholder: '留空使用会话工作区',
189
+ labelFileName: '文件名',
190
+ hintFileName: '每个文件的命名模板。{time} 便于排序,{prompt} 便于辨认,{index} 区分数张,{uniq} 保证不重名。同名文件会自动加数字后缀,不会被覆盖。',
191
+ fileNamePlaceholder: '{time}-{prompt}',
192
+ labelTimeout: '超时',
193
+ hintTimeout: '单次生成最多等待的秒数。',
194
+ labelResolution: '默认尺寸',
195
+ hintResolution: '调用没指定尺寸时使用。接口要求:每条边是 16 的倍数、最长边不超过下面的上限、总像素在 655,360 到 8,294,400 之间、比例不超过 3:1。',
196
+ labelMaxEdge: '最长边上限',
197
+ hintMaxEdge: '模型允许最长边最大到这个值。3840 是模型默认。',
198
+ resolutionPlaceholder: '宽x高,如 1536x864',
199
+ resolutionAuto: '智能比例',
200
+ sizeFormatError: '尺寸写成 宽x高,例如 1536x864;留空表示 auto。',
201
+ sizeStepError: '每条边都必须是 16 的倍数。',
202
+ sizeCapError: '最长边不能超过 {cap}。',
203
+ sizeRatioError: '接口不接受超过 3:1 的比例。',
204
+ sizeFloorError: '接口要求至少 {min} 像素。',
205
+ sizeCeilingError: '接口最多接受 {max} 像素;3840 的正方形超了,最大的正方形是 2880x2880。',
206
+ labelCompatibility: '请求字段',
207
+ toggleModeration: '发送 moderation',
208
+ toggleCompression: '发送 output_compression',
209
+ hintCompatibility: '如果这个端点背后的模型不接受某个字段,就在这里关掉。2.5 系列始终不发 moderation。',
210
+ save: '保存',
211
+ saving: '保存中...',
212
+ reload: '重新读取',
213
+ saved: '已保存',
214
+ conflict: '设置已在别处改动,已刷新表单,请再点一次保存。',
215
+ loading: '正在读取设置...',
216
+ unavailable: '当前页面访问不到设置 API,请直接改 cordis.patch.yml 里的插件配置。',
217
+ noSettingsApi: '这个页面取不到设置 API,表单无法加载。',
218
+ missing: '当前配置里没有这个插件的配置项。',
219
+ readOnly: '当前配置为只读,表单无法保存。',
220
+ cardTitle: '图片生成',
221
+ running: '生成中...',
222
+ images: '张图片',
223
+ expandPrompt: '展开提示词',
224
+ livePrompt: '提示词',
225
+ receivedChars: '已收 {n} 字',
226
+ expandParams: '参数',
227
+ paramResolution: '尺寸',
228
+ paramQuality: '画质',
229
+ paramCount: '张数',
230
+ paramBackground: '背景',
231
+ paramLocation: '位置',
232
+ paramFiles: '写盘文件',
233
+ paramImages: '源图',
234
+ paramMask: '蒙版',
235
+ imageAlt: '生成的图片',
236
+ cardFailed: '这张卡片渲染失败',
237
+ };
238
+
239
+ function useLanguage() {
240
+ const [, bump] = useState(0);
241
+ useEffect(() => {
242
+ if (!localeStore.source) return undefined;
243
+ return localeStore.subscribe(() => bump((value) => value + 1));
244
+ }, []);
245
+
246
+ return t;
247
+ }
248
+
249
+ const localeStore = {
250
+ source: null,
251
+ listeners: new Set(),
252
+ subscribe(listener) {
253
+ localeStore.listeners.add(listener);
254
+ return () => localeStore.listeners.delete(listener);
255
+ },
256
+ };
257
+
258
+ let localeReader = () => '';
259
+
260
+ const activeLocale = (localeService) => {
261
+ try {
262
+ const snapshot = localeService?.getSnapshot?.();
263
+ return snapshot?.active ?? snapshot?.locale ?? '';
264
+ } catch {
265
+ return '';
266
+ }
267
+ };
268
+
269
+ const t = (key) => {
270
+ const active = String(localeReader() ?? '');
271
+ const table = active.toLowerCase().startsWith('zh') ? ZH : EN;
272
+ return table[key] ?? EN[key] ?? key;
273
+ };
274
+
275
+ const TOKENS = {
276
+ text: 'var(--dsw-alias-text-primary, currentColor)',
277
+ muted: 'var(--dsw-alias-text-secondary, color-mix(in srgb, currentColor 62%, transparent))',
278
+ border: 'var(--dsw-alias-border-secondary, color-mix(in srgb, currentColor 18%, transparent))',
279
+ surface: 'var(--dsw-alias-bg-elevated, color-mix(in srgb, currentColor 5%, transparent))',
280
+ input: 'var(--dsw-alias-bg-base, transparent)',
281
+ accent: 'var(--dsw-alias-brand-primary, #4f7cff)',
282
+ danger: 'var(--dsw-alias-text-danger, #d9534f)',
283
+ };
284
+
285
+ const controlStyle = {
286
+ width: '100%',
287
+ boxSizing: 'border-box',
288
+ padding: '6px 8px',
289
+ borderRadius: 6,
290
+ border: `1px solid ${TOKENS.border}`,
291
+ background: TOKENS.input,
292
+ color: TOKENS.text,
293
+ font: 'inherit',
294
+ fontSize: 13,
295
+ };
296
+ const labelStyle = { width: 128, flex: '0 0 auto', paddingTop: 6, fontSize: 13, color: TOKENS.muted };
297
+ const rowStyle = { display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 10 };
298
+ const hintStyle = { fontSize: 12, color: TOKENS.muted, marginTop: 4, lineHeight: 1.5 };
299
+
300
+ function Row(props) {
301
+ return h(
302
+ 'div',
303
+ { style: rowStyle },
304
+ h('div', { style: labelStyle }, props.label),
305
+ h(
306
+ 'div',
307
+ { style: { flex: 1, minWidth: 0 } },
308
+ props.children,
309
+ props.hint ? h('div', { style: hintStyle }, props.hint) : null,
310
+ ),
311
+ );
312
+ }
313
+
314
+ function TextField(props) {
315
+ return h('input', {
316
+ type: props.type ?? 'text',
317
+ value: props.value ?? '',
318
+ placeholder: props.placeholder,
319
+ spellCheck: false,
320
+ autoComplete: 'off',
321
+ style: controlStyle,
322
+ onChange: (event) => props.onChange(event.target.value),
323
+ });
324
+ }
325
+
326
+ function SelectField(props) {
327
+ return h(
328
+ 'select',
329
+ { value: props.value, style: controlStyle, onChange: (event) => props.onChange(event.target.value) },
330
+ props.options.map((option) =>
331
+ h('option', { key: String(option), value: String(option) }, props.format ? props.format(option) : String(option)),
332
+ ),
333
+ );
334
+ }
335
+
336
+ function Button(props) {
337
+ return h(
338
+ 'button',
339
+ {
340
+ type: 'button',
341
+ disabled: props.disabled,
342
+ onClick: props.onClick,
343
+ style: {
344
+ padding: '6px 14px',
345
+ borderRadius: 6,
346
+ border: props.primary ? '1px solid transparent' : `1px solid ${props.danger ? TOKENS.danger : TOKENS.border}`,
347
+ background: props.primary ? TOKENS.accent : 'transparent',
348
+ color: props.primary ? '#fff' : props.danger ? TOKENS.danger : TOKENS.text,
349
+ cursor: props.disabled ? 'default' : 'pointer',
350
+ opacity: props.disabled ? 0.55 : 1,
351
+ font: 'inherit',
352
+ fontSize: 13,
353
+ },
354
+ },
355
+ props.children,
356
+ );
357
+ }
358
+
359
+ function Chip(props) {
360
+ return h(
361
+ 'button',
362
+ {
363
+ type: 'button',
364
+ onClick: props.onClick,
365
+ style: {
366
+ padding: '3px 9px',
367
+ borderRadius: 999,
368
+ border: `1px solid ${props.active ? TOKENS.accent : TOKENS.border}`,
369
+ background: 'transparent',
370
+ color: props.active ? TOKENS.accent : TOKENS.muted,
371
+ cursor: 'pointer',
372
+ fontSize: 12,
373
+ font: 'inherit',
374
+ },
375
+ },
376
+ props.children,
377
+ );
378
+ }
379
+
380
+ function CheckField(props) {
381
+ return h(
382
+ 'label',
383
+ { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: TOKENS.text, marginBottom: 6 } },
384
+ h('input', {
385
+ type: 'checkbox',
386
+ checked: props.value,
387
+ style: { margin: 0 },
388
+ onChange: (event) => props.onChange(event.target.checked),
389
+ }),
390
+ props.label,
391
+ );
392
+ }
393
+
394
+ const asNumber = (value, fallback) => {
395
+ const parsed = Number(value);
396
+ return Number.isFinite(parsed) ? parsed : fallback;
397
+ };
398
+
399
+ const SIZE_STEP = 16;
400
+ const MIN_PIXELS = 655360;
401
+ const MAX_PIXELS = 8294400;
402
+
403
+ function checkSize(value, maxEdge) {
404
+ if (!value || value === 'auto') return { ok: true, auto: true };
405
+ const match = /^(\d{2,5})x(\d{2,5})$/.exec(value);
406
+ if (!match) return { ok: false, reason: 'format' };
407
+ const width = Number(match[1]);
408
+ const height = Number(match[2]);
409
+ if (width % SIZE_STEP !== 0 || height % SIZE_STEP !== 0) return { ok: false, reason: 'step' };
410
+ if (Math.max(width, height) / Math.min(width, height) > 3) return { ok: false, reason: 'ratio' };
411
+ if (Math.max(width, height) > maxEdge) return { ok: false, reason: 'cap', maxEdge };
412
+ const pixels = width * height;
413
+ if (pixels < MIN_PIXELS) return { ok: false, reason: 'floor', minPixels: MIN_PIXELS };
414
+ if (pixels > MAX_PIXELS) return { ok: false, reason: 'ceiling', maxPixels: MAX_PIXELS };
415
+ return { ok: true, value: `${width}x${height}` };
416
+ }
417
+
418
+ function resolutionNotice(value, maxEdge, t) {
419
+ const verdict = checkSize(value, maxEdge);
420
+ if (verdict.ok) return null;
421
+ const text =
422
+ verdict.reason === 'step'
423
+ ? t('sizeStepError')
424
+ : verdict.reason === 'cap'
425
+ ? t('sizeCapError').replace('{cap}', String(verdict.maxEdge))
426
+ : verdict.reason === 'ratio'
427
+ ? t('sizeRatioError')
428
+ : verdict.reason === 'floor'
429
+ ? t('sizeFloorError').replace('{min}', String(verdict.minPixels))
430
+ : verdict.reason === 'ceiling'
431
+ ? t('sizeCeilingError').replace('{max}', String(verdict.maxPixels))
432
+ : t('sizeFormatError');
433
+ return h('div', { 'data-size-error': verdict.reason, style: { fontSize: 12, color: TOKENS.danger, marginTop: 6 } }, text);
434
+ }
435
+
436
+ function normalize(raw) {
437
+ return {
438
+ apiUrl: typeof raw?.apiUrl === 'string' ? raw.apiUrl : '',
439
+ model: typeof raw?.model === 'string' ? raw.model : '',
440
+ count: asNumber(raw?.count, 1),
441
+ outputFormat: typeof raw?.outputFormat === 'string' ? raw.outputFormat : 'png',
442
+ outputCompression: asNumber(raw?.outputCompression, 100),
443
+ background: typeof raw?.background === 'string' ? raw.background : 'auto',
444
+ moderation: typeof raw?.moderation === 'string' ? raw.moderation : 'auto',
445
+ user: typeof raw?.user === 'string' ? raw.user : '',
446
+ outputDir: typeof raw?.outputDir === 'string' ? raw.outputDir : '',
447
+ fileNameFormat: typeof raw?.fileNameFormat === 'string' ? raw.fileNameFormat : '{time}-{prompt}',
448
+ defaultResolution: typeof raw?.defaultResolution === 'string' ? raw.defaultResolution : 'auto',
449
+ requestTimeoutSec: asNumber(raw?.requestTimeoutSec, 600),
450
+ maxEdge: asNumber(raw?.maxEdge, 3840),
451
+ sendsModeration: raw?.sendsModeration !== false,
452
+ sendsOutputCompression: raw?.sendsOutputCompression !== false,
453
+ };
454
+ }
455
+
456
+ const unwrap = (response) => {
457
+ if (response && typeof response === 'object' && 'ok' in response) {
458
+ if (response.ok) return { ok: true, value: response.value };
459
+ const message = response.error?.message ?? response.error?.code ?? 'the settings call was refused';
460
+ return { ok: false, message: String(message) };
461
+ }
462
+ return { ok: true, value: response };
463
+ };
464
+
465
+ function makeSettingsSection(resolveApi) {
466
+ return function SettingsSection() {
467
+ const t = useLanguage();
468
+ const [state, setState] = useState({ status: 'loading' });
469
+ const [form, setForm] = useState(null);
470
+ const [keyDraft, setKeyDraft] = useState(null);
471
+ const [busy, setBusy] = useState(false);
472
+ const [message, setMessage] = useState(null);
473
+
474
+ const [recordsConfirm, setRecordsConfirm] = useState(false);
475
+ const [recordsMessage, setRecordsMessage] = useState(null);
476
+
477
+ const load = useCallback(async () => {
478
+ const api = resolveApi();
479
+ if (!api || typeof api.describe !== 'function') {
480
+ setState({
481
+ status: 'error',
482
+ message: `${t('noSettingsApi')} (resolveApi returned ${api === undefined ? 'undefined' : typeof api})`,
483
+ });
484
+ return;
485
+ }
486
+ try {
487
+ const described = unwrap(await api.describe());
488
+ if (!described.ok) {
489
+ setState({ status: 'error', message: described.message });
490
+ return;
491
+ }
492
+ const payload = described.value;
493
+ const views = Array.isArray(payload?.namespaces) ? payload.namespaces : [];
494
+ const view =
495
+ views.find((row) => row.ns === NS) ??
496
+ views.find((row) => row.value && typeof row.value === 'object' && 'apiUrl' in row.value && 'outputFormat' in row.value);
497
+ if (!view) {
498
+ setState({ status: 'missing' });
499
+ return;
500
+ }
501
+ setForm(normalize(view.value));
502
+ setState({
503
+ status: 'ready',
504
+ ns: view.ns,
505
+ revision: view.revision,
506
+ writable: payload?.writable !== false,
507
+ secrets: Array.isArray(view.secrets) ? view.secrets : [],
508
+ });
509
+ } catch (error) {
510
+ setState({ status: 'error', message: String(error?.message ?? error) });
511
+ }
512
+ }, []);
513
+
514
+ useEffect(() => {
515
+ load();
516
+ }, [load]);
517
+
518
+ const patch = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
519
+
520
+ const save = async () => {
521
+ const api = resolveApi();
522
+ if (!api || state.status !== 'ready') return;
523
+ setBusy(true);
524
+ setMessage(null);
525
+ try {
526
+ const ops = FIELDS.filter((field) => field !== 'secret').map((field) => ({
527
+ op: 'set',
528
+ path: [field],
529
+ value: form[field],
530
+ }));
531
+ if (keyDraft) ops.push({ op: 'set', path: ['secret'], value: keyDraft });
532
+ const written = unwrap(await api.mutate(state.ns, ops, state.revision));
533
+ if (!written.ok) {
534
+ setMessage({ kind: 'error', text: written.message.includes('conflict') ? t('conflict') : written.message });
535
+ if (written.message.includes('conflict')) load();
536
+ return;
537
+ }
538
+ const view = written.value;
539
+ setForm(normalize(view?.value ?? form));
540
+ setState((current) => ({
541
+ ...current,
542
+ revision: view?.revision ?? current.revision,
543
+ secrets: Array.isArray(view?.secrets) ? view.secrets : current.secrets,
544
+ }));
545
+ setKeyDraft(null);
546
+ setMessage({ kind: 'ok', text: t('saved') });
547
+ } catch (error) {
548
+ const text = String(error?.message ?? error);
549
+ setMessage({ kind: 'error', text: text.includes('conflict') ? t('conflict') : text });
550
+ if (text.includes('conflict')) load();
551
+ } finally {
552
+ setBusy(false);
553
+ }
554
+ };
555
+
556
+ const clearRecords = async () => {
557
+ setBusy(true);
558
+ setRecordsMessage(null);
559
+ try {
560
+ const response = await fetch(RECORDS_ROUTE, {
561
+ method: 'POST',
562
+ headers: { 'x-dsh-image-generation-action': 'clear-records' },
563
+ });
564
+ const payload = await response.json().catch(() => null);
565
+ if (!response.ok) {
566
+ setRecordsMessage(`${t('clearFailed')}: ${payload?.error ?? response.status}`);
567
+ setRecordsConfirm(false);
568
+ return;
569
+ }
570
+ const cleared = Number(payload?.cleared ?? 0);
571
+ setRecordsMessage(t('clearDone').replace('{n}', String(Number.isFinite(cleared) ? cleared : 0)));
572
+ setRecordsConfirm(false);
573
+ } catch (error) {
574
+ setRecordsMessage(`${t('clearFailed')}: ${String(error?.message ?? error)}`);
575
+ setRecordsConfirm(false);
576
+ } finally {
577
+ setBusy(false);
578
+ }
579
+ };
580
+
581
+ if (state.status === 'loading') {
582
+ return h('div', { style: { fontSize: 13, color: TOKENS.muted } }, t('loading'));
583
+ }
584
+ if (state.status === 'missing') {
585
+ return h('div', { style: { fontSize: 13, color: TOKENS.muted } }, t('missing'));
586
+ }
587
+ if (state.status === 'error') {
588
+ return h('div', { style: { fontSize: 13, color: TOKENS.danger } }, state.message);
589
+ }
590
+
591
+ const keyIsSet = state.secrets.some(
592
+ (secret) => Array.isArray(secret.path) && secret.path[0] === 'secret' && secret.set,
593
+ );
594
+ const compressionLocked = form.outputFormat === 'png';
595
+
596
+ return h(
597
+ 'div',
598
+ { style: { maxWidth: 640, padding: '4px 2px 24px' } },
599
+ h(
600
+ 'div',
601
+ { style: { marginBottom: 16 } },
602
+ h('div', { style: { fontSize: 15, fontWeight: 600, color: TOKENS.text } }, t('title')),
603
+ h('div', { style: hintStyle }, t('subtitle')),
604
+ ),
605
+
606
+ h('div', { style: { fontSize: 13, fontWeight: 600, color: TOKENS.text, margin: '0 0 10px' } }, t('sectionConnection')),
607
+
608
+ h(
609
+ Row,
610
+ { label: t('labelModel'), hint: t('hintModel') },
611
+ h(TextField, { value: form.model, placeholder: 'gpt-image-2.5-flare', onChange: patch('model') }),
612
+ h(
613
+ 'div',
614
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 } },
615
+ BUILTIN_MODELS.map((model) =>
616
+ h(Chip, { key: model, active: form.model === model, onClick: () => patch('model')(model) }, model),
617
+ ),
618
+ ),
619
+ ),
620
+
621
+ h(
622
+ Row,
623
+ { label: t('labelApiUrl'), hint: t('hintApiUrl') },
624
+ h(TextField, { value: form.apiUrl, placeholder: 'https://api.openai.com/v1', onChange: patch('apiUrl') }),
625
+ ),
626
+ h(
627
+ Row,
628
+ { label: t('labelApiKey'), hint: keyIsSet ? t('hintApiKeySet') : t('hintApiKeyEmpty') },
629
+ h(TextField, {
630
+ type: 'password',
631
+ value: keyDraft ?? '',
632
+ placeholder: keyIsSet ? '••••••••' : 'sk-...',
633
+ onChange: setKeyDraft,
634
+ }),
635
+ ),
636
+
637
+ h('div', { style: { fontSize: 13, fontWeight: 600, color: TOKENS.text, margin: '20px 0 10px' } }, t('sectionDefaults')),
638
+ h('div', { style: { ...hintStyle, marginTop: -6, marginBottom: 10 } }, t('hintDefaults')),
639
+
640
+ h(
641
+ Row,
642
+ { label: t('labelCount'), hint: t('hintCount') },
643
+ h(SelectField, {
644
+ value: form.count,
645
+ options: COUNTS,
646
+ onChange: (value) => patch('count')(asNumber(value, 1)),
647
+ }),
648
+ ),
649
+
650
+ h(
651
+ Row,
652
+ { label: t('labelFormat'), hint: t('hintFormat') },
653
+ h(SelectField, { value: form.outputFormat, options: FORMATS, onChange: patch('outputFormat') }),
654
+ h(
655
+ 'div',
656
+ { style: { display: 'flex', alignItems: 'center', gap: 10, marginTop: 8 } },
657
+ h('span', { style: { fontSize: 12, color: TOKENS.muted, width: 92 } }, t('labelCompression')),
658
+ h('input', {
659
+ type: 'range',
660
+ min: 0,
661
+ max: 100,
662
+ value: form.outputCompression,
663
+ disabled: compressionLocked,
664
+ style: { flex: 1, opacity: compressionLocked ? 0.45 : 1 },
665
+ onChange: (event) => patch('outputCompression')(asNumber(event.target.value, 100)),
666
+ }),
667
+ h('span', { style: { fontSize: 12, color: TOKENS.muted, width: 30 } }, String(form.outputCompression)),
668
+ ),
669
+ ),
670
+
671
+ h(
672
+ Row,
673
+ { label: t('labelBackground'), hint: t('hintBackground') },
674
+ h(SelectField, { value: form.background, options: BACKGROUNDS, onChange: patch('background') }),
675
+ ),
676
+
677
+ h(
678
+ Row,
679
+ { label: t('labelModeration'), hint: t('hintModeration') },
680
+ h(SelectField, { value: form.moderation, options: MODERATIONS, onChange: patch('moderation') }),
681
+ ),
682
+
683
+ h(
684
+ Row,
685
+ { label: t('labelUser'), hint: t('hintUser') },
686
+ h(TextField, { value: form.user, onChange: patch('user') }),
687
+ ),
688
+
689
+ h(
690
+ Row,
691
+ { label: t('labelOutputDir'), hint: t('hintOutputDir') },
692
+ h(TextField, { value: form.outputDir, placeholder: t('outputDirPlaceholder'), onChange: patch('outputDir') }),
693
+ ),
694
+
695
+ h(
696
+ Row,
697
+ { label: t('labelFileName'), hint: t('hintFileName') },
698
+ h(TextField, {
699
+ value: form.fileNameFormat,
700
+ placeholder: t('fileNamePlaceholder'),
701
+ onChange: patch('fileNameFormat'),
702
+ }),
703
+ h(
704
+ 'div',
705
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 } },
706
+ NAME_PRESETS.map((preset) =>
707
+ h(
708
+ Chip,
709
+ { key: preset, active: form.fileNameFormat === preset, onClick: () => patch('fileNameFormat')(preset) },
710
+ preset,
711
+ ),
712
+ ),
713
+ ),
714
+ h(
715
+ 'div',
716
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 } },
717
+ NAME_TOKENS.map((token) =>
718
+ h(
719
+ 'button',
720
+ {
721
+ key: token,
722
+ type: 'button',
723
+ onClick: () => patch('fileNameFormat')(`${form.fileNameFormat ?? ''}${token}`),
724
+ style: {
725
+ padding: '2px 8px',
726
+ borderRadius: 6,
727
+ border: `1px dashed ${TOKENS.border}`,
728
+ background: 'transparent',
729
+ color: TOKENS.muted,
730
+ cursor: 'pointer',
731
+ fontSize: 12,
732
+ font: 'inherit',
733
+ },
734
+ },
735
+ token,
736
+ ),
737
+ ),
738
+ ),
739
+ ),
740
+
741
+ h(
742
+ Row,
743
+ { label: t('labelResolution'), hint: t('hintResolution') },
744
+ h(TextField, {
745
+ value: form.defaultResolution === 'auto' ? '' : form.defaultResolution,
746
+ placeholder: t('resolutionPlaceholder'),
747
+ onChange: (value) => patch('defaultResolution')(value.trim().toLowerCase() || 'auto'),
748
+ }),
749
+ h(
750
+ 'div',
751
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 } },
752
+ RESOLUTION_PRESETS.map((preset) =>
753
+ h(
754
+ Chip,
755
+ {
756
+ key: preset,
757
+ active: form.defaultResolution === preset,
758
+ onClick: () => patch('defaultResolution')(preset),
759
+ },
760
+ preset === 'auto' ? t('resolutionAuto') : preset,
761
+ ),
762
+ ),
763
+ ),
764
+ resolutionNotice(form.defaultResolution, form.maxEdge, t),
765
+ ),
766
+
767
+ h(
768
+ Row,
769
+ { label: t('labelMaxEdge'), hint: t('hintMaxEdge') },
770
+ h(TextField, {
771
+ type: 'number',
772
+ value: form.maxEdge,
773
+ onChange: (value) => patch('maxEdge')(asNumber(value, 3840)),
774
+ }),
775
+ ),
776
+
777
+ h(
778
+ Row,
779
+ { label: t('labelCompatibility'), hint: t('hintCompatibility') },
780
+ h(CheckField, {
781
+ label: t('toggleModeration'),
782
+ value: form.sendsModeration,
783
+ onChange: patch('sendsModeration'),
784
+ }),
785
+ h(CheckField, {
786
+ label: t('toggleCompression'),
787
+ value: form.sendsOutputCompression,
788
+ onChange: patch('sendsOutputCompression'),
789
+ }),
790
+ ),
791
+
792
+ h(
793
+ Row,
794
+ { label: t('labelTimeout'), hint: t('hintTimeout') },
795
+ h(TextField, {
796
+ type: 'number',
797
+ value: form.requestTimeoutSec,
798
+ onChange: (value) => patch('requestTimeoutSec')(asNumber(value, 600)),
799
+ }),
800
+ ),
801
+
802
+ h(
803
+ 'div',
804
+ { style: { display: 'flex', alignItems: 'center', gap: 12, marginTop: 18 } },
805
+ h(Button, { primary: true, disabled: busy || !state.writable, onClick: save }, busy ? t('saving') : t('save')),
806
+ h(Button, { disabled: busy, onClick: load }, t('reload')),
807
+ message
808
+ ? h(
809
+ 'span',
810
+ { style: { fontSize: 12, color: message.kind === 'ok' ? TOKENS.muted : TOKENS.danger } },
811
+ message.text,
812
+ )
813
+ : null,
814
+ ),
815
+
816
+ h(
817
+ 'div',
818
+ { style: { marginTop: 22, paddingTop: 16, borderTop: `1px solid ${TOKENS.border}` } },
819
+ h('div', { style: { fontSize: 13, fontWeight: 600, color: TOKENS.text, marginBottom: 8 } }, t('sectionMaintenance')),
820
+ recordsConfirm
821
+ ? h(
822
+ 'div',
823
+ {
824
+ style: {
825
+ border: `1px solid ${TOKENS.danger}`,
826
+ borderRadius: 6,
827
+ padding: '10px 12px',
828
+ fontSize: 12,
829
+ lineHeight: 1.6,
830
+ color: TOKENS.text,
831
+ },
832
+ },
833
+ h('div', { style: { fontWeight: 600, color: TOKENS.danger, marginBottom: 4 } }, t('clearWarnTitle')),
834
+ h('div', { style: { color: TOKENS.muted, marginBottom: 10 } }, t('clearWarnBody')),
835
+ h(
836
+ 'div',
837
+ { style: { display: 'flex', alignItems: 'center', gap: 10 } },
838
+ h(Button, { danger: true, disabled: busy, onClick: clearRecords }, t('clearConfirm')),
839
+ h(Button, { disabled: busy, onClick: () => setRecordsConfirm(false) }, t('clearCancel')),
840
+ ),
841
+ )
842
+ : h(
843
+ 'div',
844
+ { style: { display: 'flex', alignItems: 'center', gap: 12 } },
845
+ h(Button, { disabled: busy, onClick: () => { setRecordsMessage(null); setRecordsConfirm(true); } }, t('clearButton')),
846
+ recordsMessage
847
+ ? h('span', { style: { fontSize: 12, color: TOKENS.muted } }, recordsMessage)
848
+ : h('span', { style: hintStyle }, t('clearHint')),
849
+ ),
850
+ ),
851
+ state.writable ? null : h('div', { style: { ...hintStyle, marginTop: 10 } }, t('readOnly')),
852
+ );
853
+ };
854
+ }
855
+
856
+ function readImages(meta) {
857
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
858
+ if (!Array.isArray(meta.images)) return null;
859
+ const usable = [];
860
+ for (const image of meta.images) {
861
+ if (!image || typeof image !== 'object') continue;
862
+ let url;
863
+ try {
864
+ url = image.url;
865
+ } catch {
866
+ continue;
867
+ }
868
+
869
+ if (typeof url !== 'string' || url.length === 0) continue;
870
+ let path = '';
871
+ try {
872
+ path = typeof image.path === 'string' ? image.path : '';
873
+ } catch {
874
+ path = '';
875
+ }
876
+ usable.push({ url, path });
877
+ }
878
+ return usable;
879
+ }
880
+
881
+ function parseArgs(raw) {
882
+ if (typeof raw !== 'string') return null;
883
+ try {
884
+ const parsed = JSON.parse(raw);
885
+ return parsed && typeof parsed === 'object' ? parsed : null;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+
891
+ function parsePartialArgs(raw) {
892
+ if (typeof raw !== 'string' || raw.length === 0) return null;
893
+ const salvaged = {};
894
+ for (const field of ['prompt', 'resolution', 'quality', 'background', 'location']) {
895
+ const pattern = new RegExp(String.raw`"` + field + String.raw`"\s*:\s*"((?:[^"\\]|\\.)*)"?`);
896
+ const match = pattern.exec(raw);
897
+ if (!match) continue;
898
+ try {
899
+ salvaged[field] = JSON.parse(`"${match[1]}"`);
900
+ } catch {
901
+ salvaged[field] = match[1];
902
+ }
903
+ }
904
+ const number = /"count"\s*:\s*(\d+)/.exec(raw);
905
+ if (number) salvaged.count = Number(number[1]);
906
+ return Object.keys(salvaged).length > 0 ? salvaged : null;
907
+ }
908
+
909
+ function renderDisclosure(label, expanded, onToggle, body) {
910
+ return h(
911
+ 'div',
912
+ { style: { marginTop: 8 } },
913
+ h(
914
+ 'button',
915
+ {
916
+ type: 'button',
917
+ 'aria-expanded': expanded ? 'true' : 'false',
918
+ onClick: onToggle,
919
+ style: {
920
+ display: 'flex',
921
+ alignItems: 'center',
922
+ gap: 6,
923
+ width: '100%',
924
+ padding: '4px 0',
925
+ border: 'none',
926
+ background: 'transparent',
927
+ color: TOKENS.muted,
928
+ cursor: 'pointer',
929
+ font: 'inherit',
930
+ fontSize: 12,
931
+ textAlign: 'left',
932
+ },
933
+ },
934
+ h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', transform: expanded ? 'rotate(90deg)' : 'none' } }, '▶'),
935
+ label,
936
+ ),
937
+ expanded ? body : null,
938
+ );
939
+ }
940
+
941
+ const PANE_STYLE = {
942
+ marginTop: 6,
943
+ padding: '8px 10px',
944
+ border: `1px solid ${TOKENS.border}`,
945
+ borderRadius: 6,
946
+ background: TOKENS.input,
947
+ color: TOKENS.text,
948
+ fontSize: 12,
949
+ lineHeight: 1.6,
950
+ };
951
+
952
+ const LIVE_PROMPT_LINES = 5;
953
+ const LIVE_PROMPT_LINE_HEIGHT = 20;
954
+
955
+ const LIVE_PROMPT_MAX_HEIGHT = LIVE_PROMPT_LINES * LIVE_PROMPT_LINE_HEIGHT + 16;
956
+ const LIVE_PROMPT_STYLE = {
957
+ ...PANE_STYLE,
958
+ fontSize: 12,
959
+ lineHeight: `${LIVE_PROMPT_LINE_HEIGHT}px`,
960
+ maxHeight: `${LIVE_PROMPT_MAX_HEIGHT}px`,
961
+ overflow: 'hidden',
962
+ whiteSpace: 'pre-wrap',
963
+ overflowWrap: 'anywhere',
964
+ };
965
+ const LIVE_PROMPT_CUT_STYLE = {
966
+ ...LIVE_PROMPT_STYLE,
967
+ // No line-clamp: the cut already appends an ellipsis, and the clamp would draw a second.
968
+ };
969
+
970
+ function promptPane(prompt, prefix) {
971
+ return h(
972
+ 'div',
973
+ {
974
+ key: `${prefix}-prompt`,
975
+ style: { ...PANE_STYLE, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: 260, overflowY: 'auto' },
976
+ },
977
+ prompt,
978
+ );
979
+ }
980
+
981
+ function measureLineCut(element, maxHeight) {
982
+ const doc = element?.ownerDocument;
983
+ if (!doc?.createRange || typeof doc.createTreeWalker !== 'function' || !element.firstChild) return null;
984
+ // Read once: inside the loop this forced a layout per character.
985
+ const paneTop = element.getBoundingClientRect().top;
986
+ const walker = doc.createTreeWalker(element, 4);
987
+ const range = doc.createRange();
988
+ let consumed = 0;
989
+ let whole = '';
990
+ let node = walker.nextNode();
991
+ while (node) {
992
+ const value = typeof node.nodeValue === 'string' ? node.nodeValue : '';
993
+ whole += value;
994
+ for (let index = 0; index < value.length; index += 1) {
995
+ range.setStart(node, index);
996
+ range.setEnd(node, index + 1);
997
+ const box = range.getBoundingClientRect();
998
+
999
+ if (box.height === 0 && box.width === 0) {
1000
+ consumed += 1;
1001
+ continue;
1002
+ }
1003
+
1004
+ if (box.bottom - paneTop >= maxHeight) {
1005
+ return { text: whole.slice(0, consumed), truncated: true };
1006
+ }
1007
+ consumed += 1;
1008
+ }
1009
+ node = walker.nextNode();
1010
+ }
1011
+ return { text: whole, truncated: false };
1012
+ }
1013
+
1014
+ function LivePrompt(props) {
1015
+ const { prompt, label, lines, lineHeight, style, cutStyle } = props;
1016
+ const [measured, setMeasured] = useState(null);
1017
+ const ref = React.useRef(null);
1018
+
1019
+ // Dropping the dependency array runs this every commit and makes the state update
1020
+ // unbounded (React error #185).
1021
+ useLayoutEffect(() => {
1022
+ const measure = () => {
1023
+ const found = measureLineCut(ref.current, lines * lineHeight);
1024
+ if (!found) return;
1025
+ setMeasured((current) => {
1026
+ if (current && current.prompt === prompt && current.text === found.text && current.truncated === found.truncated) {
1027
+ return current;
1028
+ }
1029
+ return { prompt, text: found.text, truncated: found.truncated };
1030
+ });
1031
+ };
1032
+ measure();
1033
+ if (typeof window !== 'undefined' && window.addEventListener) {
1034
+ window.addEventListener('resize', measure);
1035
+ return () => window.removeEventListener('resize', measure);
1036
+ }
1037
+ return undefined;
1038
+ }, [prompt, lines, lineHeight, measured === null]);
1039
+
1040
+ React.useEffect(() => {
1041
+ setMeasured((current) => (current && current.prompt !== prompt ? null : current));
1042
+ }, [prompt]);
1043
+
1044
+ const cut = measured && measured.prompt === prompt ? measured.text : null;
1045
+ const shown = cut === null ? prompt : cut;
1046
+ const truncated = Boolean(measured && measured.prompt === prompt && measured.truncated);
1047
+ const paneStyle = cut === null ? style : cutStyle;
1048
+
1049
+ return h(
1050
+ 'div',
1051
+ { key: 'live-prompt', style: { marginTop: 8 } },
1052
+ h('div', { style: { fontSize: 12, color: TOKENS.muted, marginBottom: 4 } }, label),
1053
+ h('div', { ref, style: paneStyle }, shown, truncated ? h('span', { 'data-live-prompt-cut': 'true' }, '…') : null),
1054
+ );
1055
+ }
1056
+
1057
+ function renderPromptDisclosure(prompt, expanded, onToggle, label) {
1058
+ if (typeof prompt !== 'string' || prompt.length === 0) return null;
1059
+ return renderDisclosure(`${label} (${prompt.length})`, expanded, onToggle, promptPane(prompt, 'collapsed'));
1060
+ }
1061
+
1062
+ function renderLivePrompt(prompt, t) {
1063
+ if (typeof prompt !== 'string' || prompt.length === 0) return null;
1064
+ return h(LivePrompt, {
1065
+ key: 'live-prompt',
1066
+ prompt,
1067
+
1068
+ label: `${t('livePrompt')} (${t('receivedChars').replace('{n}', String(prompt.length))})`,
1069
+ lines: LIVE_PROMPT_LINES,
1070
+ lineHeight: LIVE_PROMPT_LINE_HEIGHT,
1071
+ style: LIVE_PROMPT_STYLE,
1072
+ cutStyle: LIVE_PROMPT_CUT_STYLE,
1073
+ });
1074
+ }
1075
+
1076
+ function renderParamsDisclosure(args, images, expanded, onToggle, t) {
1077
+ const hasArgs = args && typeof args === 'object';
1078
+ const rows = [];
1079
+ const add = (key, value) => {
1080
+ if (value !== undefined && value !== null && value !== '') rows.push(h('div', { key }, `${t(key)}: ${String(value)}`));
1081
+ };
1082
+ if (hasArgs) {
1083
+ add('paramResolution', args.resolution ?? 'auto');
1084
+ add('paramQuality', args.quality);
1085
+ add('paramCount', args.count);
1086
+ add('paramBackground', args.background);
1087
+ add('paramLocation', args.location);
1088
+ if (Array.isArray(args.images)) add('paramImages', args.images.length);
1089
+ add('paramMask', args.mask);
1090
+ }
1091
+
1092
+ if (images && images.length > 0) add('paramFiles', images.length);
1093
+ if (rows.length === 0) return null;
1094
+ return renderDisclosure(
1095
+ `${t('expandParams')} (${rows.length})`,
1096
+ expanded,
1097
+ onToggle,
1098
+ h('div', { style: PANE_STYLE }, rows),
1099
+ );
1100
+ }
1101
+
1102
+ const usableDisclosure = (candidate) =>
1103
+ candidate && typeof candidate.expanded === 'boolean' && typeof candidate.toggle === 'function' ? candidate : null;
1104
+
1105
+ function makeToolCardFace() {
1106
+ return { hooks: { toolCallArgumentsPartial: bindToolCallArgumentsPartial } };
1107
+ }
1108
+
1109
+ function bindToolCallArgumentsPartial(_standard, context) {
1110
+ const assistant = context?.assistant;
1111
+ const callId = context?.callId;
1112
+ const subscribe = (listener) => (assistant === undefined ? () => {} : assistant.subscribe(listener));
1113
+ const getSnapshot = () => {
1114
+
1115
+ try {
1116
+ if (assistant === undefined) return '';
1117
+ const blocks = assistant.getSnapshot()?.blocks;
1118
+ if (!Array.isArray(blocks)) return '';
1119
+ const found = blocks.find((candidate) => candidate.kind === 'tool-call' && candidate.callId === callId);
1120
+ return found && typeof found.argsRaw === 'string' ? found.argsRaw : '';
1121
+ } catch {
1122
+ return '';
1123
+ }
1124
+ };
1125
+ return function useToolCallArgumentsPartial() {
1126
+ return useSyncExternalStore(subscribe, getSnapshot);
1127
+ };
1128
+ }
1129
+
1130
+ function ToolCallView(props) {
1131
+ const t = useLanguage();
1132
+
1133
+ const safeProps = props && typeof props === 'object' ? props : {};
1134
+ const block = safeProps.block && typeof safeProps.block === 'object' ? safeProps.block : {};
1135
+ const [fallbackPromptOpen, setFallbackPromptOpen] = useState(false);
1136
+ const [fallbackParamsOpen, setFallbackParamsOpen] = useState(false);
1137
+
1138
+ let promptHarness = null;
1139
+ let paramsHarness = null;
1140
+ try {
1141
+ promptHarness = usableDisclosure(typeof safeProps.useDisclosure === 'function' ? safeProps.useDisclosure() : null);
1142
+ } catch {
1143
+ promptHarness = null;
1144
+ }
1145
+ try {
1146
+ paramsHarness = usableDisclosure(typeof safeProps.useDisclosure === 'function' ? safeProps.useDisclosure() : null);
1147
+ } catch {
1148
+ paramsHarness = null;
1149
+ }
1150
+
1151
+ let livePartial = null;
1152
+ try {
1153
+ livePartial = typeof safeProps.useToolCallArgumentsPartial === 'function' ? safeProps.useToolCallArgumentsPartial() : null;
1154
+ } catch {
1155
+ livePartial = null;
1156
+ }
1157
+
1158
+ const settled = safeProps.phase === 'result';
1159
+ const running = !settled;
1160
+ const liveArgs = typeof livePartial === 'string' && livePartial.length > 0 ? livePartial : null;
1161
+ const carriedArgs = typeof block.argsRaw === 'string' && block.argsRaw.length > 0 ? block.argsRaw : null;
1162
+
1163
+ const rawArgs = settled ? (block.call?.argsRaw ?? carriedArgs) : (liveArgs ?? carriedArgs);
1164
+ const args = parseArgs(rawArgs) ?? parsePartialArgs(rawArgs);
1165
+ const images = settled ? readImages(block.meta) : null;
1166
+ const prompt = typeof args?.prompt === 'string' ? args.prompt : '';
1167
+
1168
+ const promptOpen = promptHarness ? promptHarness.expanded : fallbackPromptOpen;
1169
+ const paramsOpen = paramsHarness ? paramsHarness.expanded : fallbackParamsOpen;
1170
+ const togglePrompt = () => {
1171
+ if (promptHarness) promptHarness.toggle();
1172
+ else setFallbackPromptOpen((value) => !value);
1173
+ };
1174
+ const toggleParams = () => {
1175
+ if (paramsHarness) paramsHarness.toggle();
1176
+ else setFallbackParamsOpen((value) => !value);
1177
+ };
1178
+
1179
+ return h(
1180
+ 'div',
1181
+ {
1182
+ style: {
1183
+ border: `1px solid ${TOKENS.border}`,
1184
+ borderRadius: 8,
1185
+ padding: 12,
1186
+ margin: '6px 0',
1187
+ background: TOKENS.surface,
1188
+ },
1189
+ },
1190
+ h(
1191
+ 'div',
1192
+ { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: images?.length ? 10 : 6 } },
1193
+ h('span', { style: { fontSize: 13, fontWeight: 600, color: TOKENS.text } }, t('cardTitle')),
1194
+ h(
1195
+ 'span',
1196
+ { style: { fontSize: 12, color: TOKENS.muted } },
1197
+ settled ? `${images?.length ?? 0} ${t('images')}` : t('running'),
1198
+ ),
1199
+ ),
1200
+ images?.length
1201
+ ? h(
1202
+ 'div',
1203
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 10 } },
1204
+ images.map((image, index) =>
1205
+ h(
1206
+ 'a',
1207
+ {
1208
+ key: `${image.url}-${index}`,
1209
+ href: image.url,
1210
+ target: '_blank',
1211
+ rel: 'noreferrer',
1212
+ title: image.path ?? '',
1213
+
1214
+ style: { display: 'block', lineHeight: 0, width: 260, height: 260, overflow: 'hidden', borderRadius: 6 },
1215
+ },
1216
+ h('img', {
1217
+ src: image.url,
1218
+ alt: t('imageAlt'),
1219
+ style: {
1220
+ width: '100%',
1221
+ height: '100%',
1222
+ objectFit: 'contain',
1223
+ borderRadius: 6,
1224
+ border: `1px solid ${TOKENS.border}`,
1225
+ display: 'block',
1226
+ },
1227
+ }),
1228
+ ),
1229
+ ),
1230
+ )
1231
+ : null,
1232
+
1233
+ running && prompt
1234
+ ? renderLivePrompt(prompt, t)
1235
+ : renderPromptDisclosure(prompt, promptOpen, togglePrompt, t('expandPrompt')),
1236
+ renderParamsDisclosure(args, images, paramsOpen, toggleParams, t),
1237
+ );
1238
+ }
1239
+
1240
+ class ToolCardBoundary extends React.Component {
1241
+ constructor(props) {
1242
+ super(props);
1243
+ this.state = { failure: null };
1244
+ }
1245
+
1246
+ static getDerivedStateFromError(error) {
1247
+ return { failure: error };
1248
+ }
1249
+
1250
+ render() {
1251
+ if (this.state.failure) {
1252
+ const chinese = localeReader() === 'zh-CN';
1253
+ const reason = String(this.state.failure?.message ?? this.state.failure);
1254
+ return h(
1255
+ 'div',
1256
+ {
1257
+ style: {
1258
+ border: `1px solid ${TOKENS.border}`,
1259
+ borderRadius: 8,
1260
+ padding: 12,
1261
+ margin: '6px 0',
1262
+ background: TOKENS.surface,
1263
+ fontSize: 12,
1264
+ color: TOKENS.muted,
1265
+ },
1266
+ },
1267
+ h('div', { style: { fontWeight: 600, color: TOKENS.text, marginBottom: 4 } }, chinese ? ZH.cardTitle : EN.cardTitle),
1268
+ `${chinese ? ZH.cardFailed : EN.cardFailed}: ${reason}`,
1269
+ );
1270
+ }
1271
+ return h(ToolCallView, this.props);
1272
+ }
1273
+ }
1274
+
1275
+ return {
1276
+
1277
+ inject: ['slots', 'locale', 'remote', 'remote.settings'],
1278
+ apply(ctx) {
1279
+ const localeService = ctx.locale;
1280
+ localeReader = () => activeLocale(localeService);
1281
+ localeStore.source = localeService;
1282
+ ctx.effect(
1283
+ () => localeService.register(LOCALE_NS, { zh: ZH, en: EN }),
1284
+ 'image-generation: copy dictionaries',
1285
+ );
1286
+ ctx.effect(
1287
+ () =>
1288
+ localeService.subscribe(() => {
1289
+ for (const listener of [...localeStore.listeners]) listener();
1290
+ }),
1291
+ 'image-generation: follow the locale',
1292
+ );
1293
+
1294
+ const resolveApi = () => {
1295
+ try {
1296
+ return ctx.remote?.settings ?? ctx.get('remote.settings');
1297
+ } catch {
1298
+ return undefined;
1299
+ }
1300
+ };
1301
+
1302
+ ctx.slots.inject('settings.section', () =>
1303
+ ctx.slots.register(
1304
+ { name: 'settings.section', id: SECTION_ID, order: 13, label: () => t('section') },
1305
+ makeSettingsSection(resolveApi, localeService),
1306
+ ),
1307
+ );
1308
+
1309
+ ctx.slots.inject('tool.call.toolview', () =>
1310
+ ctx.slots.register(
1311
+ {
1312
+ name: 'tool.call.toolview',
1313
+ key: 'generate_image',
1314
+
1315
+ inject: makeToolCardFace,
1316
+ },
1317
+ ToolCardBoundary,
1318
+ ),
1319
+ );
1320
+ },
1321
+ };
1322
+ },
1323
+ });