@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.
- package/LICENSE +201 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- 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
|
+
}
|
package/src/client/controller.ts
CHANGED
|
@@ -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
|
+
}
|