@dickpy/dsh-imagegen 1.2.3 → 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 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  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 +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  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 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  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
package/src/client/api.ts CHANGED
@@ -1,193 +1,193 @@
1
- /**
2
- * Browser-side API client for the /api/dsh-imagegen route family. The only
3
- * data access path the panel uses — plain fetch, same origin.
4
- */
5
-
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
-
8
- /** Error carrying the route's JSON error message. */
9
- export class ImageGenApiError extends Error {
10
- /** Stable wire code from the host. */
11
- readonly code: string
12
-
13
- constructor(message: string, code = 'generate-failed') {
14
- super(message)
15
- this.name = 'ImageGenApiError'
16
- this.code = code
17
- }
18
- }
19
-
20
- /** Parse the { ok, ... } envelope or throw an ImageGenApiError. */
21
- async function readEnvelope<T>(response: Response): Promise<T> {
22
- let body: unknown
23
- try {
24
- body = await response.json()
25
- } catch {
26
- throw new ImageGenApiError(`HTTP ${response.status}: invalid JSON response`)
27
- }
28
- if (body === null || typeof body !== 'object') {
29
- throw new ImageGenApiError(`HTTP ${response.status}: malformed response`)
30
- }
31
- const record = body as { ok?: unknown; message?: unknown; code?: unknown }
32
- if (record.ok !== true) {
33
- throw new ImageGenApiError(
34
- typeof record.message === 'string' ? record.message : `HTTP ${response.status}`,
35
- typeof record.code === 'string' ? record.code : 'generate-failed',
36
- )
37
- }
38
- return body as T
39
- }
40
-
41
- /** The browser half's data entry point. */
42
- export class ImageGenApi {
43
- /** Ask the host to check the latest stable GitHub Release. */
44
- async updateCheck(): Promise<UpdateInfo> {
45
- const response = await fetch(UPDATE_API.check, { method: 'POST' })
46
- const body = await readEnvelope<{ ok: true; update: UpdateInfo }>(response)
47
- return body.update
48
- }
49
-
50
- /** Ask the host to install a previously discovered Release. */
51
- async updateApply(version: string): Promise<{ updatedVersion: string; restartRequired: boolean }> {
52
- const response = await fetch(UPDATE_API.apply, {
53
- method: 'POST',
54
- headers: { 'content-type': 'application/json' },
55
- body: JSON.stringify({ version }),
56
- })
57
- const body = await readEnvelope<{ ok: true; updatedVersion: string; restartRequired: boolean }>(response)
58
- return { updatedVersion: body.updatedVersion, restartRequired: body.restartRequired }
59
- }
60
-
61
- /** Forward one generate request to the host proxy. */
62
- async generate(request: GenerateRequest): Promise<GenerateResult> {
63
- const response = await fetch(GENERATE_API, {
64
- method: 'POST',
65
- headers: { 'content-type': 'application/json' },
66
- body: JSON.stringify(request),
67
- })
68
- const body = await readEnvelope<{ ok: true; images: GenerateResult['images']; history?: HistoryEntry[]; historyError?: string }>(response)
69
- return {
70
- images: body.images,
71
- ...body.history === undefined ? {} : { history: body.history },
72
- ...body.historyError === undefined ? {} : { historyError: body.historyError },
73
- }
74
- }
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
-
107
- /** List the host-persisted history (newest first). */
108
- async historyList(): Promise<HistoryEntry[]> {
109
- const response = await fetch(HISTORY_API.list, { method: 'POST' })
110
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
111
- return body.entries
112
- }
113
-
114
- /** Remove one history entry by id. */
115
- async historyRemove(id: string): Promise<HistoryEntry[]> {
116
- const response = await fetch(HISTORY_API.remove, {
117
- method: 'POST',
118
- headers: { 'content-type': 'application/json' },
119
- body: JSON.stringify({ id }),
120
- })
121
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
122
- return body.entries
123
- }
124
-
125
- /** Clear the entire history. */
126
- async historyClear(): Promise<HistoryEntry[]> {
127
- const response = await fetch(HISTORY_API.clear, { method: 'POST' })
128
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
129
- return body.entries
130
- }
131
-
132
- /** List the host-persisted gallery (newest first). */
133
- async galleryList(): Promise<HistoryEntry[]> {
134
- const response = await fetch(GALLERY_API.list, { method: 'POST' })
135
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
136
- return body.entries
137
- }
138
-
139
- /** Append one image to the gallery. The host assigns the id and skips the
140
- * append when a content-identical image is already in the gallery. */
141
- async galleryAppend(entry: HistoryEntryInput): Promise<{ entries: HistoryEntry[]; added: boolean }> {
142
- const response = await fetch(GALLERY_API.append, {
143
- method: 'POST',
144
- headers: { 'content-type': 'application/json' },
145
- body: JSON.stringify({ entry }),
146
- })
147
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[]; added: boolean }>(response)
148
- return { entries: body.entries, added: body.added }
149
- }
150
-
151
- /** Remove one gallery entry by id. */
152
- async galleryRemove(id: string): Promise<HistoryEntry[]> {
153
- const response = await fetch(GALLERY_API.remove, {
154
- method: 'POST',
155
- headers: { 'content-type': 'application/json' },
156
- body: JSON.stringify({ id }),
157
- })
158
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
159
- return body.entries
160
- }
161
-
162
- /** Clear the entire gallery. */
163
- async galleryClear(): Promise<HistoryEntry[]> {
164
- const response = await fetch(GALLERY_API.clear, { method: 'POST' })
165
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
166
- return body.entries
167
- }
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
-
174
- /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
175
- async templatesList(): Promise<TemplateListResult> {
176
- const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
177
- const body = await readEnvelope<TemplateListResult & { ok: true }>(response)
178
- return {
179
- cases: body.cases,
180
- total: body.total,
181
- origin: body.origin,
182
- repository: body.repository,
183
- fetchedAt: body.fetchedAt,
184
- }
185
- }
186
-
187
- /** Re-download the template library from the upstream mirror (host-side). */
188
- async templatesRefresh(): Promise<TemplateRefreshResult> {
189
- const response = await fetch(TEMPLATES_API.refresh, { method: 'POST' })
190
- const body = await readEnvelope<TemplateRefreshResult & { ok: true }>(response)
191
- return { total: body.total, fetchedAt: body.fetchedAt }
192
- }
193
- }
1
+ /**
2
+ * Browser-side API client for the /api/dsh-imagegen route family. The only
3
+ * data access path the panel uses — plain fetch, same origin.
4
+ */
5
+
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
+
8
+ /** Error carrying the route's JSON error message. */
9
+ export class ImageGenApiError extends Error {
10
+ /** Stable wire code from the host. */
11
+ readonly code: string
12
+
13
+ constructor(message: string, code = 'generate-failed') {
14
+ super(message)
15
+ this.name = 'ImageGenApiError'
16
+ this.code = code
17
+ }
18
+ }
19
+
20
+ /** Parse the { ok, ... } envelope or throw an ImageGenApiError. */
21
+ async function readEnvelope<T>(response: Response): Promise<T> {
22
+ let body: unknown
23
+ try {
24
+ body = await response.json()
25
+ } catch {
26
+ throw new ImageGenApiError(`HTTP ${response.status}: invalid JSON response`)
27
+ }
28
+ if (body === null || typeof body !== 'object') {
29
+ throw new ImageGenApiError(`HTTP ${response.status}: malformed response`)
30
+ }
31
+ const record = body as { ok?: unknown; message?: unknown; code?: unknown }
32
+ if (record.ok !== true) {
33
+ throw new ImageGenApiError(
34
+ typeof record.message === 'string' ? record.message : `HTTP ${response.status}`,
35
+ typeof record.code === 'string' ? record.code : 'generate-failed',
36
+ )
37
+ }
38
+ return body as T
39
+ }
40
+
41
+ /** The browser half's data entry point. */
42
+ export class ImageGenApi {
43
+ /** Ask the host to check the latest stable GitHub Release. */
44
+ async updateCheck(): Promise<UpdateInfo> {
45
+ const response = await fetch(UPDATE_API.check, { method: 'POST' })
46
+ const body = await readEnvelope<{ ok: true; update: UpdateInfo }>(response)
47
+ return body.update
48
+ }
49
+
50
+ /** Ask the host to install a previously discovered Release. */
51
+ async updateApply(version: string): Promise<{ updatedVersion: string; restartRequired: boolean }> {
52
+ const response = await fetch(UPDATE_API.apply, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ version }),
56
+ })
57
+ const body = await readEnvelope<{ ok: true; updatedVersion: string; restartRequired: boolean }>(response)
58
+ return { updatedVersion: body.updatedVersion, restartRequired: body.restartRequired }
59
+ }
60
+
61
+ /** Forward one generate request to the host proxy. */
62
+ async generate(request: GenerateRequest): Promise<GenerateResult> {
63
+ const response = await fetch(GENERATE_API, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify(request),
67
+ })
68
+ const body = await readEnvelope<{ ok: true; images: GenerateResult['images']; history?: HistoryEntry[]; historyError?: string }>(response)
69
+ return {
70
+ images: body.images,
71
+ ...body.history === undefined ? {} : { history: body.history },
72
+ ...body.historyError === undefined ? {} : { historyError: body.historyError },
73
+ }
74
+ }
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
+
107
+ /** List the host-persisted history (newest first). */
108
+ async historyList(): Promise<HistoryEntry[]> {
109
+ const response = await fetch(HISTORY_API.list, { method: 'POST' })
110
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
111
+ return body.entries
112
+ }
113
+
114
+ /** Remove one history entry by id. */
115
+ async historyRemove(id: string): Promise<HistoryEntry[]> {
116
+ const response = await fetch(HISTORY_API.remove, {
117
+ method: 'POST',
118
+ headers: { 'content-type': 'application/json' },
119
+ body: JSON.stringify({ id }),
120
+ })
121
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
122
+ return body.entries
123
+ }
124
+
125
+ /** Clear the entire history. */
126
+ async historyClear(): Promise<HistoryEntry[]> {
127
+ const response = await fetch(HISTORY_API.clear, { method: 'POST' })
128
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
129
+ return body.entries
130
+ }
131
+
132
+ /** List the host-persisted gallery (newest first). */
133
+ async galleryList(): Promise<HistoryEntry[]> {
134
+ const response = await fetch(GALLERY_API.list, { method: 'POST' })
135
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
136
+ return body.entries
137
+ }
138
+
139
+ /** Append one image to the gallery. The host assigns the id and skips the
140
+ * append when a content-identical image is already in the gallery. */
141
+ async galleryAppend(entry: HistoryEntryInput): Promise<{ entries: HistoryEntry[]; added: boolean }> {
142
+ const response = await fetch(GALLERY_API.append, {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json' },
145
+ body: JSON.stringify({ entry }),
146
+ })
147
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[]; added: boolean }>(response)
148
+ return { entries: body.entries, added: body.added }
149
+ }
150
+
151
+ /** Remove one gallery entry by id. */
152
+ async galleryRemove(id: string): Promise<HistoryEntry[]> {
153
+ const response = await fetch(GALLERY_API.remove, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json' },
156
+ body: JSON.stringify({ id }),
157
+ })
158
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
159
+ return body.entries
160
+ }
161
+
162
+ /** Clear the entire gallery. */
163
+ async galleryClear(): Promise<HistoryEntry[]> {
164
+ const response = await fetch(GALLERY_API.clear, { method: 'POST' })
165
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
166
+ return body.entries
167
+ }
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
+
174
+ /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
175
+ async templatesList(): Promise<TemplateListResult> {
176
+ const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
177
+ const body = await readEnvelope<TemplateListResult & { ok: true }>(response)
178
+ return {
179
+ cases: body.cases,
180
+ total: body.total,
181
+ origin: body.origin,
182
+ repository: body.repository,
183
+ fetchedAt: body.fetchedAt,
184
+ }
185
+ }
186
+
187
+ /** Re-download the template library from the upstream mirror (host-side). */
188
+ async templatesRefresh(): Promise<TemplateRefreshResult> {
189
+ const response = await fetch(TEMPLATES_API.refresh, { method: 'POST' })
190
+ const body = await readEnvelope<TemplateRefreshResult & { ok: true }>(response)
191
+ return { total: body.total, fetchedAt: body.fetchedAt }
192
+ }
193
+ }
@@ -0,0 +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
+ }