@dickpy/dsh-imagegen 1.0.20 → 1.2.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.
@@ -9,17 +9,22 @@
9
9
  import { useState } from 'react'
10
10
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
11
  import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
12
+ import { CardForm, booleanField, secretField, stringListField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
13
  import type { ImageGenScope } from './settings-scope.ts'
14
- import { PLUGIN_VERSION } from '../protocol.ts'
14
+ import { IMAGE_MODEL_API, PROMPT_ENHANCE_API } from '../protocol.ts'
15
15
  import css from './settings-card.module.css'
16
16
 
17
17
  /** The fields this card edits (the namespace's full schema). */
18
18
  export interface ImageGenSettings {
19
19
  enabled?: boolean
20
20
  announceToAgent?: boolean
21
+ allowAgentImageGeneration?: boolean
21
22
  apiUrl?: string
22
23
  apiKey?: string
24
+ imageModels?: string[]
25
+ promptApiUrl?: string
26
+ promptApiKey?: string
27
+ promptModel?: string
23
28
  }
24
29
 
25
30
  /** What the card renders. */
@@ -28,10 +33,15 @@ export interface ImageGenSettingsCardState extends CardShell {
28
33
  enabled: CardFieldState
29
34
  /** System-prompt announcement flag. */
30
35
  announceToAgent: CardFieldState
36
+ allowAgentImageGeneration: CardFieldState
31
37
  /** API base URL. */
32
38
  apiUrl: CardFieldState
33
39
  /** API key draft (the stored value is never rendered). */
34
40
  apiKey: CardFieldState
41
+ imageModels: CardFieldState
42
+ promptApiUrl: CardFieldState
43
+ promptApiKey: CardFieldState
44
+ promptModel: CardFieldState
35
45
  }
36
46
 
37
47
  /** The registration-side face the card's slot entry injects. */
@@ -53,8 +63,13 @@ export class ImageGenSettingsCardController {
53
63
  this.form = new CardForm(scope, [
54
64
  booleanField('enabled'),
55
65
  booleanField('announceToAgent'),
66
+ booleanField('allowAgentImageGeneration'),
56
67
  textField('apiUrl'),
57
68
  secretField('apiKey'),
69
+ stringListField('imageModels'),
70
+ textField('promptApiUrl'),
71
+ secretField('promptApiKey'),
72
+ textField('promptModel'),
58
73
  ], {
59
74
  // The redacted wire view never returns the key; a save's outcome is
60
75
  // judged by the namespace's secrets sidecar instead.
@@ -67,8 +82,13 @@ export class ImageGenSettingsCardController {
67
82
  ...this.form.shell(),
68
83
  enabled: this.form.field('enabled'),
69
84
  announceToAgent: this.form.field('announceToAgent'),
85
+ allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
70
86
  apiUrl: this.form.field('apiUrl'),
71
87
  apiKey: this.form.field('apiKey'),
88
+ imageModels: this.form.field('imageModels'),
89
+ promptApiUrl: this.form.field('promptApiUrl'),
90
+ promptApiKey: this.form.field('promptApiKey'),
91
+ promptModel: this.form.field('promptModel'),
72
92
  }
73
93
  }
74
94
 
@@ -106,6 +126,19 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
106
126
  const state = props.useImageGenSettingsCard(snapshot => snapshot)
107
127
  const keySet = props.useImageGenKeySet(snapshot => snapshot)
108
128
  const [open, setOpen] = useState(false)
129
+ const [promptModels, setPromptModels] = useState<string[]>([])
130
+ const [loadingPromptModels, setLoadingPromptModels] = useState(false)
131
+ const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
132
+ const [imageModelCandidates, setImageModelCandidates] = useState<string[]>([])
133
+ const [loadingImageModels, setLoadingImageModels] = useState(false)
134
+ const [imageModelsError, setImageModelsError] = useState<string | null>(null)
135
+ const [manualModelsOpen, setManualModelsOpen] = useState(false)
136
+ const [manualModel, setManualModel] = useState('')
137
+ const [enhancementOpen, setEnhancementOpen] = useState(false)
138
+ const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
139
+ const [manualPromptModel, setManualPromptModel] = useState('')
140
+ const [promptApiOpen, setPromptApiOpen] = useState(false)
141
+ const [moreOpen, setMoreOpen] = useState(false)
109
142
  if (!state.available) return null
110
143
  const title = t('settings.title')
111
144
  const blocked = !state.dirty || state.invalid || state.saving
@@ -162,10 +195,16 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
162
195
  ? (
163
196
  <div className={css.body}>
164
197
  {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
165
- <div className={css.versionRow}>
166
- <span className={css.versionLabel}>{t('settings.currentVersion')}</span>
167
- <code className={css.versionValue}>v{PLUGIN_VERSION}</code>
168
- </div>
198
+ <ValueField
199
+ id="dsh-imagegen-settings-apiurl"
200
+ label={t('settings.apiUrl')}
201
+ hint={t('settings.apiUrlHint')}
202
+ placeholder="https://api.openai.com/v1"
203
+ {...fieldProps}
204
+ {...state.apiUrl}
205
+ onEdit={(text) => { props.edit('apiUrl', text) }}
206
+ onReset={() => { props.resetField('apiUrl') }}
207
+ />
169
208
  <ValueField
170
209
  id="dsh-imagegen-settings-apikey"
171
210
  label={t('settings.apiKey')}
@@ -181,16 +220,216 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
181
220
  onClear={() => { props.resetField('apiKey') }}
182
221
  canClear={keySet}
183
222
  />
223
+ <div className={css.sectionDivider} />
224
+ <section className={css.modelSection} aria-label={t('settings.imageModelsTitle')}>
225
+ <div className={css.sectionHeader}>
226
+ <div>
227
+ <h3 className={css.sectionTitle}>{t('settings.imageModelsTitle')}</h3>
228
+ <p className={css.sectionHint}>{t('settings.imageModelsHint')}</p>
229
+ </div>
230
+ <button
231
+ type="button"
232
+ className={css.modelFetch}
233
+ disabled={disabled || loadingImageModels}
234
+ onClick={() => {
235
+ setLoadingImageModels(true)
236
+ setImageModelsError(null)
237
+ void fetch(IMAGE_MODEL_API.models, { method: 'POST' })
238
+ .then(async response => {
239
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
240
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
241
+ setImageModelCandidates(body.models ?? [])
242
+ })
243
+ .catch(error => { setImageModelsError(error instanceof Error ? error.message : String(error)) })
244
+ .finally(() => { setLoadingImageModels(false) })
245
+ }}
246
+ >
247
+ {loadingImageModels ? t('settings.imageModelsLoading') : t('settings.imageModelsFetch')}
248
+ </button>
249
+ </div>
250
+ <div className={css.modelSummary}>
251
+ {splitModels(state.imageModels.text).map(model => (
252
+ <span key={model} className={css.modelChip}>
253
+ <span>{model}</span>
254
+ <button
255
+ type="button"
256
+ disabled={disabled}
257
+ aria-label={`${t('settings.removeModel')}: ${model}`}
258
+ onClick={() => { props.edit('imageModels', splitModels(state.imageModels.text).filter(value => value !== model).join('\n')) }}
259
+ >
260
+ ×
261
+ </button>
262
+ </span>
263
+ ))}
264
+ <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualModelsOpen(open => !open) }}>
265
+ {manualModelsOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
266
+ </button>
267
+ </div>
268
+ {manualModelsOpen ? (
269
+ <div className={css.manualModelRow}>
270
+ <input
271
+ className={css.input}
272
+ value={manualModel}
273
+ placeholder={t('settings.addModelPlaceholder')}
274
+ disabled={disabled}
275
+ onChange={event => { setManualModel(event.target.value) }}
276
+ onKeyDown={event => {
277
+ if (event.key !== 'Enter') return
278
+ event.preventDefault()
279
+ const next = manualModel.trim()
280
+ if (next === '') return
281
+ props.edit('imageModels', [...splitModels(state.imageModels.text), next].join('\n'))
282
+ setManualModel('')
283
+ }}
284
+ />
285
+ <button
286
+ type="button"
287
+ className={css.addModel}
288
+ disabled={disabled || manualModel.trim() === ''}
289
+ onClick={() => {
290
+ props.edit('imageModels', [...splitModels(state.imageModels.text), manualModel.trim()].join('\n'))
291
+ setManualModel('')
292
+ }}
293
+ >
294
+ {t('settings.addModelConfirm')}
295
+ </button>
296
+ </div>
297
+ ) : null}
298
+ {imageModelCandidates.length > 0 ? (
299
+ <div className={css.modelCandidateList} role="group" aria-label={t('settings.imageModelsCandidates')}>
300
+ <span className={css.modelCandidateLabel}>{t('settings.imageModelsCandidates')}</span>
301
+ {imageModelCandidates.map(candidate => {
302
+ const selected = splitModels(state.imageModels.text).includes(candidate)
303
+ return (
304
+ <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
305
+ <input
306
+ type="checkbox"
307
+ checked={selected}
308
+ disabled={disabled}
309
+ onChange={() => {
310
+ const selectedModels = splitModels(state.imageModels.text)
311
+ const next = selected ? selectedModels.filter(model => model !== candidate) : [...selectedModels, candidate]
312
+ props.edit('imageModels', next.join('\n'))
313
+ }}
314
+ />
315
+ <span>{candidate}</span>
316
+ </label>
317
+ )
318
+ })}
319
+ </div>
320
+ ) : null}
321
+ {imageModelsError !== null ? <p className={css.failed} role="status">{imageModelsError}</p> : null}
322
+ </section>
323
+ <button
324
+ type="button"
325
+ className={css.disclosure}
326
+ aria-expanded={enhancementOpen}
327
+ onClick={() => { setEnhancementOpen(open => !open) }}
328
+ >
329
+ <span>{t('settings.promptEnhanceTitle')}</span>
330
+ <span>{t('settings.optional')}</span>
331
+ <span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
332
+ </button>
333
+ {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
334
+ <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
335
+ <div className={css.sectionHeader}>
336
+ <div>
337
+ <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
338
+ <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
339
+ </div>
340
+ <button
341
+ type="button"
342
+ className={css.modelFetch}
343
+ disabled={disabled || loadingPromptModels}
344
+ onClick={() => {
345
+ setLoadingPromptModels(true)
346
+ setPromptModelsError(null)
347
+ void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
348
+ .then(async response => {
349
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
350
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
351
+ setPromptModels(body.models ?? [])
352
+ })
353
+ .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
354
+ .finally(() => { setLoadingPromptModels(false) })
355
+ }}
356
+ >
357
+ {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
358
+ </button>
359
+ </div>
360
+ <div className={css.modelSummary}>
361
+ {state.promptModel.text.trim() !== '' ? (
362
+ <span className={css.modelChip}>
363
+ <span>{state.promptModel.text}</span>
364
+ <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
365
+ </span>
366
+ ) : null}
367
+ <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open) }}>
368
+ {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
369
+ </button>
370
+ </div>
371
+ {manualPromptModelOpen ? (
372
+ <div className={css.manualModelRow}>
373
+ <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
374
+ <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>
375
+ {t('settings.addModelConfirm')}
376
+ </button>
377
+ </div>
378
+ ) : null}
379
+ {promptModels.length > 0 ? (
380
+ <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
381
+ <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
382
+ {promptModels.map(candidate => (
383
+ <button
384
+ key={candidate}
385
+ type="button"
386
+ role="radio"
387
+ className={css.modelCandidate}
388
+ aria-checked={state.promptModel.text === candidate}
389
+ data-selected={state.promptModel.text === candidate ? '' : undefined}
390
+ disabled={disabled}
391
+ onClick={() => { props.edit('promptModel', candidate) }}
392
+ >
393
+ {candidate}
394
+ </button>
395
+ ))}
396
+ </div>
397
+ ) : null}
398
+ {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
399
+ <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
400
+ <span>{t('settings.promptApiAdvanced')}</span>
401
+ <span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
402
+ </button>
403
+ {promptApiOpen ? <div className={css.optionalContent}>
184
404
  <ValueField
185
- id="dsh-imagegen-settings-apiurl"
186
- label={t('settings.apiUrl')}
187
- hint={t('settings.apiUrlHint')}
405
+ id="dsh-imagegen-settings-prompt-apiurl"
406
+ label={t('settings.promptApiUrl')}
407
+ hint={t('settings.promptApiUrlHint')}
188
408
  placeholder="https://api.openai.com/v1"
189
409
  {...fieldProps}
190
- {...state.apiUrl}
191
- onEdit={(text) => { props.edit('apiUrl', text) }}
192
- onReset={() => { props.resetField('apiUrl') }}
410
+ {...state.promptApiUrl}
411
+ onEdit={(text) => { props.edit('promptApiUrl', text) }}
412
+ onReset={() => { props.resetField('promptApiUrl') }}
413
+ />
414
+ <ValueField
415
+ id="dsh-imagegen-settings-prompt-apikey"
416
+ label={t('settings.promptApiKey')}
417
+ hint={t('settings.promptApiKeyHint')}
418
+ placeholder="sk-…"
419
+ secret
420
+ {...fieldProps}
421
+ {...state.promptApiKey}
422
+ overridden={false}
423
+ onEdit={(text) => { props.edit('promptApiKey', text) }}
424
+ onReset={() => { props.resetField('promptApiKey') }}
193
425
  />
426
+ </div> : null}
427
+ </section> : null}
428
+ <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
429
+ <span>{t('settings.moreOptions')}</span>
430
+ <span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
431
+ </button>
432
+ {moreOpen ? <div className={css.optionalContent}>
194
433
  <BooleanField
195
434
  id="dsh-imagegen-settings-enabled"
196
435
  label={t('settings.enabled')}
@@ -215,6 +454,19 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
215
454
  onEdit={(text) => { props.edit('announceToAgent', text) }}
216
455
  onReset={() => { props.resetField('announceToAgent') }}
217
456
  />
457
+ <BooleanField
458
+ id="dsh-imagegen-settings-agent-generation"
459
+ label={t('settings.allowAgentImageGeneration')}
460
+ hint={t('settings.allowAgentImageGenerationHint')}
461
+ inheritLabel={t('settings.inherit')}
462
+ onLabel={t('settings.on')}
463
+ offLabel={t('settings.off')}
464
+ {...fieldProps}
465
+ {...state.allowAgentImageGeneration}
466
+ onEdit={(text) => { props.edit('allowAgentImageGeneration', text) }}
467
+ onReset={() => { props.resetField('allowAgentImageGeneration') }}
468
+ />
469
+ </div> : null}
218
470
  <div className={css.footer}>
219
471
  {state.failed ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
220
472
  <button
@@ -281,6 +533,8 @@ function ValueField(props: FieldProps & {
281
533
  onClear?: () => void
282
534
  /** Whether a stored secret exists (enables the clear control). */
283
535
  canClear?: boolean
536
+ /** Render a multiline input for newline-separated configuration values. */
537
+ multiline?: boolean
284
538
  }) {
285
539
  return (
286
540
  <div className={css.field}>
@@ -314,17 +568,29 @@ function ValueField(props: FieldProps & {
314
568
  )
315
569
  : null}
316
570
  </div>
317
- <input
318
- id={props.id}
319
- className={props.invalid ? css.inputInvalid : css.input}
320
- type={props.secret === true ? 'password' : 'text'}
321
- autoComplete={props.secret === true ? 'off' : undefined}
322
- {...props.invalid ? { 'aria-invalid': true } : {}}
323
- value={props.text}
324
- placeholder={props.placeholder ?? ''}
325
- disabled={props.disabled}
326
- onChange={(event) => { props.onEdit(event.target.value) }}
327
- />
571
+ {props.multiline === true ? (
572
+ <textarea
573
+ id={props.id}
574
+ className={props.invalid ? css.textareaInvalid : css.textarea}
575
+ {...props.invalid ? { 'aria-invalid': true } : {}}
576
+ value={props.text}
577
+ placeholder={props.placeholder ?? ''}
578
+ disabled={props.disabled}
579
+ onChange={(event) => { props.onEdit(event.target.value) }}
580
+ />
581
+ ) : (
582
+ <input
583
+ id={props.id}
584
+ className={props.invalid ? css.inputInvalid : css.input}
585
+ type={props.secret === true ? 'password' : 'text'}
586
+ autoComplete={props.secret === true ? 'off' : undefined}
587
+ {...props.invalid ? { 'aria-invalid': true } : {}}
588
+ value={props.text}
589
+ placeholder={props.placeholder ?? ''}
590
+ disabled={props.disabled}
591
+ onChange={(event) => { props.onEdit(event.target.value) }}
592
+ />
593
+ )}
328
594
  <p className={props.invalid ? css.invalid : css.hint}>
329
595
  {props.invalid ? props.invalidLabel : props.hint}
330
596
  </p>
@@ -332,6 +598,10 @@ function ValueField(props: FieldProps & {
332
598
  )
333
599
  }
334
600
 
601
+ function splitModels(value: string): string[] {
602
+ return [...new Set(value.split(/[\n,]/).map(model => model.trim()).filter(Boolean))]
603
+ }
604
+
335
605
  /** A staged boolean field: 继承 / 开 / 关. */
336
606
  function BooleanField(props: FieldProps & {
337
607
  /** Copy for the inherit option. */
package/src/client/api.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * data access path the panel uses — plain fetch, same origin.
4
4
  */
5
5
 
6
- import { GALLERY_API, GENERATE_API, HISTORY_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
6
+ import { GALLERY_API, GENERATE_API, HISTORY_API, PROMPT_ENHANCE_API, TASK_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type GenerationTask, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -73,6 +73,37 @@ export class ImageGenApi {
73
73
  }
74
74
  }
75
75
 
76
+ /** Ask the configured chat model to expand a concise image prompt. */
77
+ async enhancePrompt(prompt: string): Promise<string> {
78
+ const response = await fetch(PROMPT_ENHANCE_API.enhance, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ prompt }),
82
+ })
83
+ const body = await readEnvelope<{ ok: true; prompt: string }>(response)
84
+ return body.prompt
85
+ }
86
+
87
+ async taskSubmit(request: GenerateRequest): Promise<GenerationTask> {
88
+ const response = await fetch(TASK_API.submit, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) })
89
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
90
+ }
91
+
92
+ async taskList(): Promise<GenerationTask[]> {
93
+ const response = await fetch(TASK_API.list, { method: 'POST' })
94
+ return (await readEnvelope<{ ok: true; tasks: GenerationTask[] }>(response)).tasks
95
+ }
96
+
97
+ async taskCancel(id: string): Promise<GenerationTask> {
98
+ const response = await fetch(TASK_API.cancel, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) })
99
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
100
+ }
101
+
102
+ async taskRetry(id: string): Promise<GenerationTask> {
103
+ const response = await fetch(TASK_API.retry, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) })
104
+ return (await readEnvelope<{ ok: true; task: GenerationTask }>(response)).task
105
+ }
106
+
76
107
  /** List the host-persisted history (newest first). */
77
108
  async historyList(): Promise<HistoryEntry[]> {
78
109
  const response = await fetch(HISTORY_API.list, { method: 'POST' })
@@ -135,6 +166,11 @@ export class ImageGenApi {
135
166
  return body.entries
136
167
  }
137
168
 
169
+ async gallerySetTags(id: string, tags: string[]): Promise<HistoryEntry[]> {
170
+ const response = await fetch(GALLERY_API.tags, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, tags }) })
171
+ return (await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)).entries
172
+ }
173
+
138
174
  /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
139
175
  async templatesList(): Promise<TemplateListResult> {
140
176
  const response = await fetch(TEMPLATES_API.list, { method: 'POST' })