@dickpy/dsh-imagegen 1.3.0 → 1.4.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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -182
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +1103 -837
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +265 -135
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -418
  10. package/src/client/ImageGenPanel.tsx +1699 -1508
  11. package/src/client/SettingsCard.tsx +936 -957
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -263
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +169 -158
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -594
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -1023
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -298
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -478
  31. package/src/gallery-store.ts +286 -286
  32. package/src/generation-runtime.ts +79 -75
  33. package/src/history-store.ts +250 -244
  34. package/src/image-format.ts +11 -11
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -318
  37. package/src/model-catalog.ts +115 -98
  38. package/src/presets.ts +71 -63
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -326
  41. package/src/routes.ts +916 -906
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
@@ -1,263 +1,263 @@
1
- /**
2
- * Staged form model for the channel list of the settings card. Mirrors the
3
- * CardForm staging pattern (dirty → one save) but for a structured value, so
4
- * the card can edit N channels, per-channel keys, and the default-channel
5
- * flag, then persist everything in one revision-fenced mutate call.
6
- *
7
- * Storage rules (dictated by dsh-settings semantics):
8
- * - the whole `channels` array is written wholesale via `path: ['channels']`;
9
- * - every channel's API key lives at `channelSecrets.<channelId>` (a secret
10
- * dict), written per-key so untouched keys are never clobbered by a save
11
- * the reader could not see (keys are redacted out of the wire view);
12
- * - path ops never navigate *inside* the channels array.
13
- */
14
-
15
- import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
16
- import type { ChannelConfig, ModelMapping } from '../protocol.ts'
17
- import type { ImageGenScope, SettingsOp } from './settings-scope.ts'
18
-
19
- /** One channel as the editor stages it (secrets never travel here). */
20
- export interface ChannelDraft {
21
- id: string
22
- preset: string
23
- name: string
24
- apiUrl: string
25
- models: ModelMapping[]
26
- }
27
-
28
- /** One staged key edit for a channel. */
29
- export type KeyEdit = { kind: 'set'; value: string } | { kind: 'clear' }
30
-
31
- /** The state the card renders. */
32
- export interface ChannelsFormState {
33
- /** Staged channel list (the scope value when nothing is staged). */
34
- channels: ChannelDraft[]
35
- /** Which channels currently hold a stored secret (staged edits included). */
36
- keySet: Record<string, boolean>
37
- /** The effective default channel id. */
38
- defaultChannelId: string
39
- /** Whether a save would write anything. */
40
- dirty: boolean
41
- /** Whether the document accepts writes. */
42
- writable: boolean
43
- /** Whether a save is crossing the wire. */
44
- saving: boolean
45
- /** Whether the last save failed (cleared by the next edit or save). */
46
- failed: boolean
47
- }
48
-
49
- /** The actions the card's slot entry injects. */
50
- export interface ChannelsFormActions {
51
- /** Replace the whole channel list (add/edit/remove go through here). */
52
- setChannels: (channels: ChannelDraft[]) => void
53
- /** Stage a key for one channel ('' clears; undefined = no staged change). */
54
- setChannelKey: (id: string, value: string | undefined) => void
55
- /** Stage the default-channel flag. */
56
- setDefaultChannel: (id: string) => void
57
- /** Write every staged edit, then re-seed from what the Host accepted. */
58
- commit: () => Promise<void>
59
- /** Drop every staged edit. */
60
- discard: () => void
61
- }
62
-
63
- /** Deep equality over JSON-compatible data (the change predicate). */
64
- function deepEqualJson(a: unknown, b: unknown): boolean {
65
- if (a === b) return true
66
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
67
- if (Array.isArray(a) || Array.isArray(b)) {
68
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
69
- return a.every((entry, index) => deepEqualJson(entry, b[index]))
70
- }
71
- const left = a as Record<string, unknown>
72
- const right = b as Record<string, unknown>
73
- const keys = Object.keys(left)
74
- if (keys.length !== Object.keys(right).length) return false
75
- return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
76
- }
77
-
78
- /** Trim and normalize a draft channel (models never carry empty aliases). */
79
- function stripChannel(channel: ChannelDraft): ChannelDraft {
80
- const models = channel.models
81
- .map(model => ({ alias: model.alias.trim(), id: model.id.trim() === '' ? model.alias.trim() : model.id.trim() }))
82
- .filter(model => model.alias !== '')
83
- return {
84
- id: channel.id,
85
- preset: channel.preset,
86
- name: channel.name.trim(),
87
- apiUrl: channel.apiUrl.trim(),
88
- models: [...new Map(models.map(model => [model.alias, model])).values()],
89
- }
90
- }
91
-
92
- export class ChannelsForm {
93
- private stagedChannels: ChannelDraft[] | null = null
94
- private readonly stagedKeys = new Map<string, KeyEdit>()
95
- private stagedDefault: string | null = null
96
- private readonly listeners = new Set<() => void>()
97
- private saving = false
98
- private failed = false
99
-
100
- constructor(private readonly scope: ImageGenScope) {
101
- scope.subscribe(() => { this.publish() })
102
- scope.subscribeSecretSets(() => { this.publish() })
103
- }
104
-
105
- /** Publish a projection of this form, rebuilt on every scope or draft change. */
106
- bind<S>(project: () => S): SnapshotStore<S> {
107
- const store = createSnapshotStore(project())
108
- this.listeners.add(() => { store.set(project()) })
109
- return store
110
- }
111
-
112
- /** Subscribe to staged and persisted channel changes. */
113
- subscribe(listener: () => void): () => void {
114
- this.listeners.add(listener)
115
- return () => { this.listeners.delete(listener) }
116
- }
117
-
118
- /** The staged channel list, or the scope value when nothing is staged. */
119
- private channelsValue(): ChannelDraft[] {
120
- const view = this.scope.getSnapshot().value as { channels?: ChannelConfig[] } | undefined
121
- return this.stagedChannels ?? (Array.isArray(view?.channels) ? view.channels.map(toDraft) : [])
122
- }
123
-
124
- /** Whether a channel currently holds a stored or staged secret. */
125
- private keyHeld(id: string): boolean {
126
- const edit = this.stagedKeys.get(id)
127
- if (edit !== undefined) return edit.kind === 'set' && edit.value !== ''
128
- return this.scope.getSecretSetSnapshot(`channelSecrets.${id}`)
129
- }
130
-
131
- private defaultValue(): string {
132
- if (this.stagedDefault !== null) return this.stagedDefault
133
- const view = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
134
- const channels = this.channelsValue()
135
- if (view?.defaultChannelId !== undefined && channels.some(channel => channel.id === view.defaultChannelId)) return view.defaultChannelId
136
- return channels[0]?.id ?? ''
137
- }
138
-
139
- private dirtyValue(): boolean {
140
- const channels = this.channelsValue()
141
- const stagedChanged = this.stagedChannels !== null && !deepEqualJson(this.stagedChannels, scopeChannelsOf(this.scope))
142
- const scopeView = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
143
- const scopeDefault = scopeView?.defaultChannelId ?? channels[0]?.id ?? ''
144
- const defaultChanged = this.stagedDefault !== null && this.stagedDefault !== scopeDefault
145
- return stagedChanged || defaultChanged || this.stagedKeys.size > 0
146
- }
147
-
148
- /** The card-facing snapshot. */
149
- snapshot(): ChannelsFormState {
150
- const channels = this.channelsValue()
151
- const keySet: Record<string, boolean> = {}
152
- for (const channel of channels) keySet[channel.id] = this.keyHeld(channel.id)
153
- return {
154
- channels,
155
- keySet,
156
- defaultChannelId: this.defaultValue(),
157
- dirty: this.dirtyValue(),
158
- writable: this.scope.getSnapshot().writable !== false,
159
- saving: this.saving,
160
- failed: this.failed,
161
- }
162
- }
163
-
164
- /** The actions the card's slot registration injects. */
165
- actions(): ChannelsFormActions {
166
- return {
167
- setChannels: (channels) => { this.stageChannels(channels) },
168
- setChannelKey: (id, value) => { this.stageKey(id, value) },
169
- setDefaultChannel: (id) => { this.stagedDefault = id; this.failed = false; this.publish() },
170
- commit: () => this.commit(),
171
- discard: () => {
172
- if (this.stagedChannels === null && this.stagedKeys.size === 0 && this.stagedDefault === null && !this.failed) return
173
- this.stagedChannels = null
174
- this.stagedKeys.clear()
175
- this.stagedDefault = null
176
- this.failed = false
177
- this.publish()
178
- },
179
- }
180
- }
181
-
182
- // ------------------------------------------------------------------ staging
183
-
184
- private stageChannels(channels: ChannelDraft[]): void {
185
- const cleaned = channels.map(stripChannel)
186
- this.stagedChannels = cleaned
187
- this.failed = false
188
- this.publish()
189
- }
190
-
191
- private stageKey(id: string, value: string | undefined): void {
192
- if (value === undefined || value.trim() === '') {
193
- if (this.keyHeld(id)) this.stagedKeys.set(id, { kind: 'clear' })
194
- // An empty key for a key-less channel stages nothing.
195
- } else {
196
- this.stagedKeys.set(id, { kind: 'set', value: value.trim() })
197
- }
198
- this.failed = false
199
- this.publish()
200
- }
201
-
202
- // ----------------------------------------------------------------- save
203
-
204
- /** Build the single batch of path ops a save performs. */
205
- private planOps(): SettingsOp[] {
206
- const ops: SettingsOp[] = []
207
- if (this.stagedChannels !== null) {
208
- ops.push({ op: 'set', path: ['channels'], value: this.stagedChannels })
209
- // Once channels exist, the legacy flat fields are obsolete (idempotent).
210
- ops.push({ op: 'unset', path: ['apiUrl'] })
211
- ops.push({ op: 'unset', path: ['apiKey'] })
212
- ops.push({ op: 'unset', path: ['imageModels'] })
213
- }
214
- for (const [id, edit] of this.stagedKeys) {
215
- if (edit.kind === 'set') ops.push({ op: 'set', path: ['channelSecrets', id], value: edit.value })
216
- else ops.push({ op: 'unset', path: ['channelSecrets', id] })
217
- }
218
- if (this.stagedDefault !== null) {
219
- ops.push({ op: 'set', path: ['defaultChannelId'], value: this.stagedDefault })
220
- }
221
- return ops
222
- }
223
-
224
- /**
225
- * Write every staged edit, then re-seed from what the Host accepted.
226
- * @returns settlement after the write settles.
227
- */
228
- async commit(): Promise<void> {
229
- if (this.saving) return
230
- const ops = this.planOps()
231
- if (ops.length === 0) return
232
- this.saving = true
233
- this.failed = false
234
- this.publish()
235
- try {
236
- await this.scope.mutateOps(ops)
237
- this.stagedChannels = null
238
- this.stagedKeys.clear()
239
- this.stagedDefault = null
240
- this.failed = false
241
- } catch {
242
- this.failed = true
243
- } finally {
244
- this.saving = false
245
- this.publish()
246
- }
247
- }
248
-
249
- private publish(): void {
250
- for (const listener of [...this.listeners]) listener()
251
- }
252
- }
253
-
254
- /** Project a stored channel into a draft (secrets never travel in channels). */
255
- function toDraft(channel: ChannelConfig): ChannelDraft {
256
- return { id: channel.id, preset: channel.preset, name: channel.name, apiUrl: channel.apiUrl, models: channel.models.map(model => ({ ...model })) }
257
- }
258
-
259
- /** The scope's current channels value (a plain array), for change detection. */
260
- function scopeChannelsOf(scope: ImageGenScope): unknown {
261
- const view = scope.getSnapshot().value as { channels?: unknown } | undefined
262
- return Array.isArray(view?.channels) ? view.channels : []
263
- }
1
+ /**
2
+ * Staged form model for the channel list of the settings card. Mirrors the
3
+ * CardForm staging pattern (dirty → one save) but for a structured value, so
4
+ * the card can edit N channels, per-channel keys, and the default-channel
5
+ * flag, then persist everything in one revision-fenced mutate call.
6
+ *
7
+ * Storage rules (dictated by dsh-settings semantics):
8
+ * - the whole `channels` array is written wholesale via `path: ['channels']`;
9
+ * - every channel's API key lives at `channelSecrets.<channelId>` (a secret
10
+ * dict), written per-key so untouched keys are never clobbered by a save
11
+ * the reader could not see (keys are redacted out of the wire view);
12
+ * - path ops never navigate *inside* the channels array.
13
+ */
14
+
15
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
16
+ import type { ChannelConfig, ModelMapping } from '../protocol.ts'
17
+ import type { ImageGenScope, SettingsOp } from './settings-scope.ts'
18
+
19
+ /** One channel as the editor stages it (secrets never travel here). */
20
+ export interface ChannelDraft {
21
+ id: string
22
+ preset: string
23
+ name: string
24
+ apiUrl: string
25
+ models: ModelMapping[]
26
+ }
27
+
28
+ /** One staged key edit for a channel. */
29
+ export type KeyEdit = { kind: 'set'; value: string } | { kind: 'clear' }
30
+
31
+ /** The state the card renders. */
32
+ export interface ChannelsFormState {
33
+ /** Staged channel list (the scope value when nothing is staged). */
34
+ channels: ChannelDraft[]
35
+ /** Which channels currently hold a stored secret (staged edits included). */
36
+ keySet: Record<string, boolean>
37
+ /** The effective default channel id. */
38
+ defaultChannelId: string
39
+ /** Whether a save would write anything. */
40
+ dirty: boolean
41
+ /** Whether the document accepts writes. */
42
+ writable: boolean
43
+ /** Whether a save is crossing the wire. */
44
+ saving: boolean
45
+ /** Whether the last save failed (cleared by the next edit or save). */
46
+ failed: boolean
47
+ }
48
+
49
+ /** The actions the card's slot entry injects. */
50
+ export interface ChannelsFormActions {
51
+ /** Replace the whole channel list (add/edit/remove go through here). */
52
+ setChannels: (channels: ChannelDraft[]) => void
53
+ /** Stage a key for one channel ('' clears; undefined = no staged change). */
54
+ setChannelKey: (id: string, value: string | undefined) => void
55
+ /** Stage the default-channel flag. */
56
+ setDefaultChannel: (id: string) => void
57
+ /** Write every staged edit, then re-seed from what the Host accepted. */
58
+ commit: () => Promise<void>
59
+ /** Drop every staged edit. */
60
+ discard: () => void
61
+ }
62
+
63
+ /** Deep equality over JSON-compatible data (the change predicate). */
64
+ function deepEqualJson(a: unknown, b: unknown): boolean {
65
+ if (a === b) return true
66
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
67
+ if (Array.isArray(a) || Array.isArray(b)) {
68
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
69
+ return a.every((entry, index) => deepEqualJson(entry, b[index]))
70
+ }
71
+ const left = a as Record<string, unknown>
72
+ const right = b as Record<string, unknown>
73
+ const keys = Object.keys(left)
74
+ if (keys.length !== Object.keys(right).length) return false
75
+ return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
76
+ }
77
+
78
+ /** Trim and normalize a draft channel (models never carry empty aliases). */
79
+ function stripChannel(channel: ChannelDraft): ChannelDraft {
80
+ const models = channel.models
81
+ .map(model => ({ alias: model.alias.trim(), id: model.id.trim() === '' ? model.alias.trim() : model.id.trim() }))
82
+ .filter(model => model.alias !== '')
83
+ return {
84
+ id: channel.id,
85
+ preset: channel.preset,
86
+ name: channel.name.trim(),
87
+ apiUrl: channel.apiUrl.trim(),
88
+ models: [...new Map(models.map(model => [model.alias, model])).values()],
89
+ }
90
+ }
91
+
92
+ export class ChannelsForm {
93
+ private stagedChannels: ChannelDraft[] | null = null
94
+ private readonly stagedKeys = new Map<string, KeyEdit>()
95
+ private stagedDefault: string | null = null
96
+ private readonly listeners = new Set<() => void>()
97
+ private saving = false
98
+ private failed = false
99
+
100
+ constructor(private readonly scope: ImageGenScope) {
101
+ scope.subscribe(() => { this.publish() })
102
+ scope.subscribeSecretSets(() => { this.publish() })
103
+ }
104
+
105
+ /** Publish a projection of this form, rebuilt on every scope or draft change. */
106
+ bind<S>(project: () => S): SnapshotStore<S> {
107
+ const store = createSnapshotStore(project())
108
+ this.listeners.add(() => { store.set(project()) })
109
+ return store
110
+ }
111
+
112
+ /** Subscribe to staged and persisted channel changes. */
113
+ subscribe(listener: () => void): () => void {
114
+ this.listeners.add(listener)
115
+ return () => { this.listeners.delete(listener) }
116
+ }
117
+
118
+ /** The staged channel list, or the scope value when nothing is staged. */
119
+ private channelsValue(): ChannelDraft[] {
120
+ const view = this.scope.getSnapshot().value as { channels?: ChannelConfig[] } | undefined
121
+ return this.stagedChannels ?? (Array.isArray(view?.channels) ? view.channels.map(toDraft) : [])
122
+ }
123
+
124
+ /** Whether a channel currently holds a stored or staged secret. */
125
+ private keyHeld(id: string): boolean {
126
+ const edit = this.stagedKeys.get(id)
127
+ if (edit !== undefined) return edit.kind === 'set' && edit.value !== ''
128
+ return this.scope.getSecretSetSnapshot(`channelSecrets.${id}`)
129
+ }
130
+
131
+ private defaultValue(): string {
132
+ if (this.stagedDefault !== null) return this.stagedDefault
133
+ const view = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
134
+ const channels = this.channelsValue()
135
+ if (view?.defaultChannelId !== undefined && channels.some(channel => channel.id === view.defaultChannelId)) return view.defaultChannelId
136
+ return channels[0]?.id ?? ''
137
+ }
138
+
139
+ private dirtyValue(): boolean {
140
+ const channels = this.channelsValue()
141
+ const stagedChanged = this.stagedChannels !== null && !deepEqualJson(this.stagedChannels, scopeChannelsOf(this.scope))
142
+ const scopeView = this.scope.getSnapshot().value as { defaultChannelId?: string } | undefined
143
+ const scopeDefault = scopeView?.defaultChannelId ?? channels[0]?.id ?? ''
144
+ const defaultChanged = this.stagedDefault !== null && this.stagedDefault !== scopeDefault
145
+ return stagedChanged || defaultChanged || this.stagedKeys.size > 0
146
+ }
147
+
148
+ /** The card-facing snapshot. */
149
+ snapshot(): ChannelsFormState {
150
+ const channels = this.channelsValue()
151
+ const keySet: Record<string, boolean> = {}
152
+ for (const channel of channels) keySet[channel.id] = this.keyHeld(channel.id)
153
+ return {
154
+ channels,
155
+ keySet,
156
+ defaultChannelId: this.defaultValue(),
157
+ dirty: this.dirtyValue(),
158
+ writable: this.scope.getSnapshot().writable !== false,
159
+ saving: this.saving,
160
+ failed: this.failed,
161
+ }
162
+ }
163
+
164
+ /** The actions the card's slot registration injects. */
165
+ actions(): ChannelsFormActions {
166
+ return {
167
+ setChannels: (channels) => { this.stageChannels(channels) },
168
+ setChannelKey: (id, value) => { this.stageKey(id, value) },
169
+ setDefaultChannel: (id) => { this.stagedDefault = id; this.failed = false; this.publish() },
170
+ commit: () => this.commit(),
171
+ discard: () => {
172
+ if (this.stagedChannels === null && this.stagedKeys.size === 0 && this.stagedDefault === null && !this.failed) return
173
+ this.stagedChannels = null
174
+ this.stagedKeys.clear()
175
+ this.stagedDefault = null
176
+ this.failed = false
177
+ this.publish()
178
+ },
179
+ }
180
+ }
181
+
182
+ // ------------------------------------------------------------------ staging
183
+
184
+ private stageChannels(channels: ChannelDraft[]): void {
185
+ const cleaned = channels.map(stripChannel)
186
+ this.stagedChannels = cleaned
187
+ this.failed = false
188
+ this.publish()
189
+ }
190
+
191
+ private stageKey(id: string, value: string | undefined): void {
192
+ if (value === undefined || value.trim() === '') {
193
+ if (this.keyHeld(id)) this.stagedKeys.set(id, { kind: 'clear' })
194
+ // An empty key for a key-less channel stages nothing.
195
+ } else {
196
+ this.stagedKeys.set(id, { kind: 'set', value: value.trim() })
197
+ }
198
+ this.failed = false
199
+ this.publish()
200
+ }
201
+
202
+ // ----------------------------------------------------------------- save
203
+
204
+ /** Build the single batch of path ops a save performs. */
205
+ private planOps(): SettingsOp[] {
206
+ const ops: SettingsOp[] = []
207
+ if (this.stagedChannels !== null) {
208
+ ops.push({ op: 'set', path: ['channels'], value: this.stagedChannels })
209
+ // Once channels exist, the legacy flat fields are obsolete (idempotent).
210
+ ops.push({ op: 'unset', path: ['apiUrl'] })
211
+ ops.push({ op: 'unset', path: ['apiKey'] })
212
+ ops.push({ op: 'unset', path: ['imageModels'] })
213
+ }
214
+ for (const [id, edit] of this.stagedKeys) {
215
+ if (edit.kind === 'set') ops.push({ op: 'set', path: ['channelSecrets', id], value: edit.value })
216
+ else ops.push({ op: 'unset', path: ['channelSecrets', id] })
217
+ }
218
+ if (this.stagedDefault !== null) {
219
+ ops.push({ op: 'set', path: ['defaultChannelId'], value: this.stagedDefault })
220
+ }
221
+ return ops
222
+ }
223
+
224
+ /**
225
+ * Write every staged edit, then re-seed from what the Host accepted.
226
+ * @returns settlement after the write settles.
227
+ */
228
+ async commit(): Promise<void> {
229
+ if (this.saving) return
230
+ const ops = this.planOps()
231
+ if (ops.length === 0) return
232
+ this.saving = true
233
+ this.failed = false
234
+ this.publish()
235
+ try {
236
+ await this.scope.mutateOps(ops)
237
+ this.stagedChannels = null
238
+ this.stagedKeys.clear()
239
+ this.stagedDefault = null
240
+ this.failed = false
241
+ } catch {
242
+ this.failed = true
243
+ } finally {
244
+ this.saving = false
245
+ this.publish()
246
+ }
247
+ }
248
+
249
+ private publish(): void {
250
+ for (const listener of [...this.listeners]) listener()
251
+ }
252
+ }
253
+
254
+ /** Project a stored channel into a draft (secrets never travel in channels). */
255
+ function toDraft(channel: ChannelConfig): ChannelDraft {
256
+ return { id: channel.id, preset: channel.preset, name: channel.name, apiUrl: channel.apiUrl, models: channel.models.map(model => ({ ...model })) }
257
+ }
258
+
259
+ /** The scope's current channels value (a plain array), for change detection. */
260
+ function scopeChannelsOf(scope: ImageGenScope): unknown {
261
+ const view = scope.getSnapshot().value as { channels?: unknown } | undefined
262
+ return Array.isArray(view?.channels) ? view.channels : []
263
+ }
@@ -1,46 +1,46 @@
1
- /**
2
- * Image-gen panel controller: the single owner of the panel's open/closed
3
- * state. Framework-free so the DOM mounts and the React panel share one tiny
4
- * subscription surface. The state lives only for the browser session.
5
- */
6
-
7
- /** Immutable controller snapshot for UI subscriptions. */
8
- export interface ImageGenControllerSnapshot {
9
- panelOpen: boolean
10
- }
11
-
12
- /** The panel state owner the sidebar entry toggles and the view renders from. */
13
- export class ImageGenController {
14
- private panelOpen = false
15
- private listeners = new Set<() => void>()
16
-
17
- getSnapshot(): ImageGenControllerSnapshot {
18
- return { panelOpen: this.panelOpen }
19
- }
20
-
21
- subscribe(fn: () => void): () => void {
22
- this.listeners.add(fn)
23
- return () => { this.listeners.delete(fn) }
24
- }
25
-
26
- open(): void {
27
- if (this.panelOpen) return
28
- this.panelOpen = true
29
- this.notify()
30
- }
31
-
32
- close(): void {
33
- if (!this.panelOpen) return
34
- this.panelOpen = false
35
- this.notify()
36
- }
37
-
38
- toggle(): void {
39
- if (this.panelOpen) this.close()
40
- else this.open()
41
- }
42
-
43
- private notify(): void {
44
- for (const fn of [...this.listeners]) fn()
45
- }
46
- }
1
+ /**
2
+ * Image-gen panel controller: the single owner of the panel's open/closed
3
+ * state. Framework-free so the DOM mounts and the React panel share one tiny
4
+ * subscription surface. The state lives only for the browser session.
5
+ */
6
+
7
+ /** Immutable controller snapshot for UI subscriptions. */
8
+ export interface ImageGenControllerSnapshot {
9
+ panelOpen: boolean
10
+ }
11
+
12
+ /** The panel state owner the sidebar entry toggles and the view renders from. */
13
+ export class ImageGenController {
14
+ private panelOpen = false
15
+ private listeners = new Set<() => void>()
16
+
17
+ getSnapshot(): ImageGenControllerSnapshot {
18
+ return { panelOpen: this.panelOpen }
19
+ }
20
+
21
+ subscribe(fn: () => void): () => void {
22
+ this.listeners.add(fn)
23
+ return () => { this.listeners.delete(fn) }
24
+ }
25
+
26
+ open(): void {
27
+ if (this.panelOpen) return
28
+ this.panelOpen = true
29
+ this.notify()
30
+ }
31
+
32
+ close(): void {
33
+ if (!this.panelOpen) return
34
+ this.panelOpen = false
35
+ this.notify()
36
+ }
37
+
38
+ toggle(): void {
39
+ if (this.panelOpen) this.close()
40
+ else this.open()
41
+ }
42
+
43
+ private notify(): void {
44
+ for (const fn of [...this.listeners]) fn()
45
+ }
46
+ }
@@ -0,0 +1,14 @@
1
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
2
+ import type { ConversationController, IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
3
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
4
+
5
+ /** Document event used to bridge chat tool results into the image workspace. */
6
+ export const CHAT_IMAGE_EVENT = 'dsh-imagegen:chat-images'
7
+
8
+ export interface ChatImageEventDetail {
9
+ sessionId: SessionId
10
+ refs: readonly ImageAttachmentRef[]
11
+ }
12
+
13
+ /** Composer-facing extension of the narrower public conversation interface. */
14
+ export type ConversationService = IConversation & Pick<ConversationController, 'createDraftImages' | 'releaseDraftImages'>
@@ -1,5 +1,5 @@
1
- /** CSS Modules type shim (the bundle inlines the compiled class map). */
2
- declare module '*.module.css' {
3
- const classes: Record<string, string>
4
- export default classes
5
- }
1
+ /** CSS Modules type shim (the bundle inlines the compiled class map). */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }