@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,48 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared host-side generation runtime. Both the browser routes and Agent tools
|
|
3
|
-
* submit to this one queue so persisted history and cancellation semantics stay
|
|
4
|
-
* identical regardless of where a request originated.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-side generation runtime. Both the browser routes and Agent tools
|
|
3
|
+
* submit to this one queue so persisted history and cancellation semantics stay
|
|
4
|
+
* identical regardless of where a request originated.
|
|
5
|
+
*
|
|
6
|
+
* Requests carry a channel id (host-filled by the route/tool resolution); the
|
|
7
|
+
* runtime picks that channel's upstream credentials, otherwise the default
|
|
8
|
+
* channel, and records a channel snapshot on the history entry so usage
|
|
9
|
+
* counters and filters survive channel deletion.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { generateImage, ImageGenError, type UpstreamConfig } from './engine.ts'
|
|
14
|
+
import { appendHistory } from './history-store.ts'
|
|
15
|
+
import { GenerationTaskQueue } from './task-queue.ts'
|
|
16
|
+
import type { ChannelConfig, GenerateRequest, GenerateResult, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
17
|
+
|
|
18
|
+
/** A channel with its resolved API key (the settings doc holds the key
|
|
19
|
+
* separately so redacted reads never expose it). */
|
|
20
|
+
export interface RuntimeChannel extends ChannelConfig {
|
|
21
|
+
apiKey: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The resolved channels view the runtime picks upstream credentials from. */
|
|
25
|
+
export interface ChannelsView {
|
|
26
|
+
channels: RuntimeChannel[]
|
|
27
|
+
defaultChannelId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HistorySink {
|
|
31
|
+
append(entry: HistoryEntryInput): Promise<HistoryEntry[]>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ImageGenerationRuntime {
|
|
35
|
+
readonly queue: GenerationTaskQueue
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly resolve: () => ChannelsView,
|
|
39
|
+
private readonly history: HistorySink = { append: appendHistory },
|
|
40
|
+
) {
|
|
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)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async run(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResult> {
|
|
47
|
+
const view = this.resolve()
|
|
48
|
+
const channel = view.channels.find(candidate => candidate.id === request.channelId)
|
|
49
|
+
?? view.channels.find(candidate => candidate.id === view.defaultChannelId)
|
|
50
|
+
?? view.channels[0]
|
|
51
|
+
if (channel === undefined) {
|
|
52
|
+
throw new ImageGenError('尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥', 'no-channels')
|
|
53
|
+
}
|
|
54
|
+
const upstream: UpstreamConfig = { apiUrl: channel.apiUrl, apiKey: channel.apiKey }
|
|
55
|
+
const result = await generateImage(upstream, request, { signal })
|
|
56
|
+
try {
|
|
57
|
+
const history = await this.history.append({
|
|
58
|
+
id: randomUUID(),
|
|
59
|
+
createdAt: Date.now(),
|
|
60
|
+
mode: request.mode,
|
|
61
|
+
model: request.model,
|
|
62
|
+
prompt: request.prompt,
|
|
63
|
+
size: request.size,
|
|
64
|
+
quality: request.quality,
|
|
65
|
+
detail: request.detail,
|
|
66
|
+
n: request.n,
|
|
67
|
+
images: result.images,
|
|
68
|
+
...request.refName === undefined ? {} : { refName: request.refName },
|
|
69
|
+
...request.channelId === undefined ? {} : { channelId: request.channelId },
|
|
70
|
+
...request.channel === undefined ? {} : { channel: request.channel },
|
|
71
|
+
...request.comparisonId === undefined ? {} : { comparisonId: request.comparisonId },
|
|
72
|
+
...request.comparisonModels === undefined ? {} : { comparisonModels: request.comparisonModels },
|
|
73
|
+
})
|
|
74
|
+
return { ...result, history }
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { ...result, historyError: error instanceof Error ? error.message : String(error) }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/history-store.ts
CHANGED
|
@@ -1,238 +1,250 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-persisted generation history: images are stored as individual files
|
|
3
|
-
* under ~/.dsh/dsh-imagegen/images/ and an index.json keeps the metadata +
|
|
4
|
-
* file names. This makes the history survive across browsers/devices that
|
|
5
|
-
* connect to the same DSH host, and keeps list responses small (the browser
|
|
6
|
-
* loads image bytes lazily through the history image route).
|
|
7
|
-
*
|
|
8
|
-
* Framework-free (node:fs only) so the route layer can drive it directly.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { promises as fs } from 'node:fs'
|
|
12
|
-
import { homedir } from 'node:os'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
import { HISTORY_MAX, type GenerateMode, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
|
|
15
|
-
|
|
16
|
-
const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
17
|
-
const INDEX_PATH = path.join(HISTORY_DIR, 'index.json')
|
|
18
|
-
const IMAGES_DIR = path.join(HISTORY_DIR, 'images')
|
|
19
|
-
|
|
20
|
-
// History mutations read and replace one shared index. Serialize them so
|
|
21
|
-
// overlapping requests cannot each read an old index and lose the other's row.
|
|
22
|
-
let pendingMutation: Promise<void> = Promise.resolve()
|
|
23
|
-
|
|
24
|
-
function mutateHistory<T>(operation: () => Promise<T>): Promise<T> {
|
|
25
|
-
const next = pendingMutation.then(operation, operation)
|
|
26
|
-
pendingMutation = next.then(() => undefined, () => undefined)
|
|
27
|
-
return next
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One image's on-disk record (file name + mime, never base64). */
|
|
31
|
-
interface StoredImage {
|
|
32
|
-
file: string
|
|
33
|
-
mime: string
|
|
34
|
-
revisedPrompt?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** One entry's on-disk record. */
|
|
38
|
-
interface StoredEntry {
|
|
39
|
-
id: string
|
|
40
|
-
createdAt: number
|
|
41
|
-
mode: GenerateMode
|
|
42
|
-
model: string
|
|
43
|
-
prompt: string
|
|
44
|
-
size: string
|
|
45
|
-
quality: string
|
|
46
|
-
detail: string
|
|
47
|
-
n: number
|
|
48
|
-
images: StoredImage[]
|
|
49
|
-
refName?: string
|
|
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
|
-
await
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
&& entry.
|
|
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
|
-
const
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
await writeIndex(kept)
|
|
213
|
-
return kept.map(toWire)
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Remove
|
|
218
|
-
export async function
|
|
219
|
-
return mutateHistory(async () => {
|
|
220
|
-
const previous = await readIndex()
|
|
221
|
-
|
|
222
|
-
await
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Host-persisted generation history: images are stored as individual files
|
|
3
|
+
* under ~/.dsh/dsh-imagegen/images/ and an index.json keeps the metadata +
|
|
4
|
+
* file names. This makes the history survive across browsers/devices that
|
|
5
|
+
* connect to the same DSH host, and keeps list responses small (the browser
|
|
6
|
+
* loads image bytes lazily through the history image route).
|
|
7
|
+
*
|
|
8
|
+
* Framework-free (node:fs only) so the route layer can drive it directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { HISTORY_MAX, type GenerateMode, type HistoryEntry, type HistoryEntryInput } from './protocol.ts'
|
|
15
|
+
|
|
16
|
+
const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
17
|
+
const INDEX_PATH = path.join(HISTORY_DIR, 'index.json')
|
|
18
|
+
const IMAGES_DIR = path.join(HISTORY_DIR, 'images')
|
|
19
|
+
|
|
20
|
+
// History mutations read and replace one shared index. Serialize them so
|
|
21
|
+
// overlapping requests cannot each read an old index and lose the other's row.
|
|
22
|
+
let pendingMutation: Promise<void> = Promise.resolve()
|
|
23
|
+
|
|
24
|
+
function mutateHistory<T>(operation: () => Promise<T>): Promise<T> {
|
|
25
|
+
const next = pendingMutation.then(operation, operation)
|
|
26
|
+
pendingMutation = next.then(() => undefined, () => undefined)
|
|
27
|
+
return next
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One image's on-disk record (file name + mime, never base64). */
|
|
31
|
+
interface StoredImage {
|
|
32
|
+
file: string
|
|
33
|
+
mime: string
|
|
34
|
+
revisedPrompt?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One entry's on-disk record. */
|
|
38
|
+
interface StoredEntry {
|
|
39
|
+
id: string
|
|
40
|
+
createdAt: number
|
|
41
|
+
mode: GenerateMode
|
|
42
|
+
model: string
|
|
43
|
+
prompt: string
|
|
44
|
+
size: string
|
|
45
|
+
quality: string
|
|
46
|
+
detail: string
|
|
47
|
+
n: number
|
|
48
|
+
images: StoredImage[]
|
|
49
|
+
refName?: string
|
|
50
|
+
channelId?: string
|
|
51
|
+
channel?: string
|
|
52
|
+
comparisonId?: string
|
|
53
|
+
comparisonModels?: string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The index.json shape. */
|
|
57
|
+
interface IndexFile {
|
|
58
|
+
entries: StoredEntry[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** File extension for a MIME type (image file names). */
|
|
62
|
+
function extensionOf(mime: string): string {
|
|
63
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
64
|
+
case 'image/jpeg': return 'jpg'
|
|
65
|
+
case 'image/webp': return 'webp'
|
|
66
|
+
case 'image/gif': return 'gif'
|
|
67
|
+
default: return 'png'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** MIME type for a stored image file name (image route responses). */
|
|
72
|
+
function mimeOfFile(file: string): string {
|
|
73
|
+
const ext = path.extname(file).toLowerCase()
|
|
74
|
+
switch (ext) {
|
|
75
|
+
case '.jpg':
|
|
76
|
+
case '.jpeg': return 'image/jpeg'
|
|
77
|
+
case '.webp': return 'image/webp'
|
|
78
|
+
case '.gif': return 'image/gif'
|
|
79
|
+
default: return 'image/png'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Sanitize an entry id for use as a file-name prefix. */
|
|
84
|
+
function safeId(id: string): string {
|
|
85
|
+
const cleaned = id.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
86
|
+
return cleaned === '' ? 'entry' : cleaned
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Ensure the storage directories exist. */
|
|
90
|
+
async function ensureDirs(): Promise<void> {
|
|
91
|
+
await fs.mkdir(IMAGES_DIR, { recursive: true })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Read the index, tolerating a missing/corrupt file. */
|
|
95
|
+
async function readIndex(): Promise<StoredEntry[]> {
|
|
96
|
+
try {
|
|
97
|
+
const raw = await fs.readFile(INDEX_PATH, 'utf8')
|
|
98
|
+
const parsed: unknown = JSON.parse(raw)
|
|
99
|
+
if (parsed === null || typeof parsed !== 'object') return []
|
|
100
|
+
const entries = (parsed as { entries?: unknown }).entries
|
|
101
|
+
if (!Array.isArray(entries)) return []
|
|
102
|
+
return entries.filter(isStoredEntry)
|
|
103
|
+
} catch {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Persist the index. */
|
|
109
|
+
async function writeIndex(entries: StoredEntry[]): Promise<void> {
|
|
110
|
+
await ensureDirs()
|
|
111
|
+
const payload: IndexFile = { entries }
|
|
112
|
+
const tmp = `${INDEX_PATH}.tmp-${process.pid}`
|
|
113
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
114
|
+
await fs.rename(tmp, INDEX_PATH)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Structural guard for a stored entry. */
|
|
118
|
+
function isStoredEntry(value: unknown): value is StoredEntry {
|
|
119
|
+
if (value === null || typeof value !== 'object') return false
|
|
120
|
+
const entry = value as Record<string, unknown>
|
|
121
|
+
return typeof entry.id === 'string'
|
|
122
|
+
&& typeof entry.createdAt === 'number'
|
|
123
|
+
&& (entry.mode === 'text' || entry.mode === 'edit')
|
|
124
|
+
&& typeof entry.prompt === 'string'
|
|
125
|
+
&& Array.isArray(entry.images)
|
|
126
|
+
&& entry.images.every(image => {
|
|
127
|
+
if (image === null || typeof image !== 'object') return false
|
|
128
|
+
const record = image as Record<string, unknown>
|
|
129
|
+
return typeof record.file === 'string' && typeof record.mime === 'string'
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Remove one entry's image files (best effort). */
|
|
134
|
+
async function removeEntryFiles(entry: StoredEntry): Promise<void> {
|
|
135
|
+
for (const image of entry.images) {
|
|
136
|
+
try { await fs.rm(path.join(IMAGES_DIR, image.file), { force: true }) } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Project a stored entry onto the wire shape (image URLs). */
|
|
141
|
+
function toWire(entry: StoredEntry): HistoryEntry {
|
|
142
|
+
return {
|
|
143
|
+
id: entry.id,
|
|
144
|
+
createdAt: entry.createdAt,
|
|
145
|
+
mode: entry.mode,
|
|
146
|
+
model: entry.model,
|
|
147
|
+
prompt: entry.prompt,
|
|
148
|
+
size: entry.size,
|
|
149
|
+
quality: entry.quality,
|
|
150
|
+
detail: entry.detail,
|
|
151
|
+
n: entry.n,
|
|
152
|
+
images: entry.images.map(image => ({
|
|
153
|
+
url: `/api/dsh-imagegen/history/image/${image.file}`,
|
|
154
|
+
mime: image.mime,
|
|
155
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
156
|
+
})),
|
|
157
|
+
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
158
|
+
...entry.channel === undefined ? {} : { channel: entry.channel },
|
|
159
|
+
...entry.channelId === undefined ? {} : { channelId: entry.channelId },
|
|
160
|
+
...entry.comparisonId === undefined ? {} : { comparisonId: entry.comparisonId },
|
|
161
|
+
...entry.comparisonModels === undefined ? {} : { comparisonModels: entry.comparisonModels },
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** List the persisted history, newest first, as wire entries. */
|
|
166
|
+
export async function listHistory(): Promise<HistoryEntry[]> {
|
|
167
|
+
const entries = await readIndex()
|
|
168
|
+
return entries.map(toWire)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Append one generation, evicting the oldest beyond HISTORY_MAX. */
|
|
172
|
+
export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEntry[]> {
|
|
173
|
+
return mutateHistory(async () => {
|
|
174
|
+
await ensureDirs()
|
|
175
|
+
const prefix = safeId(input.id)
|
|
176
|
+
const storedImages: StoredImage[] = []
|
|
177
|
+
try {
|
|
178
|
+
for (let index = 0; index < input.images.length; index++) {
|
|
179
|
+
const image = input.images[index]!
|
|
180
|
+
const file = `${prefix}-${index}.${extensionOf(image.mime)}`
|
|
181
|
+
await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
|
|
182
|
+
storedImages.push({
|
|
183
|
+
file,
|
|
184
|
+
mime: image.mime,
|
|
185
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
await removeEntryFiles({ images: storedImages } as StoredEntry)
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
const entry: StoredEntry = {
|
|
193
|
+
id: input.id,
|
|
194
|
+
createdAt: input.createdAt,
|
|
195
|
+
mode: input.mode,
|
|
196
|
+
model: input.model,
|
|
197
|
+
prompt: input.prompt,
|
|
198
|
+
size: input.size,
|
|
199
|
+
quality: input.quality,
|
|
200
|
+
detail: input.detail,
|
|
201
|
+
n: input.n,
|
|
202
|
+
images: storedImages,
|
|
203
|
+
...input.refName === undefined ? {} : { refName: input.refName },
|
|
204
|
+
...input.channelId === undefined ? {} : { channelId: input.channelId },
|
|
205
|
+
...input.channel === undefined ? {} : { channel: input.channel },
|
|
206
|
+
...input.comparisonId === undefined ? {} : { comparisonId: input.comparisonId },
|
|
207
|
+
...input.comparisonModels === undefined ? {} : { comparisonModels: input.comparisonModels },
|
|
208
|
+
}
|
|
209
|
+
const merged = [entry, ...await readIndex()]
|
|
210
|
+
const kept = merged.slice(0, HISTORY_MAX)
|
|
211
|
+
for (const dropped of merged.slice(HISTORY_MAX)) await removeEntryFiles(dropped)
|
|
212
|
+
await writeIndex(kept)
|
|
213
|
+
return kept.map(toWire)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Remove one entry (and its image files). */
|
|
218
|
+
export async function removeHistory(id: string): Promise<HistoryEntry[]> {
|
|
219
|
+
return mutateHistory(async () => {
|
|
220
|
+
const previous = await readIndex()
|
|
221
|
+
const target = previous.find(entry => entry.id === id)
|
|
222
|
+
if (target !== undefined) await removeEntryFiles(target)
|
|
223
|
+
const kept = previous.filter(entry => entry.id !== id)
|
|
224
|
+
await writeIndex(kept)
|
|
225
|
+
return kept.map(toWire)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Remove every entry (and all image files). */
|
|
230
|
+
export async function clearHistory(): Promise<HistoryEntry[]> {
|
|
231
|
+
return mutateHistory(async () => {
|
|
232
|
+
const previous = await readIndex()
|
|
233
|
+
for (const entry of previous) await removeEntryFiles(entry)
|
|
234
|
+
await writeIndex([])
|
|
235
|
+
return []
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Read one stored image file by its (validated) file name. */
|
|
240
|
+
export async function readHistoryImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
241
|
+
// Only accept <id>-<index>.<png|jpg|jpeg|webp|gif> — the exact names this
|
|
242
|
+
// store writes — so the route can never escape the images directory.
|
|
243
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
|
|
244
|
+
try {
|
|
245
|
+
const data = await fs.readFile(path.join(IMAGES_DIR, file))
|
|
246
|
+
return { data, mime: mimeOfFile(file) }
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Detect the supported raster format from its encoded bytes. */
|
|
2
|
+
export type SupportedImageMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
3
|
+
|
|
4
|
+
export function detectImageMime(data: Uint8Array): SupportedImageMime | undefined {
|
|
5
|
+
const startsWith = (...bytes: number[]): boolean => bytes.every((value, index) => data[index] === value)
|
|
6
|
+
if (startsWith(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'image/png'
|
|
7
|
+
if (startsWith(0xff, 0xd8, 0xff)) return 'image/jpeg'
|
|
8
|
+
if (startsWith(0x47, 0x49, 0x46, 0x38, 0x37, 0x61) || startsWith(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)) return 'image/gif'
|
|
9
|
+
if (startsWith(0x52, 0x49, 0x46, 0x46) && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) return 'image/webp'
|
|
10
|
+
return undefined
|
|
11
|
+
}
|