@dsh-plus/image-studio 0.1.0 → 0.1.1

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/lib/index.d.ts CHANGED
@@ -161,6 +161,7 @@ interface PresetsWire {
161
161
  //#endregion
162
162
  //#region src/service.d.ts
163
163
  declare class ImageStudioService extends Service {
164
+ static [Context.inject]: string[];
164
165
  private current;
165
166
  private readonly runner;
166
167
  private readonly log;
@@ -184,7 +185,7 @@ declare class ImageStudioService extends Service {
184
185
  /** set/unset 凭据(端点写通道)。 */
185
186
  setCredential(presetId: string, value: string): Promise<void>;
186
187
  unsetCredential(presetId: string): Promise<void>;
187
- /** credentials seam 可选探测(缺席返回 null)。 */
188
+ /** credentials seam(inject 硬声明保证时序,见类声明;缺席即组合缺服务)。 */
188
189
  private credentialsSeam;
189
190
  private requireCredentials;
190
191
  /** 提交生图任务:校验 → 入队 → 返回 taskId。 */
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { Service } from "@deepseek-ai/cordis";
2
+ import { Context, Service } from "@deepseek-ai/cordis";
3
3
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
4
  import { request } from "node:http";
5
5
  import { request as request$1 } from "node:https";
@@ -1007,6 +1007,7 @@ function defaultIdCounter() {
1007
1007
  */
1008
1008
  /** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
1009
1009
  var ImageStudioService = class extends Service {
1010
+ static [Context.inject] = ["credentials"];
1010
1011
  current;
1011
1012
  runner;
1012
1013
  log;
@@ -1070,7 +1071,7 @@ var ImageStudioService = class extends Service {
1070
1071
  async unsetCredential(presetId) {
1071
1072
  await (await this.requireCredentials()).unset(credentialRef(credentialRefNameOf(presetId)));
1072
1073
  }
1073
- /** credentials seam 可选探测(缺席返回 null)。 */
1074
+ /** credentials seam(inject 硬声明保证时序,见类声明;缺席即组合缺服务)。 */
1074
1075
  async credentialsSeam() {
1075
1076
  const found = this.ctx.get?.("credentials");
1076
1077
  return found === void 0 || found === null ? null : found;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/image-studio",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "dsh-plus service+ui plugin: 图像工作室——OpenAI Images 协议文生图/图生图,全参数开关化,双预设体系,并发生图任务与可二次编辑画廊",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -19,11 +19,12 @@
19
19
  "src"
20
20
  ],
21
21
  "dependencies": {
22
- "https-proxy-agent": "^9.1.0",
23
- "@dsh-plus/shared": "0.1.11"
22
+ "@dsh-plus/shared": "0.1.12",
23
+ "https-proxy-agent": "^9.1.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@deepseek-ai/cordis": "^4.0.2",
27
+ "@deepseek-ai/dsh-client-ui-primitives": "*",
27
28
  "@deepseek-ai/dsh-credentials": "*",
28
29
  "@deepseek-ai/dsh-host-webserver": "*",
29
30
  "@deepseek-ai/dsh-settings": "*",
@@ -31,6 +32,7 @@
31
32
  },
32
33
  "devDependencies": {
33
34
  "@deepseek-ai/cordis": "4.0.2",
35
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-alpha.1",
34
36
  "@deepseek-ai/dsh-credentials": "0.1.5-alpha.1",
35
37
  "@deepseek-ai/dsh-host-webserver": "0.1.5-alpha.1",
36
38
  "@deepseek-ai/dsh-session": "0.1.5-alpha.1",
@@ -1,14 +1,584 @@
1
1
  /**
2
- * 配置卡片占位组件:本次交付为空壳(不做前端设计与编码)。
3
- * 后续版本在此渲染三组预设编辑与凭据管理;keyed 槽位契约已声明。
2
+ * 配置卡片(settings.plugin.item,key = settings 命名空间):
3
+ * 三组预设(提供商/参数/提示词)+ 高级项(并发/超时/代理/画廊上限)的
4
+ * staged draft 编辑;提供商预设逐条附 API Key 管理(凭据端点,值不回显)。
5
+ * JSON 字段(extraHeaders/paramSpecs)以文本镜像编辑,保存时解析 +
6
+ * validateParamSpecs 本地校验,非法即拦在保存前。
4
7
  * @module image-studio/client/card
5
8
  */
9
+ import {
10
+ CardChrome,
11
+ type CardStatusState,
12
+ IDLE_STATUS,
13
+ type NamespaceSettingsApi,
14
+ type Scope,
15
+ TextField,
16
+ } from '@dsh-plus/shared/client'
17
+ import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
6
18
 
7
- /** 占位组件。 */
8
- export function StudioConfigCard(props: { t?: (key: string) => string }): unknown {
9
- const t = props.t ?? ((key: string) => key)
10
- const el = document.createElement('div')
11
- el.className = 'dsh-plus-image-studio-card-placeholder'
12
- el.textContent = t('cardPlaceholder')
13
- return { type: el, props: {} }
19
+ import type { ImageStudioConfig } from '../config.ts'
20
+ import { credentialRefNameOf, isValidPresetId } from '../credentials.ts'
21
+ import { validateParamSpecs } from '../params/spec.ts'
22
+ import { fetchCredentialStatus, fetchProviders, setCredential, unsetCredential } from './api.ts'
23
+ import type { Translate } from './i18n.ts'
24
+
25
+ export interface CardProps {
26
+ t: Translate
27
+ scope: Scope
28
+ api: NamespaceSettingsApi
29
+ }
30
+
31
+ /** 编辑态草稿:JSON 字段与数值以文本承载(保存时解析校验)。 */
32
+ interface ProviderDraft {
33
+ id: string
34
+ name: string
35
+ protocol: string
36
+ baseUrl: string
37
+ model: string
38
+ extraHeadersText: string
39
+ }
40
+
41
+ interface ParamDraft {
42
+ id: string
43
+ name: string
44
+ endpoint: string
45
+ specsText: string
46
+ }
47
+
48
+ interface PromptDraft {
49
+ id: string
50
+ name: string
51
+ text: string
52
+ }
53
+
54
+ interface Draft {
55
+ providers: ProviderDraft[]
56
+ params: ParamDraft[]
57
+ prompts: PromptDraft[]
58
+ maxConcurrent: string
59
+ requestTimeoutMs: string
60
+ proxy: string
61
+ galleryMaxItems: string
62
+ }
63
+
64
+ /** 配置值 → 编辑草稿。 */
65
+ function draftOf(value: ImageStudioConfig): Draft {
66
+ return {
67
+ providers: value.providerPresets.map((p) => ({
68
+ id: p.id,
69
+ name: p.name,
70
+ protocol: p.protocol,
71
+ baseUrl: p.baseUrl,
72
+ model: p.model,
73
+ extraHeadersText: JSON.stringify(p.extraHeaders ?? {}, null, 2),
74
+ })),
75
+ params: value.paramPresets.map((p) => ({
76
+ id: p.id,
77
+ name: p.name,
78
+ endpoint: p.endpoint,
79
+ specsText: JSON.stringify(p.paramSpecs ?? {}, null, 2),
80
+ })),
81
+ prompts: value.promptPresets.map((p) => ({ id: p.id, name: p.name, text: p.text })),
82
+ maxConcurrent: String(value.maxConcurrent),
83
+ requestTimeoutMs: String(value.requestTimeoutMs),
84
+ proxy: value.proxy,
85
+ galleryMaxItems: String(value.galleryMaxItems),
86
+ }
87
+ }
88
+
89
+ function parseJson(text: string, label: string): unknown {
90
+ try {
91
+ return JSON.parse(text)
92
+ } catch (error) {
93
+ throw new Error(`${label} ${error instanceof Error ? error.message : String(error)}`)
94
+ }
95
+ }
96
+
97
+ /** 草稿 → settings 载荷(边界校验:JSON 解析、参数表校验、预设 id 形态)。 */
98
+ function payloadOf(draft: Draft): Record<string, unknown> {
99
+ const providers = draft.providers.map((p) => {
100
+ if (!isValidPresetId(p.id)) throw new Error(p.id)
101
+ return {
102
+ id: p.id,
103
+ name: p.name,
104
+ protocol: p.protocol,
105
+ baseUrl: p.baseUrl,
106
+ model: p.model,
107
+ credentialRef: credentialRefNameOf(p.id),
108
+ extraHeaders: parseJson(p.extraHeadersText, p.id) as Record<string, string>,
109
+ }
110
+ })
111
+ const params = draft.params.map((p) => ({
112
+ id: p.id,
113
+ name: p.name,
114
+ endpoint: p.endpoint,
115
+ paramSpecs: validateParamSpecs(parseJson(p.specsText, p.id)),
116
+ }))
117
+ return {
118
+ promptPresets: draft.prompts.map((p) => ({ id: p.id, name: p.name, text: p.text })),
119
+ paramPresets: params,
120
+ providerPresets: providers,
121
+ maxConcurrent: Math.max(0, Math.floor(Number(draft.maxConcurrent) || 0)),
122
+ requestTimeoutMs: Math.max(10_000, Math.floor(Number(draft.requestTimeoutMs) || 300_000)),
123
+ proxy: draft.proxy,
124
+ galleryMaxItems: Math.max(0, Math.floor(Number(draft.galleryMaxItems) || 0)),
125
+ }
126
+ }
127
+
128
+ /** 单个提供商预设的 API Key 行(describe 徽标 + 设置/删除,值不回显)。 */
129
+ function CredentialRow(props: { t: Translate; presetId: string; disabled: boolean }): ReactElement {
130
+ const { t, presetId, disabled } = props
131
+ const [configured, setConfigured] = useState<boolean | null>(null)
132
+ const [value, setValue] = useState('')
133
+ const [busy, setBusy] = useState(false)
134
+
135
+ useEffect(() => {
136
+ if (!isValidPresetId(presetId)) return
137
+ let alive = true
138
+ fetchCredentialStatus(presetId)
139
+ .then((res) => {
140
+ if (alive) setConfigured(res.configured)
141
+ })
142
+ .catch(() => {})
143
+ return () => {
144
+ alive = false
145
+ }
146
+ }, [presetId])
147
+
148
+ const act = (fn: () => Promise<unknown>): void => {
149
+ setBusy(true)
150
+ fn()
151
+ .then(() => fetchCredentialStatus(presetId))
152
+ .then((res) => {
153
+ setConfigured(res.configured)
154
+ setValue('')
155
+ })
156
+ .catch(() => {})
157
+ .finally(() => setBusy(false))
158
+ }
159
+
160
+ return (
161
+ <div className="imsc-credRow">
162
+ <input
163
+ className="imsc-input"
164
+ type="password"
165
+ autoComplete="off"
166
+ placeholder={t('card.credential.placeholder')}
167
+ aria-label={t('card.credential.label')}
168
+ value={value}
169
+ disabled={disabled || busy}
170
+ onChange={(event) => setValue(event.target.value)}
171
+ />
172
+ {configured !== null ? (
173
+ <span className={`imsc-badge ${configured ? 'imsc-badgeSet' : 'imsc-badgeUnset'}`}>
174
+ {t(configured ? 'card.credential.configured' : 'card.credential.unconfigured')}
175
+ </span>
176
+ ) : null}
177
+ <button
178
+ type="button"
179
+ className="imsc-btn imsc-btnGhost"
180
+ disabled={disabled || busy || value === '' || !isValidPresetId(presetId)}
181
+ onClick={() => act(() => setCredential(presetId, value))}
182
+ >
183
+ {t('card.credential.set')}
184
+ </button>
185
+ <button
186
+ type="button"
187
+ className="imsc-btn imsc-btnGhost"
188
+ disabled={disabled || busy || configured !== true || !isValidPresetId(presetId)}
189
+ onClick={() => act(() => unsetCredential(presetId))}
190
+ >
191
+ {t('card.credential.unset')}
192
+ </button>
193
+ </div>
194
+ )
195
+ }
196
+
197
+ export function StudioConfigCard(props: CardProps): ReactElement | null {
198
+ const { t, scope, api } = props
199
+ const snapshot = useSyncExternalStore(
200
+ (listener: () => void) => scope.subscribe(listener),
201
+ () => scope.getSnapshot(),
202
+ )
203
+ const value = snapshot.value as ImageStudioConfig | undefined
204
+ const [open, setOpen] = useState(false)
205
+ const [draft, setDraft] = useState<Draft | null>(null)
206
+ const [baseline, setBaseline] = useState('')
207
+ const [saving, setSaving] = useState(false)
208
+ const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
209
+ const [protocols, setProtocols] = useState<Array<{ id: string; label: string }>>([])
210
+
211
+ useEffect(() => {
212
+ fetchProviders()
213
+ .then((res) => setProtocols(res.protocols.map((p) => ({ id: p.id, label: p.label }))))
214
+ .catch(() => {})
215
+ }, [])
216
+
217
+ useEffect(() => {
218
+ if (value === undefined || draft !== null) return
219
+ const initial = draftOf(value)
220
+ setDraft(initial)
221
+ setBaseline(JSON.stringify(initial))
222
+ }, [value, draft])
223
+
224
+ const dirty = useMemo(
225
+ () => draft !== null && JSON.stringify(draft) !== baseline,
226
+ [draft, baseline],
227
+ )
228
+
229
+ if (value === undefined || draft === null) {
230
+ return (
231
+ <li className="imsc-card">
232
+ <p className="imsc-loading">{t('common.loading')}</p>
233
+ </li>
234
+ )
235
+ }
236
+
237
+ const disabled = !snapshot.writable
238
+
239
+ const onSave = (): void => {
240
+ let payload: Record<string, unknown>
241
+ try {
242
+ payload = payloadOf(draft)
243
+ } catch (error) {
244
+ setStatus({
245
+ kind: 'error',
246
+ text: `${t('card.specsInvalid')}${error instanceof Error ? error.message : String(error)}`,
247
+ })
248
+ return
249
+ }
250
+ setSaving(true)
251
+ api
252
+ .update(payload, snapshot.revision)
253
+ .then(() => scope.load())
254
+ .then(() => {
255
+ setBaseline(JSON.stringify(draft))
256
+ setStatus({ kind: 'ok', text: t('card.saved') })
257
+ })
258
+ .catch((error: unknown) => {
259
+ setStatus({
260
+ kind: 'error',
261
+ text: `${t('card.saveFailed')}${error instanceof Error ? error.message : String(error)}`,
262
+ })
263
+ })
264
+ .finally(() => setSaving(false))
265
+ }
266
+
267
+ const patch = (next: Partial<Draft>): void => {
268
+ setDraft({ ...draft, ...next })
269
+ setStatus(IDLE_STATUS)
270
+ }
271
+ const patchProvider = (index: number, next: Partial<ProviderDraft>): void =>
272
+ patch({ providers: draft.providers.map((p, i) => (i === index ? { ...p, ...next } : p)) })
273
+ const patchParam = (index: number, next: Partial<ParamDraft>): void =>
274
+ patch({ params: draft.params.map((p, i) => (i === index ? { ...p, ...next } : p)) })
275
+ const patchPrompt = (index: number, next: Partial<PromptDraft>): void =>
276
+ patch({ prompts: draft.prompts.map((p, i) => (i === index ? { ...p, ...next } : p)) })
277
+
278
+ return (
279
+ <CardChrome
280
+ prefix="imsc"
281
+ title={t('card.title')}
282
+ description={t('card.description')}
283
+ open={open}
284
+ onToggle={setOpen}
285
+ statusBadge={{
286
+ text:
287
+ draft.providers.length > 0
288
+ ? t('card.badgeOn').replace('{n}', String(draft.providers.length))
289
+ : t('card.badgeOff'),
290
+ on: draft.providers.length > 0,
291
+ }}
292
+ dirty={dirty}
293
+ dirtyLabel={t('common.unsaved')}
294
+ readOnlyNotice={disabled ? t('card.readOnly') : undefined}
295
+ status={status}
296
+ actions={[
297
+ {
298
+ key: 'discard',
299
+ label: t('card.discard'),
300
+ disabled: !dirty || saving,
301
+ onClick: () => {
302
+ const restored = draftOf(value)
303
+ setDraft(restored)
304
+ setBaseline(JSON.stringify(restored))
305
+ setStatus(IDLE_STATUS)
306
+ },
307
+ },
308
+ {
309
+ key: 'save',
310
+ label: t(saving ? 'common.saving' : 'card.save'),
311
+ variant: 'primary',
312
+ disabled: !dirty || saving || disabled,
313
+ onClick: onSave,
314
+ },
315
+ ]}
316
+ >
317
+ <h4 className="imsc-groupTitle">{t('card.providers')}</h4>
318
+ {draft.providers.map((provider, index) => (
319
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id(id 字段本身可编辑),行序即身份
320
+ <div className="imsc-presetBox" key={`provider-${index}`}>
321
+ <div className="imsc-presetHead">
322
+ <span className="imsc-presetName">{provider.name || provider.id || '—'}</span>
323
+ <button
324
+ type="button"
325
+ className="imsc-btn imsc-btnGhost"
326
+ disabled={disabled}
327
+ onClick={() => patch({ providers: draft.providers.filter((_, i) => i !== index) })}
328
+ >
329
+ {t('common.delete')}
330
+ </button>
331
+ </div>
332
+ <div className="imsc-grid2">
333
+ <TextField
334
+ prefix="imsc"
335
+ id={`imsc-pid-${index}`}
336
+ label={t('card.field.id')}
337
+ hint={t('card.idHint')}
338
+ value={provider.id}
339
+ invalid={provider.id !== '' && !isValidPresetId(provider.id)}
340
+ invalidLabel={t('card.idInvalid')}
341
+ disabled={disabled}
342
+ onEdit={(v) => patchProvider(index, { id: v })}
343
+ />
344
+ <TextField
345
+ prefix="imsc"
346
+ id={`imsc-pname-${index}`}
347
+ label={t('card.field.name')}
348
+ value={provider.name}
349
+ disabled={disabled}
350
+ onEdit={(v) => patchProvider(index, { name: v })}
351
+ />
352
+ </div>
353
+ <div className="imsc-grid2">
354
+ <div className="imsc-field">
355
+ <div className="imsc-head">
356
+ <label className="imsc-label" htmlFor={`imsc-pproto-${index}`}>
357
+ {t('card.field.protocol')}
358
+ </label>
359
+ </div>
360
+ <select
361
+ id={`imsc-pproto-${index}`}
362
+ className="imsc-select"
363
+ value={provider.protocol}
364
+ disabled={disabled}
365
+ onChange={(event) => patchProvider(index, { protocol: event.target.value })}
366
+ >
367
+ {(protocols.length > 0
368
+ ? protocols
369
+ : [{ id: provider.protocol, label: provider.protocol }]
370
+ ).map((protocol) => (
371
+ <option key={protocol.id} value={protocol.id}>
372
+ {protocol.label}
373
+ </option>
374
+ ))}
375
+ </select>
376
+ </div>
377
+ <TextField
378
+ prefix="imsc"
379
+ id={`imsc-pmodel-${index}`}
380
+ label={t('card.field.model')}
381
+ value={provider.model}
382
+ disabled={disabled}
383
+ onEdit={(v) => patchProvider(index, { model: v })}
384
+ />
385
+ </div>
386
+ <TextField
387
+ prefix="imsc"
388
+ id={`imsc-purl-${index}`}
389
+ label={t('card.field.baseUrl')}
390
+ value={provider.baseUrl}
391
+ disabled={disabled}
392
+ onEdit={(v) => patchProvider(index, { baseUrl: v })}
393
+ />
394
+ <div className="imsc-field">
395
+ <div className="imsc-head">
396
+ <label className="imsc-label" htmlFor={`imsc-pheaders-${index}`}>
397
+ {t('card.field.extraHeaders')}
398
+ </label>
399
+ </div>
400
+ <textarea
401
+ id={`imsc-pheaders-${index}`}
402
+ className="imsc-textarea"
403
+ value={provider.extraHeadersText}
404
+ disabled={disabled}
405
+ onChange={(event) => patchProvider(index, { extraHeadersText: event.target.value })}
406
+ />
407
+ </div>
408
+ <CredentialRow t={t} presetId={provider.id} disabled={disabled} />
409
+ </div>
410
+ ))}
411
+ <button
412
+ type="button"
413
+ className="imsc-btn imsc-btnGhost"
414
+ disabled={disabled}
415
+ onClick={() =>
416
+ patch({
417
+ providers: [
418
+ ...draft.providers,
419
+ {
420
+ id: '',
421
+ name: '',
422
+ protocol: protocols[0]?.id ?? 'openai-images',
423
+ baseUrl: '',
424
+ model: '',
425
+ extraHeadersText: '{}',
426
+ },
427
+ ],
428
+ })
429
+ }
430
+ >
431
+ {t('card.add')} · {t('card.providers')}
432
+ </button>
433
+
434
+ <h4 className="imsc-groupTitle">{t('card.prompts')}</h4>
435
+ {draft.prompts.map((prompt, index) => (
436
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
437
+ <div className="imsc-presetBox" key={`prompt-${index}`}>
438
+ <div className="imsc-presetHead">
439
+ <input
440
+ className="imsc-input"
441
+ aria-label={t('card.field.name')}
442
+ value={prompt.name}
443
+ disabled={disabled}
444
+ onChange={(event) => patchPrompt(index, { name: event.target.value })}
445
+ />
446
+ <button
447
+ type="button"
448
+ className="imsc-btn imsc-btnGhost"
449
+ disabled={disabled}
450
+ onClick={() => patch({ prompts: draft.prompts.filter((_, i) => i !== index) })}
451
+ >
452
+ {t('common.delete')}
453
+ </button>
454
+ </div>
455
+ <textarea
456
+ className="imsc-textarea"
457
+ aria-label={t('card.field.text')}
458
+ value={prompt.text}
459
+ disabled={disabled}
460
+ onChange={(event) => patchPrompt(index, { text: event.target.value })}
461
+ />
462
+ </div>
463
+ ))}
464
+ <button
465
+ type="button"
466
+ className="imsc-btn imsc-btnGhost"
467
+ disabled={disabled}
468
+ onClick={() =>
469
+ patch({
470
+ prompts: [
471
+ ...draft.prompts,
472
+ { id: `prompt-${Date.now().toString(36)}`, name: '', text: '' },
473
+ ],
474
+ })
475
+ }
476
+ >
477
+ {t('card.add')} · {t('card.prompts')}
478
+ </button>
479
+
480
+ <h4 className="imsc-groupTitle">{t('card.params')}</h4>
481
+ {draft.params.map((param, index) => (
482
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
483
+ <div className="imsc-presetBox" key={`param-${index}`}>
484
+ <div className="imsc-presetHead">
485
+ <input
486
+ className="imsc-input"
487
+ aria-label={t('card.field.name')}
488
+ value={param.name}
489
+ disabled={disabled}
490
+ onChange={(event) => patchParam(index, { name: event.target.value })}
491
+ />
492
+ <select
493
+ className="imsc-select"
494
+ aria-label={t('card.field.endpoint')}
495
+ value={param.endpoint}
496
+ disabled={disabled}
497
+ onChange={(event) => patchParam(index, { endpoint: event.target.value })}
498
+ >
499
+ <option value="generation">{t('tab.generation')}</option>
500
+ <option value="edit">{t('tab.edit')}</option>
501
+ </select>
502
+ <button
503
+ type="button"
504
+ className="imsc-btn imsc-btnGhost"
505
+ disabled={disabled}
506
+ onClick={() => patch({ params: draft.params.filter((_, i) => i !== index) })}
507
+ >
508
+ {t('common.delete')}
509
+ </button>
510
+ </div>
511
+ <textarea
512
+ className="imsc-textarea"
513
+ aria-label={t('card.field.paramSpecs')}
514
+ placeholder='{ "size": { "enabled": true, "value": "1024x1024" } }'
515
+ value={param.specsText}
516
+ disabled={disabled}
517
+ onChange={(event) => patchParam(index, { specsText: event.target.value })}
518
+ />
519
+ </div>
520
+ ))}
521
+ <button
522
+ type="button"
523
+ className="imsc-btn imsc-btnGhost"
524
+ disabled={disabled}
525
+ onClick={() =>
526
+ patch({
527
+ params: [
528
+ ...draft.params,
529
+ {
530
+ id: `param-${Date.now().toString(36)}`,
531
+ name: '',
532
+ endpoint: 'generation',
533
+ specsText: '{}',
534
+ },
535
+ ],
536
+ })
537
+ }
538
+ >
539
+ {t('card.add')} · {t('card.params')}
540
+ </button>
541
+
542
+ <h4 className="imsc-groupTitle">{t('card.advanced')}</h4>
543
+ <div className="imsc-grid2">
544
+ <TextField
545
+ prefix="imsc"
546
+ id="imsc-conc"
547
+ label={t('card.maxConcurrent')}
548
+ numeric
549
+ value={draft.maxConcurrent}
550
+ disabled={disabled}
551
+ onEdit={(v) => patch({ maxConcurrent: v })}
552
+ />
553
+ <TextField
554
+ prefix="imsc"
555
+ id="imsc-timeout"
556
+ label={t('card.requestTimeoutMs')}
557
+ numeric
558
+ value={draft.requestTimeoutMs}
559
+ disabled={disabled}
560
+ onEdit={(v) => patch({ requestTimeoutMs: v })}
561
+ />
562
+ </div>
563
+ <div className="imsc-grid2">
564
+ <TextField
565
+ prefix="imsc"
566
+ id="imsc-proxy"
567
+ label={t('card.proxy')}
568
+ value={draft.proxy}
569
+ disabled={disabled}
570
+ onEdit={(v) => patch({ proxy: v })}
571
+ />
572
+ <TextField
573
+ prefix="imsc"
574
+ id="imsc-gmax"
575
+ label={t('card.galleryMaxItems')}
576
+ numeric
577
+ value={draft.galleryMaxItems}
578
+ disabled={disabled}
579
+ onEdit={(v) => patch({ galleryMaxItems: v })}
580
+ />
581
+ </div>
582
+ </CardChrome>
583
+ )
14
584
  }