@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.
- package/LICENSE +201 -201
- package/README.md +203 -181
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +2711 -1318
- package/lib/client.js.map +1 -1
- package/lib/index.js +830 -155
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -316
- package/src/client/ImageGenPanel.tsx +1703 -1476
- package/src/client/SettingsCard.tsx +936 -648
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -0
- 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 +170 -152
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -484
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -536
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -250
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -464
- package/src/gallery-store.ts +286 -280
- package/src/generation-runtime.ts +79 -48
- package/src/history-store.ts +250 -238
- package/src/image-format.ts +11 -0
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -212
- package/src/model-catalog.ts +115 -0
- package/src/presets.ts +71 -0
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -253
- package/src/routes.ts +916 -738
- 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,250 +1,298 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser-side settings scope for the dsh-imagegen namespace, served by the
|
|
3
|
-
* plugin's own loopback bridge routes (/api/dsh-imagegen/settings). The
|
|
4
|
-
* official rc.6 settings scope answers "unavailable" for every third-party
|
|
5
|
-
* namespace (the host-apiproxy allowlist is hard-coded), so this package
|
|
6
|
-
* re-serves its namespace through the host settings seam over a same-origin,
|
|
7
|
-
* loopback-only HTTP pair — the same pattern the dsh-web-ui family bridge
|
|
8
|
-
* uses, self-contained per plugin.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
createSnapshotStore,
|
|
13
|
-
type SettingsScope,
|
|
14
|
-
type SettingsScopeSnapshot,
|
|
15
|
-
type SnapshotStore,
|
|
16
|
-
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
17
|
-
import { SETTINGS_API } from '../protocol.ts'
|
|
18
|
-
|
|
19
|
-
/** The fields this plugin's settings card edits. */
|
|
20
|
-
export interface ImageGenConfig {
|
|
21
|
-
enabled?: boolean
|
|
22
|
-
announceToAgent?: boolean
|
|
23
|
-
allowAgentImageGeneration?: boolean
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side settings scope for the dsh-imagegen namespace, served by the
|
|
3
|
+
* plugin's own loopback bridge routes (/api/dsh-imagegen/settings). The
|
|
4
|
+
* official rc.6 settings scope answers "unavailable" for every third-party
|
|
5
|
+
* namespace (the host-apiproxy allowlist is hard-coded), so this package
|
|
6
|
+
* re-serves its namespace through the host settings seam over a same-origin,
|
|
7
|
+
* loopback-only HTTP pair — the same pattern the dsh-web-ui family bridge
|
|
8
|
+
* uses, self-contained per plugin.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createSnapshotStore,
|
|
13
|
+
type SettingsScope,
|
|
14
|
+
type SettingsScopeSnapshot,
|
|
15
|
+
type SnapshotStore,
|
|
16
|
+
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
17
|
+
import { SETTINGS_API, type ChannelConfig } from '../protocol.ts'
|
|
18
|
+
|
|
19
|
+
/** The fields this plugin's settings card edits. */
|
|
20
|
+
export interface ImageGenConfig {
|
|
21
|
+
enabled?: boolean
|
|
22
|
+
announceToAgent?: boolean
|
|
23
|
+
allowAgentImageGeneration?: boolean
|
|
24
|
+
/** Configured channels (each: name, endpoint, model catalog). */
|
|
25
|
+
channels?: ChannelConfig[]
|
|
26
|
+
/** Per-channel API keys, keyed by channel id. The redacted wire view returns
|
|
27
|
+
* this as an empty object; key presence comes from the secrets sidecar. */
|
|
28
|
+
channelSecrets?: Record<string, string>
|
|
29
|
+
/** Channel used when a request does not name one. */
|
|
30
|
+
defaultChannelId?: string
|
|
31
|
+
promptApiUrl?: string
|
|
32
|
+
promptApiKey?: string
|
|
33
|
+
promptModel?: string
|
|
34
|
+
/* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
|
|
35
|
+
apiUrl?: string
|
|
36
|
+
apiKey?: string
|
|
37
|
+
imageModels?: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One settings path-op as the bridge consumes it. */
|
|
41
|
+
export type SettingsOp = { op: 'set'; path: string[]; value: unknown } | { op: 'unset'; path: string[] }
|
|
42
|
+
|
|
43
|
+
/** Wire shape of one namespace view from the bridge. */
|
|
44
|
+
interface BridgeView {
|
|
45
|
+
ns: string
|
|
46
|
+
value: unknown
|
|
47
|
+
base?: unknown
|
|
48
|
+
user?: unknown
|
|
49
|
+
revision: number
|
|
50
|
+
secrets?: Array<{ path: string[]; set: boolean }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The bridge response envelope ({ ok: true, value } | { ok: false, code, message }). */
|
|
54
|
+
type BridgeEnvelope =
|
|
55
|
+
| { ok: true; value: { namespaces?: BridgeView[]; writable?: boolean } | BridgeView }
|
|
56
|
+
| { ok: false; code: string; message: string }
|
|
57
|
+
|
|
58
|
+
/** Settings wire face over the bridge routes (fetch-backed). */
|
|
59
|
+
function createBridgeApi(fetchFn: typeof fetch): {
|
|
60
|
+
settings: {
|
|
61
|
+
describe(payload: Record<string, never>): Promise<{ result: BridgeEnvelope }>
|
|
62
|
+
mutate(payload: { ns: string; ops: unknown[]; expectedRevision?: number }): Promise<{ result: BridgeEnvelope }>
|
|
63
|
+
}
|
|
64
|
+
} {
|
|
65
|
+
const post = async (path: string, body: unknown): Promise<{ result: BridgeEnvelope }> => {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetchFn(path, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
})
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
return { result: { ok: false, code: 'internal', message: `bridge HTTP ${response.status}` } }
|
|
74
|
+
}
|
|
75
|
+
return { result: await response.json() as BridgeEnvelope }
|
|
76
|
+
} catch {
|
|
77
|
+
return { result: { ok: false, code: 'internal', message: 'settings bridge unreachable' } }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
settings: {
|
|
82
|
+
describe: async payload => post(SETTINGS_API.describe, payload),
|
|
83
|
+
mutate: async payload => post(SETTINGS_API.mutate, payload),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A SettingsScope over the bridge face: serialized queue, revision-fenced
|
|
90
|
+
* writes, recovery read after a refusal. Mirrors the official controller's
|
|
91
|
+
* ordering but trusts the Host-seam value without re-running the wire-schema
|
|
92
|
+
* validation — the seam already validated it.
|
|
93
|
+
*/
|
|
94
|
+
class BridgeScopeController<T> implements SettingsScope<T> {
|
|
95
|
+
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
|
96
|
+
/** Whether the namespace currently holds a stored secret (e.g. apiKey). */
|
|
97
|
+
private readonly keySet: SnapshotStore<boolean>
|
|
98
|
+
/** Individual secret presence bits, keyed by the settings field name. */
|
|
99
|
+
private readonly secretSets: SnapshotStore<Record<string, boolean>>
|
|
100
|
+
private tail: Promise<void> = Promise.resolve()
|
|
101
|
+
private disposed = false
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly api: ReturnType<typeof createBridgeApi>['settings'],
|
|
105
|
+
private readonly spec: { namespace: string },
|
|
106
|
+
) {
|
|
107
|
+
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
|
108
|
+
status: 'loading',
|
|
109
|
+
value: undefined,
|
|
110
|
+
base: undefined,
|
|
111
|
+
user: undefined,
|
|
112
|
+
revision: undefined,
|
|
113
|
+
writable: false,
|
|
114
|
+
mode: 'host',
|
|
115
|
+
})
|
|
116
|
+
this.keySet = createSnapshotStore(false)
|
|
117
|
+
this.secretSets = createSnapshotStore({})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getSnapshot(): SettingsScopeSnapshot<T> {
|
|
121
|
+
return this.store.getSnapshot()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether a stored secret exists (from the redacted view's secrets list). */
|
|
125
|
+
getKeySetSnapshot(): boolean {
|
|
126
|
+
return this.keySet.getSnapshot()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Observe the secret-set flag. */
|
|
130
|
+
subscribeKeySet(listener: () => void): () => void {
|
|
131
|
+
return this.keySet.subscribe(listener)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether a specific secret field currently has a stored value. */
|
|
135
|
+
getSecretSetSnapshot(field: string): boolean {
|
|
136
|
+
return this.secretSets.getSnapshot()[field] === true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Observe changes to individual secret-field presence bits. */
|
|
140
|
+
subscribeSecretSets(listener: () => void): () => void {
|
|
141
|
+
return this.secretSets.subscribe(listener)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
subscribe(listener: () => void): () => void {
|
|
145
|
+
return this.store.subscribe(listener)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Queue a bridge refresh. */
|
|
149
|
+
load(): Promise<void> {
|
|
150
|
+
return this.enqueue(() => this.read())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
set(field: string, value: unknown): Promise<void> {
|
|
154
|
+
return this.enqueue(() => this.writeOps([{ op: 'set', path: [field], value }]))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
unset(field: string): Promise<void> {
|
|
158
|
+
return this.enqueue(() => this.writeOps([{ op: 'unset', path: [field] }]))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Apply several path ops in one revision-fenced mutate call (atomic save).
|
|
162
|
+
* Path ops may address plain-object fields (e.g. `channelSecrets.<id>`),
|
|
163
|
+
* but never navigate *inside* arrays — write array fields wholesale. */
|
|
164
|
+
mutateOps(ops: SettingsOp[]): Promise<void> {
|
|
165
|
+
return this.enqueue(() => this.writeOps(ops))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async dispose(): Promise<void> {
|
|
169
|
+
this.disposed = true
|
|
170
|
+
await this.tail
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private enqueue(operation: () => Promise<void>): Promise<void> {
|
|
174
|
+
if (this.disposed) return Promise.resolve()
|
|
175
|
+
const task = this.tail.then(async () => {
|
|
176
|
+
if (this.disposed) return
|
|
177
|
+
await operation()
|
|
178
|
+
})
|
|
179
|
+
this.tail = task.catch(() => {})
|
|
180
|
+
return task
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async read(): Promise<void> {
|
|
184
|
+
let response
|
|
185
|
+
try {
|
|
186
|
+
response = await this.api.describe({})
|
|
187
|
+
} catch {
|
|
188
|
+
if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (!response.result.ok || this.disposed) {
|
|
192
|
+
if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const { namespaces, writable } = response.result.value as { namespaces?: BridgeView[]; writable?: boolean }
|
|
196
|
+
const view = namespaces?.find(candidate => candidate.ns === this.spec.namespace)
|
|
197
|
+
if (view === undefined) {
|
|
198
|
+
this.store.update(draft => {
|
|
199
|
+
draft.status = 'unavailable'
|
|
200
|
+
draft.writable = writable === true
|
|
201
|
+
})
|
|
202
|
+
this.keySet.set(false)
|
|
203
|
+
this.secretSets.set({})
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
this.accept(view, writable)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async writeOps(ops: SettingsOp[]): Promise<void> {
|
|
210
|
+
const revision = this.getSnapshot().revision
|
|
211
|
+
let response
|
|
212
|
+
try {
|
|
213
|
+
response = await this.api.mutate({
|
|
214
|
+
ns: this.spec.namespace,
|
|
215
|
+
ops,
|
|
216
|
+
...revision === undefined ? {} : { expectedRevision: revision },
|
|
217
|
+
})
|
|
218
|
+
} catch {
|
|
219
|
+
await this.read()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (!response.result.ok || this.disposed) {
|
|
223
|
+
await this.read()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
this.accept(response.result.value as BridgeView, undefined)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private accept(view: BridgeView, writable: boolean | undefined): void {
|
|
230
|
+
this.store.update(draft => {
|
|
231
|
+
draft.revision = view.revision
|
|
232
|
+
draft.base = view.base
|
|
233
|
+
draft.user = view.user
|
|
234
|
+
if (writable !== undefined) draft.writable = writable
|
|
235
|
+
draft.status = 'ready'
|
|
236
|
+
// Trust the Host-seam value: the seam already validated it, and the
|
|
237
|
+
// card binds without a narrowing decoder.
|
|
238
|
+
draft.value = view.value as T
|
|
239
|
+
})
|
|
240
|
+
const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
|
|
241
|
+
this.keySet.set(Object.values(secretSets).some(Boolean))
|
|
242
|
+
this.secretSets.set(secretSets)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The bound scope plus the secret-set flag, as the card and panel consume it. */
|
|
247
|
+
export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
|
|
248
|
+
/** Queue a bridge refresh (the invalidation path re-reads the namespace). */
|
|
249
|
+
load(): Promise<void>
|
|
250
|
+
/** Apply several path ops in one revision-fenced mutate call. */
|
|
251
|
+
mutateOps(ops: SettingsOp[]): Promise<void>
|
|
252
|
+
getKeySetSnapshot(): boolean
|
|
253
|
+
subscribeKeySet(listener: () => void): () => void
|
|
254
|
+
getSecretSetSnapshot(field: string): boolean
|
|
255
|
+
subscribeSecretSets(listener: () => void): () => void
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Bind the dsh-imagegen settings scope over the bridge routes and start its
|
|
260
|
+
* initial read (the caller mounts nothing until the scope settles).
|
|
261
|
+
* @param fetchFn - the fetch implementation (the global fetch on loopback).
|
|
262
|
+
* @returns the scope; unavailable when the bridge is unreachable.
|
|
263
|
+
*/
|
|
264
|
+
export function bindImageGenScope(fetchFn: typeof fetch = fetch): ImageGenScope {
|
|
265
|
+
const controller = new BridgeScopeController<ImageGenConfig>(createBridgeApi(fetchFn).settings, {
|
|
266
|
+
namespace: 'dsh-imagegen',
|
|
267
|
+
})
|
|
268
|
+
void controller.load()
|
|
269
|
+
return controller
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Flatten the configured channels into the model options the panel lists
|
|
274
|
+
* (aliases; the default channel's models first) plus the default channel id.
|
|
275
|
+
* Falls back to the legacy flat allow-list while no channels exist (upgrade
|
|
276
|
+
* path). Pure projection — no host calls.
|
|
277
|
+
*/
|
|
278
|
+
export function imageModelOptions(config: ImageGenConfig | undefined): { models: string[]; defaultChannelId?: string } {
|
|
279
|
+
const channels = config?.channels ?? []
|
|
280
|
+
if (channels.length === 0) {
|
|
281
|
+
const legacy = Array.isArray(config?.imageModels)
|
|
282
|
+
? config.imageModels.filter((model): model is string => typeof model === 'string' && model.trim() !== '')
|
|
283
|
+
: []
|
|
284
|
+
return { models: legacy }
|
|
285
|
+
}
|
|
286
|
+
const defaultId = config?.defaultChannelId !== undefined && channels.some(channel => channel.id === config.defaultChannelId)
|
|
287
|
+
? config.defaultChannelId
|
|
288
|
+
: channels[0]!.id
|
|
289
|
+
const ordered = [defaultId, ...channels.filter(channel => channel.id !== defaultId).map(channel => channel.id)]
|
|
290
|
+
const models: string[] = []
|
|
291
|
+
for (const id of ordered) {
|
|
292
|
+
const channel = channels.find(candidate => candidate.id === id)!
|
|
293
|
+
for (const model of channel.models) {
|
|
294
|
+
if (model.alias !== '' && !models.includes(model.alias)) models.push(model.alias)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return models.length > 0 ? { models, defaultChannelId: defaultId } : { models: [], defaultChannelId: defaultId }
|
|
298
|
+
}
|