@dickpy/dsh-imagegen 1.3.0 → 1.5.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 (38) hide show
  1. package/README.md +363 -196
  2. package/docs/images/ecommerce-mode.png +0 -0
  3. package/docs/images/image-generation-studio-three-column.png +0 -0
  4. package/docs/images/imagegen-overview.png +0 -0
  5. package/docs/images/multi-model-comparison.png +0 -0
  6. package/docs/videos/agent-chat-edit.gif +0 -0
  7. package/docs/videos/agent-chat-edit.mp4 +0 -0
  8. package/lib/client.js +2589 -884
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +585 -240
  11. package/package.json +5 -2
  12. package/src/agent-image-tools.ts +131 -102
  13. package/src/client/ImageGenPanel.tsx +2679 -1594
  14. package/src/client/SettingsCard.tsx +6 -27
  15. package/src/client/api.ts +11 -1
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/image-toolview.tsx +176 -165
  18. package/src/client/index.ts +25 -15
  19. package/src/client/locales.ts +746 -602
  20. package/src/client/mount.tsx +213 -124
  21. package/src/client/panel.module.css +2619 -1563
  22. package/src/client/sidebar-entry.ts +190 -144
  23. package/src/edit-image-command.ts +110 -0
  24. package/src/engine.ts +47 -5
  25. package/src/gallery-store.ts +20 -0
  26. package/src/generation-runtime.ts +11 -2
  27. package/src/history-store.ts +26 -0
  28. package/src/image-models.ts +1 -1
  29. package/src/index.ts +31 -12
  30. package/src/model-catalog.ts +19 -2
  31. package/src/presets.ts +11 -3
  32. package/src/prompt-enhancer.ts +63 -5
  33. package/src/protocol.ts +59 -5
  34. package/src/routes.ts +62 -4
  35. package/src/task-queue.ts +42 -32
  36. package/docs/images/agent-chat-edit.png +0 -0
  37. package/docs/images/agent-chat-generate.png +0 -0
  38. package/docs/images/agent-chat-poster-workflow.png +0 -0
@@ -1,144 +1,190 @@
1
- /**
2
- * Sidebar entry injection.
3
- *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so — following the dsh-ssh / task-board precedent of DOM-level extension
6
- * the entry row is injected after the shell's New Session button (after the
7
- * sibling plugin family block). The injection self-heals: a MutationObserver
8
- * watches the sidebar root and re-inserts the row whenever a React re-render
9
- * displaces it (re-insertion happens in the same frame, before paint).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the panel view it toggles is a separate React root mounted
13
- * in the center column (see mount.tsx).
14
- */
15
-
16
- import type { ImageGenController } from './controller.ts'
17
- import css from './panel.module.css'
18
-
19
- /** Stable data attribute identifying the injected entry row. */
20
- export const ENTRY_SELECTOR = '[data-dsh-imagegen-entry]'
21
-
22
- /** Inline icon (matches the shell's 16px nav-icon look): a picture glyph. */
23
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><circle cx="5.6" cy="5.8" r="1"/><path d="M2.5 12.5l3.6-3.4 2.4 2.2 3-3 2 2.4"/></svg>'
24
-
25
- /** Family entry selectors of sibling plugins (relative placement anchor). */
26
- const FAMILY_ENTRY_SELECTOR = '[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-imagegen-entry]'
27
-
28
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
29
- function sidebarRoot(): HTMLElement | undefined {
30
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
31
- if (column === null) return undefined
32
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
- }
35
-
36
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
- if (nested !== null) return nested
40
- for (const child of root.children) {
41
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
42
- }
43
- return undefined
44
- }
45
-
46
- /** Build the entry row (a detached button; insert once the shell is up). */
47
- function createEntry(controller: ImageGenController, label: string, tooltip: string): HTMLButtonElement {
48
- const entry = document.createElement('button')
49
- entry.type = 'button'
50
- entry.dataset.dshImagegenEntry = ''
51
- entry.className = css.entry
52
- entry.setAttribute('aria-label', label)
53
- entry.setAttribute('title', tooltip)
54
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + label + '</span>'
55
- entry.addEventListener('click', () => { controller.toggle() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the family block (task board → ssh → imagegen). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block, never relative to transient
65
- // logoRow geometry: every family plugin that self-heals during a
66
- // re-render then lands in the same relative order.
67
- const row = button.closest('[class*="logoRow"]')
68
- const base = (row !== null && row.parentElement === root) ? row : button
69
- const family = Array.from(root.children).filter(
70
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_ENTRY_SELECTOR),
71
- )
72
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
73
- root.insertBefore(entry, anchor)
74
- }
75
- return true
76
- }
77
-
78
- /**
79
- * Mount the sidebar entry, waiting for the shell to render and self-healing
80
- * on later React re-renders.
81
- * @param controller - the panel controller the entry toggles.
82
- * @param label - the entry label (localized).
83
- * @param tooltip - the entry tooltip (localized).
84
- * @returns disposer removing the entry and its observers.
85
- */
86
- export function mountSidebarEntry(controller: ImageGenController, label: string, tooltip: string): () => void {
87
- const entry = createEntry(controller, label, tooltip)
88
- let root: HTMLElement | undefined
89
- let placed = false
90
-
91
- const tryPlace = (): void => {
92
- if (root !== undefined && !root.isConnected) {
93
- rootObserver.disconnect()
94
- root = undefined
95
- placed = false
96
- }
97
- if (placed) {
98
- if (document.body.contains(entry)) return
99
- rootObserver.disconnect()
100
- root = undefined
101
- placed = false
102
- }
103
- root ??= sidebarRoot()
104
- if (root === undefined) return
105
- placed = placeEntry(root, entry)
106
- if (placed) {
107
- rootObserver.observe(root, { childList: true, subtree: true })
108
- }
109
- }
110
-
111
- // Body-level watcher retained as the "whole rebuild" fallback.
112
- const waitObserver = new MutationObserver(() => { tryPlace() })
113
- waitObserver.observe(document.body, { childList: true, subtree: true })
114
-
115
- // Self-heal: if a React re-render displaces the row, re-insert it in the
116
- // same frame (microtask before paint -> no visible flicker).
117
- const rootObserver = new MutationObserver(() => {
118
- if (root === undefined || !root.isConnected) {
119
- placed = false
120
- tryPlace()
121
- return
122
- }
123
- if (!root.contains(entry)) {
124
- placed = placeEntry(root, entry)
125
- }
126
- })
127
-
128
- // Reflect the panel's open state on the row (active highlight).
129
- const syncActive = () => {
130
- if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
131
- else delete entry.dataset.active
132
- }
133
- const unsubscribe = controller.subscribe(syncActive)
134
- syncActive()
135
-
136
- tryPlace()
137
-
138
- return () => {
139
- waitObserver.disconnect()
140
- rootObserver.disconnect()
141
- unsubscribe()
142
- entry.remove()
143
- }
144
- }
1
+ /**
2
+ * Replace the shell's standalone New Session affordance with a two-tab entry:
3
+ * New Session and Image Generation. When image generation is active, the
4
+ * plugin also uses the shell's region area as a dedicated history surface;
5
+ * the original workspace/session tree remains underneath and is restored when
6
+ * the panel closes.
7
+ */
8
+
9
+ import type { ImageGenController } from './controller.ts'
10
+ import css from './panel.module.css'
11
+
12
+ /** Stable selector for the injected two-tab host. */
13
+ export const ENTRY_SELECTOR = '[data-dsh-imagegen-session-tabs]'
14
+ /** Stable selector for the history surface in the shell region area. */
15
+ export const HISTORY_HOST_SELECTOR = '[data-dsh-imagegen-history-host]'
16
+
17
+ /** Inline picture glyph kept deliberately small for the sidebar rail. */
18
+ const IMAGE_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><circle cx="5.6" cy="5.8" r="1"/><path d="M2.5 12.5l3.6-3.4 2.4 2.2 3-3 2 2.4"/></svg>'
19
+
20
+ /** Inline plus glyph for the new-session tab. */
21
+ const NEW_SESSION_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'
22
+
23
+ /** Find the sidebar shell root, or undefined while it is not mounted. */
24
+ function sidebarRoot(): HTMLElement | undefined {
25
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
26
+ if (column === null) return undefined
27
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
28
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
29
+ }
30
+
31
+ /** The shell-owned New Session button across current and legacy shells. */
32
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
33
+ return root.querySelector<HTMLButtonElement>(
34
+ 'button[data-dsh-part="new-session"], button[class*="newSession"]',
35
+ ) ?? Array.from(root.children).find(
36
+ (child): child is HTMLButtonElement => child instanceof HTMLElement && child.tagName === 'BUTTON',
37
+ )
38
+ }
39
+
40
+ /** Locate the shell region that normally contains workspaces and sessions. */
41
+ function regionArea(root: HTMLElement): HTMLElement | undefined {
42
+ return root.querySelector<HTMLElement>('[class*="regionArea"]') ?? undefined
43
+ }
44
+
45
+ function makeTab(
46
+ label: string,
47
+ tooltip: string,
48
+ icon: string,
49
+ onClick: () => void,
50
+ ): HTMLButtonElement {
51
+ const tab = document.createElement('button')
52
+ tab.type = 'button'
53
+ tab.className = css.sessionTab
54
+ tab.setAttribute('aria-label', label)
55
+ tab.setAttribute('title', tooltip)
56
+ tab.innerHTML = `<span class="${css.sessionTabIcon}">${icon}</span><span class="${css.sessionTabLabel}">${label}</span>`
57
+ tab.addEventListener('click', onClick)
58
+ return tab
59
+ }
60
+
61
+ function hideShellButton(button: HTMLButtonElement): void {
62
+ button.dataset.dshImagegenOriginal = ''
63
+ button.setAttribute('aria-hidden', 'true')
64
+ button.tabIndex = -1
65
+ button.style.display = 'none'
66
+ }
67
+
68
+ function restoreShellButton(button: HTMLButtonElement): void {
69
+ button.style.removeProperty('display')
70
+ button.removeAttribute('aria-hidden')
71
+ button.removeAttribute('tabindex')
72
+ delete button.dataset.dshImagegenOriginal
73
+ }
74
+
75
+ /** Mount or repair the two-tab host at the shell's New Session position. */
76
+ function placeTabs(
77
+ root: HTMLElement,
78
+ controller: ImageGenController,
79
+ newSessionLabel: string,
80
+ newSessionTooltip: string,
81
+ imageLabel: string,
82
+ imageTooltip: string,
83
+ ): HTMLDivElement | undefined {
84
+ const button = newSessionButton(root)
85
+ if (button === undefined) return undefined
86
+
87
+ const existing = root.querySelector<HTMLDivElement>(ENTRY_SELECTOR)
88
+ if (existing !== null && existing.parentElement === button.parentElement) {
89
+ hideShellButton(button)
90
+ return existing
91
+ }
92
+
93
+ existing?.remove()
94
+ const tabs = document.createElement('div')
95
+ tabs.dataset.dshImagegenSessionTabs = ''
96
+ tabs.className = css.sessionTabs
97
+ tabs.setAttribute('role', 'tablist')
98
+ tabs.setAttribute('aria-label', imageTooltip)
99
+
100
+ const newSessionTab = makeTab(newSessionLabel, newSessionTooltip, NEW_SESSION_ICON, () => {
101
+ controller.close()
102
+ button.click()
103
+ })
104
+ const imageTab = makeTab(imageLabel, imageTooltip, IMAGE_ICON, () => {
105
+ controller.open()
106
+ })
107
+ newSessionTab.dataset.dshImagegenTab = 'new-session'
108
+ imageTab.dataset.dshImagegenTab = 'image'
109
+ tabs.append(newSessionTab, imageTab)
110
+
111
+ button.parentElement?.insertBefore(tabs, button)
112
+ hideShellButton(button)
113
+ return tabs
114
+ }
115
+
116
+ /** Mount an overlay host over the workspace/session tree for image history. */
117
+ function placeHistoryHost(root: HTMLElement): HTMLDivElement | undefined {
118
+ const region = regionArea(root)
119
+ if (region === undefined) return undefined
120
+ const existing = region.querySelector<HTMLDivElement>(HISTORY_HOST_SELECTOR)
121
+ if (existing !== null) return existing
122
+ const host = document.createElement('div')
123
+ host.dataset.dshImagegenHistoryHost = ''
124
+ host.className = css.sidebarHistoryHost
125
+ region.append(host)
126
+ return host
127
+ }
128
+
129
+ /**
130
+ * Mount the two tabs and self-heal after React rebuilds the sidebar. The
131
+ * shell-owned button is restored by the disposer so unloading the plugin
132
+ * leaves the host unchanged.
133
+ */
134
+ export function mountSidebarEntry(
135
+ controller: ImageGenController,
136
+ newSessionLabel: string,
137
+ newSessionTooltip: string,
138
+ imageLabel: string,
139
+ imageTooltip: string,
140
+ ): () => void {
141
+ let root: HTMLElement | undefined
142
+ let tabs: HTMLDivElement | undefined
143
+ let historyHost: HTMLDivElement | undefined
144
+ let originalButton: HTMLButtonElement | undefined
145
+
146
+ const syncActive = (): void => {
147
+ if (tabs === undefined) return
148
+ const newTab = tabs.querySelector<HTMLElement>('[data-dsh-imagegen-tab="new-session"]')
149
+ const imageTab = tabs.querySelector<HTMLElement>('[data-dsh-imagegen-tab="image"]')
150
+ if (controller.getSnapshot().panelOpen) {
151
+ if (newTab !== null) delete newTab.dataset.active
152
+ if (imageTab !== null) imageTab.dataset.active = ''
153
+ } else {
154
+ if (newTab !== null) newTab.dataset.active = ''
155
+ if (imageTab !== null) delete imageTab.dataset.active
156
+ }
157
+ }
158
+
159
+ const ensure = (): void => {
160
+ if (root !== undefined && !root.isConnected) {
161
+ root = undefined
162
+ tabs = undefined
163
+ historyHost = undefined
164
+ originalButton = undefined
165
+ }
166
+ root ??= sidebarRoot()
167
+ if (root === undefined) return
168
+ root.dataset.dshImagegenSidebarRoot = ''
169
+ const button = newSessionButton(root)
170
+ if (button === undefined) return
171
+ originalButton ??= button
172
+ tabs = placeTabs(root, controller, newSessionLabel, newSessionTooltip, imageLabel, imageTooltip)
173
+ historyHost = placeHistoryHost(root)
174
+ syncActive()
175
+ }
176
+
177
+ const bodyObserver = new MutationObserver(ensure)
178
+ bodyObserver.observe(document.body, { childList: true, subtree: true })
179
+ const unsubscribe = controller.subscribe(syncActive)
180
+ ensure()
181
+
182
+ return () => {
183
+ bodyObserver.disconnect()
184
+ unsubscribe()
185
+ tabs?.remove()
186
+ historyHost?.remove()
187
+ if (originalButton !== undefined && originalButton.isConnected) restoreShellButton(originalButton)
188
+ if (root !== undefined) delete root.dataset.dshImagegenSidebarRoot
189
+ }
190
+ }
@@ -0,0 +1,110 @@
1
+ /** Direct `/edit_image` command for editing the latest image in the session. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { CommandResult } from '@deepseek-ai/dsh-commands'
5
+ import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
6
+ import type {} from '@deepseek-ai/dsh-commands'
7
+ import type {} from '@deepseek-ai/dsh-attachment'
8
+ import {
9
+ submitAgentImageEdit,
10
+ type AgentImageToolConfig,
11
+ } from './agent-image-tools.ts'
12
+ import type { ImageGenerationRuntime } from './generation-runtime.ts'
13
+
14
+ function isImageReference(value: unknown): value is ImageAttachmentRef {
15
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
16
+ const ref = value as Record<string, unknown>
17
+ return typeof ref.attachmentId === 'string'
18
+ && (ref.mediaType === 'image/png' || ref.mediaType === 'image/jpeg' || ref.mediaType === 'image/webp' || ref.mediaType === 'image/gif')
19
+ && Number.isInteger(ref.bytes) && (ref.bytes as number) > 0
20
+ && Number.isInteger(ref.width) && (ref.width as number) > 0
21
+ && Number.isInteger(ref.height) && (ref.height as number) > 0
22
+ }
23
+
24
+ function imageInContent(value: unknown): ImageAttachmentRef | undefined {
25
+ if (!Array.isArray(value)) return undefined
26
+ for (let index = value.length - 1; index >= 0; index -= 1) {
27
+ const block = value[index]
28
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) continue
29
+ const raw = block as Record<string, unknown>
30
+ if (raw.type === 'image' && isImageReference(raw.attachment)) return raw.attachment
31
+ if (raw.type === 'tool-result') {
32
+ const nested = imageInContent(raw.content)
33
+ if (nested !== undefined) return nested
34
+ }
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ /** Pick the newest image explicitly attached to this command invocation. */
40
+ function imageInInvocation(value: unknown): ImageAttachmentRef | undefined {
41
+ if (!Array.isArray(value)) return undefined
42
+ for (let index = value.length - 1; index >= 0; index -= 1) {
43
+ const block = value[index]
44
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) continue
45
+ const raw = block as Record<string, unknown>
46
+ if (raw.type === 'image' && isImageReference(raw.attachment)) return raw.attachment
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ /** Find the newest durable image reference, including nested tool results. */
52
+ export function latestSessionImage(messages: readonly unknown[]): ImageAttachmentRef | undefined {
53
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
54
+ const message = messages[index]
55
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) continue
56
+ const image = imageInContent((message as { content?: unknown }).content)
57
+ if (image !== undefined) return image
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ function commandError(error: unknown): CommandResult {
63
+ const text = error instanceof Error ? error.message : String(error)
64
+ return { kind: 'error', text: text.trim() === '' ? '图片编辑失败。' : text }
65
+ }
66
+
67
+ /** Register the host-side command; it never sends the command line to a chat model. */
68
+ export function registerEditImageCommand(
69
+ ctx: Context,
70
+ runtime: ImageGenerationRuntime,
71
+ resolve: () => AgentImageToolConfig,
72
+ pendingImages?: {
73
+ get: (sessionId: string) => ImageAttachmentRef | undefined
74
+ consume: (sessionId: string, ref: ImageAttachmentRef) => void
75
+ },
76
+ ): () => void {
77
+ return ctx.commands.register({
78
+ name: 'edit_image',
79
+ description: 'Edit the latest image in this conversation with the plugin image model',
80
+ // `images` is understood by the newer host command protocol. Keep the
81
+ // source compatible with older development-only command typings.
82
+ input: { hint: 'Describe how to modify the latest image', images: true } as { hint: string; images: boolean },
83
+ async handler(invocation): Promise<CommandResult> {
84
+ const prompt = invocation.rawInput.trim()
85
+ if (prompt === '') return { kind: 'error', text: '请提供图片修改描述,例如:/edit_image 把背景改成夜景' }
86
+ const invocationImage = imageInInvocation((invocation as typeof invocation & { attachments?: readonly unknown[] }).attachments)
87
+ // A staged composer image is newer than anything already committed to
88
+ // the session, so it must win when the user explicitly adds a preview
89
+ // or gallery image before running the command.
90
+ const pendingImage = pendingImages?.get(String(invocation.agent.id))
91
+ const durableImage = latestSessionImage(invocation.agent.session.deriveMessages())
92
+ const sourceImage = invocationImage ?? pendingImage ?? durableImage
93
+ if (sourceImage === undefined) return { kind: 'error', text: '当前对话没有可用图片,请先上传图片或把画廊图片加入对话。' }
94
+ try {
95
+ const task = await submitAgentImageEdit(ctx.attachments as Pick<AttachmentStore, 'readImage'>, runtime, resolve, {
96
+ prompt,
97
+ sourceImage,
98
+ signal: invocation.signal,
99
+ })
100
+ if (task.status === 'completed') {
101
+ if (pendingImage !== undefined) pendingImages?.consume(String(invocation.agent.id), pendingImage)
102
+ return { kind: 'success', text: '图片编辑已完成,可在 AI 生图面板查看结果。' }
103
+ }
104
+ return { kind: 'error', text: task.error ?? `图片编辑${task.status === 'cancelled' ? '已取消' : '失败'}。` }
105
+ } catch (error) {
106
+ return commandError(error)
107
+ }
108
+ },
109
+ })
110
+ }
package/src/engine.ts CHANGED
@@ -79,6 +79,15 @@ function isSeedream(model: string): boolean {
79
79
  return modelFamily(model) === 'seedream'
80
80
  }
81
81
 
82
+ /** Whether the model uses the official Zhipu image-generation contract. */
83
+ function isZhipuImage(model: string): boolean {
84
+ return modelFamily(model) === 'zhipu'
85
+ }
86
+
87
+ function isGlmImage(model: string): boolean {
88
+ return /^glm-image(?:-|$)/i.test(model.trim())
89
+ }
90
+
82
91
  /** Whether this is the official Volcengine Ark model naming convention. */
83
92
  function isVolcSeedream(model: string): boolean {
84
93
  return /^doubao-seedream(?:-|$)/i.test(model.trim())
@@ -163,6 +172,22 @@ function bareBase64(value: string): string {
163
172
  return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
164
173
  }
165
174
 
175
+ /** Whether a result URL carries cloud-storage signing credentials. */
176
+ function isPresignedUrl(value: string): boolean {
177
+ let url: URL
178
+ try {
179
+ url = new URL(value)
180
+ } catch {
181
+ return false
182
+ }
183
+ const params = new Set(Array.from(url.searchParams.keys(), key => key.toLowerCase()))
184
+ if (params.has('x-goog-signature') || params.has('x-goog-credential')) return true
185
+ if (params.has('x-amz-signature') || params.has('x-amz-credential')) return true
186
+ return params.has('signature') && (
187
+ params.has('expires') || params.has('googleaccessid') || params.has('awsaccesskeyid')
188
+ )
189
+ }
190
+
166
191
  /** Clamp the requested image count into the API-accepted range. */
167
192
  function clampCount(n: number): number {
168
193
  if (!Number.isFinite(n)) return 1
@@ -234,6 +259,18 @@ function effectiveParams(request: GenerateRequest): {
234
259
  response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
235
260
  }
236
261
  }
262
+ // Zhipu's official image API accepts OpenAI-style JSON but uses its own
263
+ // quality vocabulary. GLM-Image currently supports hd only; CogView uses
264
+ // the standard tier. Size remains a valid custom pixel size for both.
265
+ if (isZhipuImage(model)) {
266
+ return {
267
+ model,
268
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
269
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
270
+ : {},
271
+ quality: isGlmImage(model) ? 'hd' : 'standard',
272
+ }
273
+ }
237
274
  // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
238
275
  // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
239
276
  return {
@@ -261,9 +298,11 @@ async function normalizeItem(
261
298
  upstream: UpstreamConfig,
262
299
  ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
263
300
  const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
264
- if (typeof item.b64_json === 'string') {
301
+ if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
265
302
  const b64 = bareBase64(item.b64_json)
266
- return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
303
+ if (b64.trim() !== '') {
304
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
305
+ }
267
306
  }
268
307
  if (typeof item.url !== 'string' || item.url === '') {
269
308
  throw new ImageGenError('upstream image item has neither b64_json nor url')
@@ -278,9 +317,9 @@ async function normalizeItem(
278
317
  let response: Response
279
318
  try {
280
319
  response = await fetch(url, {
281
- headers: {
282
- ...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
283
- },
320
+ ...isPresignedUrl(url) || upstream.apiKey === ''
321
+ ? {}
322
+ : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
284
323
  signal: budget.signal,
285
324
  })
286
325
  } catch (error) {
@@ -443,6 +482,9 @@ export async function generateImage(upstream: UpstreamConfig, request: GenerateR
443
482
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
444
483
  if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
445
484
  if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
485
+ if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
486
+ throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
487
+ }
446
488
  const params = effectiveParams(request)
447
489
  const count = effectiveCount(request)
448
490
  const batches = await Promise.all(
@@ -63,6 +63,11 @@ interface StoredEntry {
63
63
  tags?: string[]
64
64
  channelId?: string
65
65
  channel?: string
66
+ workflow?: 'ecommerce'
67
+ projectId?: string
68
+ projectName?: string
69
+ slotKey?: string
70
+ slotLabel?: string
66
71
  }
67
72
 
68
73
  /** The index.json shape. */
@@ -140,6 +145,11 @@ function isStoredEntry(value: unknown): value is StoredEntry {
140
145
  return typeof entry.id === 'string'
141
146
  && typeof entry.createdAt === 'number'
142
147
  && (entry.mode === 'text' || entry.mode === 'edit')
148
+ && (entry.workflow === undefined || entry.workflow === 'ecommerce')
149
+ && (entry.projectId === undefined || typeof entry.projectId === 'string')
150
+ && (entry.projectName === undefined || typeof entry.projectName === 'string')
151
+ && (entry.slotKey === undefined || typeof entry.slotKey === 'string')
152
+ && (entry.slotLabel === undefined || typeof entry.slotLabel === 'string')
143
153
  && typeof entry.prompt === 'string'
144
154
  && Array.isArray(entry.images)
145
155
  && entry.images.every(image => {
@@ -177,6 +187,11 @@ function toWire(entry: StoredEntry): HistoryEntry {
177
187
  ...entry.tags === undefined ? {} : { tags: entry.tags },
178
188
  ...entry.channel === undefined ? {} : { channel: entry.channel },
179
189
  ...entry.channelId === undefined ? {} : { channelId: entry.channelId },
190
+ ...entry.workflow === undefined ? {} : { workflow: entry.workflow },
191
+ ...entry.projectId === undefined ? {} : { projectId: entry.projectId },
192
+ ...entry.projectName === undefined ? {} : { projectName: entry.projectName },
193
+ ...entry.slotKey === undefined ? {} : { slotKey: entry.slotKey },
194
+ ...entry.slotLabel === undefined ? {} : { slotLabel: entry.slotLabel },
180
195
  }
181
196
  }
182
197
 
@@ -231,6 +246,11 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
231
246
  ...input.refName === undefined ? {} : { refName: input.refName },
232
247
  ...input.channelId === undefined ? {} : { channelId: input.channelId },
233
248
  ...input.channel === undefined ? {} : { channel: input.channel },
249
+ ...input.workflow === undefined ? {} : { workflow: input.workflow },
250
+ ...input.projectId === undefined ? {} : { projectId: input.projectId },
251
+ ...input.projectName === undefined ? {} : { projectName: input.projectName },
252
+ ...input.slotKey === undefined ? {} : { slotKey: input.slotKey },
253
+ ...input.slotLabel === undefined ? {} : { slotLabel: input.slotLabel },
234
254
  }
235
255
  const merged = [entry, ...await readIndex()]
236
256
  await writeIndex(merged)
@@ -38,7 +38,9 @@ export class ImageGenerationRuntime {
38
38
  private readonly resolve: () => ChannelsView,
39
39
  private readonly history: HistorySink = { append: appendHistory },
40
40
  ) {
41
- this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal))
41
+ // A comparison can contain up to four models; let those tasks run at the
42
+ // same time while still applying a small host-wide concurrency limit.
43
+ this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal), 4)
42
44
  }
43
45
 
44
46
  async run(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResult> {
@@ -66,10 +68,17 @@ export class ImageGenerationRuntime {
66
68
  ...request.refName === undefined ? {} : { refName: request.refName },
67
69
  ...request.channelId === undefined ? {} : { channelId: request.channelId },
68
70
  ...request.channel === undefined ? {} : { channel: request.channel },
71
+ ...request.comparisonId === undefined ? {} : { comparisonId: request.comparisonId },
72
+ ...request.comparisonModels === undefined ? {} : { comparisonModels: request.comparisonModels },
73
+ ...request.workflow === undefined ? {} : { workflow: request.workflow },
74
+ ...request.projectId === undefined ? {} : { projectId: request.projectId },
75
+ ...request.projectName === undefined ? {} : { projectName: request.projectName },
76
+ ...request.slotKey === undefined ? {} : { slotKey: request.slotKey },
77
+ ...request.slotLabel === undefined ? {} : { slotLabel: request.slotLabel },
69
78
  })
70
79
  return { ...result, history }
71
80
  } catch (error) {
72
81
  return { ...result, historyError: error instanceof Error ? error.message : String(error) }
73
82
  }
74
83
  }
75
- }
84
+ }