@dickpy/dsh-imagegen 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
|
@@ -1,336 +1,336 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prompt-template library overlay: a searchable, category-filtered gallery of
|
|
3
|
-
* the bundled awesome-gpt-image-2 cases. The case list is served by the host
|
|
4
|
-
* (bundled snapshot, optionally refreshed online); reference images load
|
|
5
|
-
* lazily through the host's caching proxy, so browsing progressively mirrors
|
|
6
|
-
* the gallery onto the local disk. Picking a template hands its prompt back
|
|
7
|
-
* to the studio form.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
11
|
-
import { createPortal } from 'react-dom'
|
|
12
|
-
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
|
-
import type { ImageGenApi } from './api.ts'
|
|
14
|
-
import { errorMessage, tt } from './helpers.ts'
|
|
15
|
-
import { TEMPLATES_API, type TemplateCase, type TemplateListResult } from '../protocol.ts'
|
|
16
|
-
import css from './templates.module.css'
|
|
17
|
-
|
|
18
|
-
/** Concurrent image downloads while caching the whole gallery offline. */
|
|
19
|
-
const CACHE_ALL_CONCURRENCY = 4
|
|
20
|
-
|
|
21
|
-
/** Same-origin URL of one case's reference image (host caching proxy). */
|
|
22
|
-
function imageUrlOf(item: TemplateCase): string {
|
|
23
|
-
return `${TEMPLATES_API.image}/${encodeURIComponent(item.image)}`
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A card thumbnail that falls back to a placeholder when the proxy 404s. */
|
|
27
|
-
function TemplateThumb(props: { item: TemplateCase }) {
|
|
28
|
-
const [failed, setFailed] = useState(false)
|
|
29
|
-
if (props.item.image === '' || failed) {
|
|
30
|
-
return (
|
|
31
|
-
<span className={css.thumbPlaceholder} aria-hidden="true">
|
|
32
|
-
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
|
33
|
-
</span>
|
|
34
|
-
)
|
|
35
|
-
}
|
|
36
|
-
return (
|
|
37
|
-
<img
|
|
38
|
-
className={css.thumb}
|
|
39
|
-
src={imageUrlOf(props.item)}
|
|
40
|
-
alt={props.item.title}
|
|
41
|
-
loading="lazy"
|
|
42
|
-
onError={() => { setFailed(true) }}
|
|
43
|
-
/>
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The template-library modal. Rendered through a portal above the studio. */
|
|
48
|
-
export function TemplateLibrary(props: {
|
|
49
|
-
api: ImageGenApi
|
|
50
|
-
/** Hand a picked prompt back to the studio form and close the library. */
|
|
51
|
-
onUse: (prompt: string) => void
|
|
52
|
-
onClose: () => void
|
|
53
|
-
}) {
|
|
54
|
-
const { api, onUse, onClose } = props
|
|
55
|
-
const [list, setList] = useState<TemplateListResult | null>(null)
|
|
56
|
-
const [loadError, setLoadError] = useState<string | null>(null)
|
|
57
|
-
const [query, setQuery] = useState('')
|
|
58
|
-
const [category, setCategory] = useState('')
|
|
59
|
-
const [selected, setSelected] = useState<TemplateCase | null>(null)
|
|
60
|
-
const [copied, setCopied] = useState(false)
|
|
61
|
-
const [refreshing, setRefreshing] = useState(false)
|
|
62
|
-
const [notice, setNotice] = useState<string | null>(null)
|
|
63
|
-
const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 })
|
|
64
|
-
const searchRef = useRef<HTMLInputElement>(null)
|
|
65
|
-
const load = (): void => {
|
|
66
|
-
api.templatesList()
|
|
67
|
-
.then(result => { setList(result); setLoadError(null) })
|
|
68
|
-
.catch(caught => { setLoadError(errorMessage(caught)) })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Load once on open; focus the search box for immediate typing.
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
load()
|
|
74
|
-
searchRef.current?.focus()
|
|
75
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
-
}, [])
|
|
77
|
-
|
|
78
|
-
const categories = useMemo(() => {
|
|
79
|
-
if (list === null) return [] as Array<{ key: string; label: string; count: number }>
|
|
80
|
-
const counts = new Map<string, { label: string; count: number }>()
|
|
81
|
-
for (const item of list.cases) {
|
|
82
|
-
const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 }
|
|
83
|
-
entry.count += 1
|
|
84
|
-
counts.set(item.category, entry)
|
|
85
|
-
}
|
|
86
|
-
return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count }))
|
|
87
|
-
}, [list])
|
|
88
|
-
|
|
89
|
-
const filtered = useMemo(() => {
|
|
90
|
-
if (list === null) return [] as TemplateCase[]
|
|
91
|
-
const needle = query.trim().toLowerCase()
|
|
92
|
-
return list.cases.filter(item => {
|
|
93
|
-
if (category !== '' && item.category !== category) return false
|
|
94
|
-
if (needle === '') return true
|
|
95
|
-
return item.title.toLowerCase().includes(needle)
|
|
96
|
-
|| item.prompt.toLowerCase().includes(needle)
|
|
97
|
-
|| item.sourceLabel.toLowerCase().includes(needle)
|
|
98
|
-
})
|
|
99
|
-
}, [list, query, category])
|
|
100
|
-
|
|
101
|
-
// Escape backs out of the detail view first, then closes the modal.
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
const onKey = (event: KeyboardEvent): void => {
|
|
104
|
-
if (event.key !== 'Escape') return
|
|
105
|
-
event.stopPropagation()
|
|
106
|
-
if (selected !== null) setSelected(null)
|
|
107
|
-
else onClose()
|
|
108
|
-
}
|
|
109
|
-
window.addEventListener('keydown', onKey, true)
|
|
110
|
-
return () => window.removeEventListener('keydown', onKey, true)
|
|
111
|
-
}, [selected, onClose])
|
|
112
|
-
|
|
113
|
-
const refresh = async (): Promise<void> => {
|
|
114
|
-
if (refreshing) return
|
|
115
|
-
setRefreshing(true)
|
|
116
|
-
setNotice(null)
|
|
117
|
-
try {
|
|
118
|
-
const result = await api.templatesRefresh()
|
|
119
|
-
const reloaded = await api.templatesList()
|
|
120
|
-
setList(reloaded)
|
|
121
|
-
setLoadError(null)
|
|
122
|
-
setNotice(tt('templates.refreshed', { count: result.total }))
|
|
123
|
-
} catch (caught) {
|
|
124
|
-
setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) }))
|
|
125
|
-
} finally {
|
|
126
|
-
setRefreshing(false)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Mirror every reference image through the host cache (offline browsing). */
|
|
131
|
-
const cacheAllImages = async (): Promise<void> => {
|
|
132
|
-
if (cacheAll.running || list === null) return
|
|
133
|
-
const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))]
|
|
134
|
-
setCacheAll({ running: true, done: 0, total: files.length })
|
|
135
|
-
let index = 0
|
|
136
|
-
const worker = async (): Promise<void> => {
|
|
137
|
-
while (index < files.length) {
|
|
138
|
-
const file = files[index]!
|
|
139
|
-
index += 1
|
|
140
|
-
try {
|
|
141
|
-
await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(file)}`)
|
|
142
|
-
} catch { /* individual failures are retried on the next run */ }
|
|
143
|
-
setCacheAll(current => ({ ...current, done: current.done + 1 }))
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
await Promise.all(Array.from({ length: CACHE_ALL_CONCURRENCY }, () => worker()))
|
|
147
|
-
setCacheAll({ running: false, done: files.length, total: files.length })
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const copyPrompt = async (text: string): Promise<void> => {
|
|
151
|
-
try {
|
|
152
|
-
if (navigator.clipboard?.writeText !== undefined) {
|
|
153
|
-
await navigator.clipboard.writeText(text)
|
|
154
|
-
} else {
|
|
155
|
-
const textarea = document.createElement('textarea')
|
|
156
|
-
textarea.value = text
|
|
157
|
-
textarea.style.position = 'fixed'
|
|
158
|
-
textarea.style.opacity = '0'
|
|
159
|
-
document.body.appendChild(textarea)
|
|
160
|
-
textarea.select()
|
|
161
|
-
const copiedOk = document.execCommand('copy')
|
|
162
|
-
textarea.remove()
|
|
163
|
-
if (!copiedOk) throw new Error('copy failed')
|
|
164
|
-
}
|
|
165
|
-
setCopied(true)
|
|
166
|
-
window.setTimeout(() => { setCopied(false) }, 1800)
|
|
167
|
-
} catch {
|
|
168
|
-
setCopied(false)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const originLabel = list === null ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
|
|
173
|
-
|
|
174
|
-
return createPortal(
|
|
175
|
-
<div className={css.overlay} role="dialog" aria-modal="true" aria-label={tt('templates.title')} onClick={onClose}>
|
|
176
|
-
<section className={css.shell} onClick={(event) => { event.stopPropagation() }}>
|
|
177
|
-
<header className={css.header}>
|
|
178
|
-
<span className={css.heading}>
|
|
179
|
-
<h3 className={css.title}>{tt('templates.title')}</h3>
|
|
180
|
-
{list !== null ? (
|
|
181
|
-
<span className={css.meta}>{tt('templates.meta', { count: list.total, origin: originLabel })}</span>
|
|
182
|
-
) : null}
|
|
183
|
-
</span>
|
|
184
|
-
<span className={css.headerActions}>
|
|
185
|
-
<Button variant="outline" size="sm" disabled={refreshing || cacheAll.running} onClick={() => { void refresh() }}>
|
|
186
|
-
{refreshing ? tt('templates.refreshing') : tt('templates.refresh')}
|
|
187
|
-
</Button>
|
|
188
|
-
<Button
|
|
189
|
-
variant="outline"
|
|
190
|
-
size="sm"
|
|
191
|
-
disabled={list === null || cacheAll.running}
|
|
192
|
-
title={tt('templates.cacheAllHint')}
|
|
193
|
-
onClick={() => { void cacheAllImages() }}
|
|
194
|
-
>
|
|
195
|
-
{cacheAll.running
|
|
196
|
-
? tt('templates.caching', { done: cacheAll.done, total: cacheAll.total })
|
|
197
|
-
: cacheAll.total > 0 && cacheAll.done === cacheAll.total
|
|
198
|
-
? tt('templates.cached')
|
|
199
|
-
: tt('templates.cacheAll')}
|
|
200
|
-
</Button>
|
|
201
|
-
<button type="button" className={css.close} aria-label={tt('templates.close')} title={tt('templates.close')} onClick={onClose}>
|
|
202
|
-
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
203
|
-
</button>
|
|
204
|
-
</span>
|
|
205
|
-
</header>
|
|
206
|
-
|
|
207
|
-
<div className={css.toolbar}>
|
|
208
|
-
<input
|
|
209
|
-
ref={searchRef}
|
|
210
|
-
type="search"
|
|
211
|
-
className={css.search}
|
|
212
|
-
placeholder={tt('templates.search')}
|
|
213
|
-
value={query}
|
|
214
|
-
onChange={(event) => { setQuery(event.target.value) }}
|
|
215
|
-
/>
|
|
216
|
-
<div className={css.categoryRow}>
|
|
217
|
-
<button
|
|
218
|
-
type="button"
|
|
219
|
-
className={css.categoryPill}
|
|
220
|
-
data-active={category === '' ? '' : undefined}
|
|
221
|
-
onClick={() => { setCategory('') }}
|
|
222
|
-
>
|
|
223
|
-
{tt('templates.all')}{list !== null ? ` ${list.total}` : ''}
|
|
224
|
-
</button>
|
|
225
|
-
{categories.map(entry => (
|
|
226
|
-
<button
|
|
227
|
-
key={entry.key}
|
|
228
|
-
type="button"
|
|
229
|
-
className={css.categoryPill}
|
|
230
|
-
data-active={category === entry.key ? '' : undefined}
|
|
231
|
-
onClick={() => { setCategory(entry.key) }}
|
|
232
|
-
>
|
|
233
|
-
{entry.label} {entry.count}
|
|
234
|
-
</button>
|
|
235
|
-
))}
|
|
236
|
-
</div>
|
|
237
|
-
</div>
|
|
238
|
-
|
|
239
|
-
{notice !== null ? <div className={css.notice} role="status">{notice}</div> : null}
|
|
240
|
-
|
|
241
|
-
<div className={css.body}>
|
|
242
|
-
{list === null && loadError === null ? (
|
|
243
|
-
<div className={css.state} role="status">
|
|
244
|
-
<span className={css.spinner} />
|
|
245
|
-
<span>{tt('templates.loading')}</span>
|
|
246
|
-
</div>
|
|
247
|
-
) : null}
|
|
248
|
-
|
|
249
|
-
{loadError !== null ? (
|
|
250
|
-
<div className={css.state} role="alert">
|
|
251
|
-
<span>{tt('templates.loadFailed', { error: loadError })}</span>
|
|
252
|
-
<Button variant="outline" size="sm" onClick={() => { setLoadError(null); setList(null); load() }}>
|
|
253
|
-
{tt('templates.retry')}
|
|
254
|
-
</Button>
|
|
255
|
-
</div>
|
|
256
|
-
) : null}
|
|
257
|
-
|
|
258
|
-
{list !== null && filtered.length === 0 ? (
|
|
259
|
-
<div className={css.state}>{tt('templates.empty')}</div>
|
|
260
|
-
) : null}
|
|
261
|
-
|
|
262
|
-
{list !== null && filtered.length > 0 ? (
|
|
263
|
-
<div className={css.grid}>
|
|
264
|
-
{filtered.map(item => (
|
|
265
|
-
<button
|
|
266
|
-
key={item.id}
|
|
267
|
-
type="button"
|
|
268
|
-
className={css.card}
|
|
269
|
-
onClick={() => { setSelected(item); setCopied(false) }}
|
|
270
|
-
>
|
|
271
|
-
<span className={css.thumbWrap}>
|
|
272
|
-
<TemplateThumb item={item} />
|
|
273
|
-
{item.featured ? <span className={css.featuredBadge}>{tt('templates.featured')}</span> : null}
|
|
274
|
-
</span>
|
|
275
|
-
<span className={css.cardBody}>
|
|
276
|
-
<span className={css.cardTitle}>{item.title}</span>
|
|
277
|
-
<span className={css.cardMeta}>
|
|
278
|
-
<span className={css.cardCategory}>{item.categoryZh || item.category}</span>
|
|
279
|
-
{item.sourceLabel !== '' ? <span className={css.cardSource}>{item.sourceLabel}</span> : null}
|
|
280
|
-
</span>
|
|
281
|
-
</span>
|
|
282
|
-
</button>
|
|
283
|
-
))}
|
|
284
|
-
</div>
|
|
285
|
-
) : null}
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
<footer className={css.footer}>
|
|
289
|
-
<span className={css.attribution}>{tt('templates.attribution')}</span>
|
|
290
|
-
<a className={css.sourceLink} href="https://vibeui.top/" target="_blank" rel="noreferrer">
|
|
291
|
-
{tt('templates.source')}
|
|
292
|
-
</a>
|
|
293
|
-
</footer>
|
|
294
|
-
</section>
|
|
295
|
-
|
|
296
|
-
{selected !== null ? (
|
|
297
|
-
<div className={css.detailOverlay} onClick={() => { setSelected(null) }}>
|
|
298
|
-
<section className={css.detail} onClick={(event) => { event.stopPropagation() }}>
|
|
299
|
-
<div className={css.detailMedia}>
|
|
300
|
-
{selected.image !== '' ? (
|
|
301
|
-
<img className={css.detailImage} src={imageUrlOf(selected)} alt={selected.title} />
|
|
302
|
-
) : (
|
|
303
|
-
<span className={css.thumbPlaceholder} aria-hidden="true" />
|
|
304
|
-
)}
|
|
305
|
-
</div>
|
|
306
|
-
<div className={css.detailInfo}>
|
|
307
|
-
<h4 className={css.detailTitle}>{selected.title}</h4>
|
|
308
|
-
<div className={css.detailMeta}>
|
|
309
|
-
<span className={css.cardCategory}>{selected.categoryZh || selected.category}</span>
|
|
310
|
-
{selected.sourceUrl !== '' ? (
|
|
311
|
-
<a className={css.detailLink} href={selected.sourceUrl} target="_blank" rel="noreferrer">{selected.sourceLabel || selected.sourceUrl}</a>
|
|
312
|
-
) : null}
|
|
313
|
-
{selected.githubUrl !== '' ? (
|
|
314
|
-
<a className={css.detailLink} href={selected.githubUrl} target="_blank" rel="noreferrer">GitHub</a>
|
|
315
|
-
) : null}
|
|
316
|
-
</div>
|
|
317
|
-
<pre className={css.detailPrompt}>{selected.prompt}</pre>
|
|
318
|
-
<div className={css.detailActions}>
|
|
319
|
-
<Button variant="primary" size="md" onClick={() => { onUse(selected.prompt) }}>
|
|
320
|
-
{tt('templates.use')}
|
|
321
|
-
</Button>
|
|
322
|
-
<Button variant="outline" size="md" onClick={() => { void copyPrompt(selected.prompt) }}>
|
|
323
|
-
{copied ? tt('templates.copied') : tt('templates.copy')}
|
|
324
|
-
</Button>
|
|
325
|
-
<Button variant="outline" size="md" onClick={() => { setSelected(null) }}>
|
|
326
|
-
{tt('templates.back')}
|
|
327
|
-
</Button>
|
|
328
|
-
</div>
|
|
329
|
-
</div>
|
|
330
|
-
</section>
|
|
331
|
-
</div>
|
|
332
|
-
) : null}
|
|
333
|
-
</div>,
|
|
334
|
-
document.body,
|
|
335
|
-
)
|
|
336
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-template library overlay: a searchable, category-filtered gallery of
|
|
3
|
+
* the bundled awesome-gpt-image-2 cases. The case list is served by the host
|
|
4
|
+
* (bundled snapshot, optionally refreshed online); reference images load
|
|
5
|
+
* lazily through the host's caching proxy, so browsing progressively mirrors
|
|
6
|
+
* the gallery onto the local disk. Picking a template hands its prompt back
|
|
7
|
+
* to the studio form.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
11
|
+
import { createPortal } from 'react-dom'
|
|
12
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
13
|
+
import type { ImageGenApi } from './api.ts'
|
|
14
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
15
|
+
import { TEMPLATES_API, type TemplateCase, type TemplateListResult } from '../protocol.ts'
|
|
16
|
+
import css from './templates.module.css'
|
|
17
|
+
|
|
18
|
+
/** Concurrent image downloads while caching the whole gallery offline. */
|
|
19
|
+
const CACHE_ALL_CONCURRENCY = 4
|
|
20
|
+
|
|
21
|
+
/** Same-origin URL of one case's reference image (host caching proxy). */
|
|
22
|
+
function imageUrlOf(item: TemplateCase): string {
|
|
23
|
+
return `${TEMPLATES_API.image}/${encodeURIComponent(item.image)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A card thumbnail that falls back to a placeholder when the proxy 404s. */
|
|
27
|
+
function TemplateThumb(props: { item: TemplateCase }) {
|
|
28
|
+
const [failed, setFailed] = useState(false)
|
|
29
|
+
if (props.item.image === '' || failed) {
|
|
30
|
+
return (
|
|
31
|
+
<span className={css.thumbPlaceholder} aria-hidden="true">
|
|
32
|
+
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<img
|
|
38
|
+
className={css.thumb}
|
|
39
|
+
src={imageUrlOf(props.item)}
|
|
40
|
+
alt={props.item.title}
|
|
41
|
+
loading="lazy"
|
|
42
|
+
onError={() => { setFailed(true) }}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The template-library modal. Rendered through a portal above the studio. */
|
|
48
|
+
export function TemplateLibrary(props: {
|
|
49
|
+
api: ImageGenApi
|
|
50
|
+
/** Hand a picked prompt back to the studio form and close the library. */
|
|
51
|
+
onUse: (prompt: string) => void
|
|
52
|
+
onClose: () => void
|
|
53
|
+
}) {
|
|
54
|
+
const { api, onUse, onClose } = props
|
|
55
|
+
const [list, setList] = useState<TemplateListResult | null>(null)
|
|
56
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
57
|
+
const [query, setQuery] = useState('')
|
|
58
|
+
const [category, setCategory] = useState('')
|
|
59
|
+
const [selected, setSelected] = useState<TemplateCase | null>(null)
|
|
60
|
+
const [copied, setCopied] = useState(false)
|
|
61
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
62
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
63
|
+
const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 })
|
|
64
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
65
|
+
const load = (): void => {
|
|
66
|
+
api.templatesList()
|
|
67
|
+
.then(result => { setList(result); setLoadError(null) })
|
|
68
|
+
.catch(caught => { setLoadError(errorMessage(caught)) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Load once on open; focus the search box for immediate typing.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
load()
|
|
74
|
+
searchRef.current?.focus()
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
const categories = useMemo(() => {
|
|
79
|
+
if (list === null) return [] as Array<{ key: string; label: string; count: number }>
|
|
80
|
+
const counts = new Map<string, { label: string; count: number }>()
|
|
81
|
+
for (const item of list.cases) {
|
|
82
|
+
const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 }
|
|
83
|
+
entry.count += 1
|
|
84
|
+
counts.set(item.category, entry)
|
|
85
|
+
}
|
|
86
|
+
return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count }))
|
|
87
|
+
}, [list])
|
|
88
|
+
|
|
89
|
+
const filtered = useMemo(() => {
|
|
90
|
+
if (list === null) return [] as TemplateCase[]
|
|
91
|
+
const needle = query.trim().toLowerCase()
|
|
92
|
+
return list.cases.filter(item => {
|
|
93
|
+
if (category !== '' && item.category !== category) return false
|
|
94
|
+
if (needle === '') return true
|
|
95
|
+
return item.title.toLowerCase().includes(needle)
|
|
96
|
+
|| item.prompt.toLowerCase().includes(needle)
|
|
97
|
+
|| item.sourceLabel.toLowerCase().includes(needle)
|
|
98
|
+
})
|
|
99
|
+
}, [list, query, category])
|
|
100
|
+
|
|
101
|
+
// Escape backs out of the detail view first, then closes the modal.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
104
|
+
if (event.key !== 'Escape') return
|
|
105
|
+
event.stopPropagation()
|
|
106
|
+
if (selected !== null) setSelected(null)
|
|
107
|
+
else onClose()
|
|
108
|
+
}
|
|
109
|
+
window.addEventListener('keydown', onKey, true)
|
|
110
|
+
return () => window.removeEventListener('keydown', onKey, true)
|
|
111
|
+
}, [selected, onClose])
|
|
112
|
+
|
|
113
|
+
const refresh = async (): Promise<void> => {
|
|
114
|
+
if (refreshing) return
|
|
115
|
+
setRefreshing(true)
|
|
116
|
+
setNotice(null)
|
|
117
|
+
try {
|
|
118
|
+
const result = await api.templatesRefresh()
|
|
119
|
+
const reloaded = await api.templatesList()
|
|
120
|
+
setList(reloaded)
|
|
121
|
+
setLoadError(null)
|
|
122
|
+
setNotice(tt('templates.refreshed', { count: result.total }))
|
|
123
|
+
} catch (caught) {
|
|
124
|
+
setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) }))
|
|
125
|
+
} finally {
|
|
126
|
+
setRefreshing(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Mirror every reference image through the host cache (offline browsing). */
|
|
131
|
+
const cacheAllImages = async (): Promise<void> => {
|
|
132
|
+
if (cacheAll.running || list === null) return
|
|
133
|
+
const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))]
|
|
134
|
+
setCacheAll({ running: true, done: 0, total: files.length })
|
|
135
|
+
let index = 0
|
|
136
|
+
const worker = async (): Promise<void> => {
|
|
137
|
+
while (index < files.length) {
|
|
138
|
+
const file = files[index]!
|
|
139
|
+
index += 1
|
|
140
|
+
try {
|
|
141
|
+
await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(file)}`)
|
|
142
|
+
} catch { /* individual failures are retried on the next run */ }
|
|
143
|
+
setCacheAll(current => ({ ...current, done: current.done + 1 }))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
await Promise.all(Array.from({ length: CACHE_ALL_CONCURRENCY }, () => worker()))
|
|
147
|
+
setCacheAll({ running: false, done: files.length, total: files.length })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const copyPrompt = async (text: string): Promise<void> => {
|
|
151
|
+
try {
|
|
152
|
+
if (navigator.clipboard?.writeText !== undefined) {
|
|
153
|
+
await navigator.clipboard.writeText(text)
|
|
154
|
+
} else {
|
|
155
|
+
const textarea = document.createElement('textarea')
|
|
156
|
+
textarea.value = text
|
|
157
|
+
textarea.style.position = 'fixed'
|
|
158
|
+
textarea.style.opacity = '0'
|
|
159
|
+
document.body.appendChild(textarea)
|
|
160
|
+
textarea.select()
|
|
161
|
+
const copiedOk = document.execCommand('copy')
|
|
162
|
+
textarea.remove()
|
|
163
|
+
if (!copiedOk) throw new Error('copy failed')
|
|
164
|
+
}
|
|
165
|
+
setCopied(true)
|
|
166
|
+
window.setTimeout(() => { setCopied(false) }, 1800)
|
|
167
|
+
} catch {
|
|
168
|
+
setCopied(false)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const originLabel = list === null ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
|
|
173
|
+
|
|
174
|
+
return createPortal(
|
|
175
|
+
<div className={css.overlay} role="dialog" aria-modal="true" aria-label={tt('templates.title')} onClick={onClose}>
|
|
176
|
+
<section className={css.shell} onClick={(event) => { event.stopPropagation() }}>
|
|
177
|
+
<header className={css.header}>
|
|
178
|
+
<span className={css.heading}>
|
|
179
|
+
<h3 className={css.title}>{tt('templates.title')}</h3>
|
|
180
|
+
{list !== null ? (
|
|
181
|
+
<span className={css.meta}>{tt('templates.meta', { count: list.total, origin: originLabel })}</span>
|
|
182
|
+
) : null}
|
|
183
|
+
</span>
|
|
184
|
+
<span className={css.headerActions}>
|
|
185
|
+
<Button variant="outline" size="sm" disabled={refreshing || cacheAll.running} onClick={() => { void refresh() }}>
|
|
186
|
+
{refreshing ? tt('templates.refreshing') : tt('templates.refresh')}
|
|
187
|
+
</Button>
|
|
188
|
+
<Button
|
|
189
|
+
variant="outline"
|
|
190
|
+
size="sm"
|
|
191
|
+
disabled={list === null || cacheAll.running}
|
|
192
|
+
title={tt('templates.cacheAllHint')}
|
|
193
|
+
onClick={() => { void cacheAllImages() }}
|
|
194
|
+
>
|
|
195
|
+
{cacheAll.running
|
|
196
|
+
? tt('templates.caching', { done: cacheAll.done, total: cacheAll.total })
|
|
197
|
+
: cacheAll.total > 0 && cacheAll.done === cacheAll.total
|
|
198
|
+
? tt('templates.cached')
|
|
199
|
+
: tt('templates.cacheAll')}
|
|
200
|
+
</Button>
|
|
201
|
+
<button type="button" className={css.close} aria-label={tt('templates.close')} title={tt('templates.close')} onClick={onClose}>
|
|
202
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
203
|
+
</button>
|
|
204
|
+
</span>
|
|
205
|
+
</header>
|
|
206
|
+
|
|
207
|
+
<div className={css.toolbar}>
|
|
208
|
+
<input
|
|
209
|
+
ref={searchRef}
|
|
210
|
+
type="search"
|
|
211
|
+
className={css.search}
|
|
212
|
+
placeholder={tt('templates.search')}
|
|
213
|
+
value={query}
|
|
214
|
+
onChange={(event) => { setQuery(event.target.value) }}
|
|
215
|
+
/>
|
|
216
|
+
<div className={css.categoryRow}>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className={css.categoryPill}
|
|
220
|
+
data-active={category === '' ? '' : undefined}
|
|
221
|
+
onClick={() => { setCategory('') }}
|
|
222
|
+
>
|
|
223
|
+
{tt('templates.all')}{list !== null ? ` ${list.total}` : ''}
|
|
224
|
+
</button>
|
|
225
|
+
{categories.map(entry => (
|
|
226
|
+
<button
|
|
227
|
+
key={entry.key}
|
|
228
|
+
type="button"
|
|
229
|
+
className={css.categoryPill}
|
|
230
|
+
data-active={category === entry.key ? '' : undefined}
|
|
231
|
+
onClick={() => { setCategory(entry.key) }}
|
|
232
|
+
>
|
|
233
|
+
{entry.label} {entry.count}
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{notice !== null ? <div className={css.notice} role="status">{notice}</div> : null}
|
|
240
|
+
|
|
241
|
+
<div className={css.body}>
|
|
242
|
+
{list === null && loadError === null ? (
|
|
243
|
+
<div className={css.state} role="status">
|
|
244
|
+
<span className={css.spinner} />
|
|
245
|
+
<span>{tt('templates.loading')}</span>
|
|
246
|
+
</div>
|
|
247
|
+
) : null}
|
|
248
|
+
|
|
249
|
+
{loadError !== null ? (
|
|
250
|
+
<div className={css.state} role="alert">
|
|
251
|
+
<span>{tt('templates.loadFailed', { error: loadError })}</span>
|
|
252
|
+
<Button variant="outline" size="sm" onClick={() => { setLoadError(null); setList(null); load() }}>
|
|
253
|
+
{tt('templates.retry')}
|
|
254
|
+
</Button>
|
|
255
|
+
</div>
|
|
256
|
+
) : null}
|
|
257
|
+
|
|
258
|
+
{list !== null && filtered.length === 0 ? (
|
|
259
|
+
<div className={css.state}>{tt('templates.empty')}</div>
|
|
260
|
+
) : null}
|
|
261
|
+
|
|
262
|
+
{list !== null && filtered.length > 0 ? (
|
|
263
|
+
<div className={css.grid}>
|
|
264
|
+
{filtered.map(item => (
|
|
265
|
+
<button
|
|
266
|
+
key={item.id}
|
|
267
|
+
type="button"
|
|
268
|
+
className={css.card}
|
|
269
|
+
onClick={() => { setSelected(item); setCopied(false) }}
|
|
270
|
+
>
|
|
271
|
+
<span className={css.thumbWrap}>
|
|
272
|
+
<TemplateThumb item={item} />
|
|
273
|
+
{item.featured ? <span className={css.featuredBadge}>{tt('templates.featured')}</span> : null}
|
|
274
|
+
</span>
|
|
275
|
+
<span className={css.cardBody}>
|
|
276
|
+
<span className={css.cardTitle}>{item.title}</span>
|
|
277
|
+
<span className={css.cardMeta}>
|
|
278
|
+
<span className={css.cardCategory}>{item.categoryZh || item.category}</span>
|
|
279
|
+
{item.sourceLabel !== '' ? <span className={css.cardSource}>{item.sourceLabel}</span> : null}
|
|
280
|
+
</span>
|
|
281
|
+
</span>
|
|
282
|
+
</button>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
) : null}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<footer className={css.footer}>
|
|
289
|
+
<span className={css.attribution}>{tt('templates.attribution')}</span>
|
|
290
|
+
<a className={css.sourceLink} href="https://vibeui.top/" target="_blank" rel="noreferrer">
|
|
291
|
+
{tt('templates.source')}
|
|
292
|
+
</a>
|
|
293
|
+
</footer>
|
|
294
|
+
</section>
|
|
295
|
+
|
|
296
|
+
{selected !== null ? (
|
|
297
|
+
<div className={css.detailOverlay} onClick={() => { setSelected(null) }}>
|
|
298
|
+
<section className={css.detail} onClick={(event) => { event.stopPropagation() }}>
|
|
299
|
+
<div className={css.detailMedia}>
|
|
300
|
+
{selected.image !== '' ? (
|
|
301
|
+
<img className={css.detailImage} src={imageUrlOf(selected)} alt={selected.title} />
|
|
302
|
+
) : (
|
|
303
|
+
<span className={css.thumbPlaceholder} aria-hidden="true" />
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
<div className={css.detailInfo}>
|
|
307
|
+
<h4 className={css.detailTitle}>{selected.title}</h4>
|
|
308
|
+
<div className={css.detailMeta}>
|
|
309
|
+
<span className={css.cardCategory}>{selected.categoryZh || selected.category}</span>
|
|
310
|
+
{selected.sourceUrl !== '' ? (
|
|
311
|
+
<a className={css.detailLink} href={selected.sourceUrl} target="_blank" rel="noreferrer">{selected.sourceLabel || selected.sourceUrl}</a>
|
|
312
|
+
) : null}
|
|
313
|
+
{selected.githubUrl !== '' ? (
|
|
314
|
+
<a className={css.detailLink} href={selected.githubUrl} target="_blank" rel="noreferrer">GitHub</a>
|
|
315
|
+
) : null}
|
|
316
|
+
</div>
|
|
317
|
+
<pre className={css.detailPrompt}>{selected.prompt}</pre>
|
|
318
|
+
<div className={css.detailActions}>
|
|
319
|
+
<Button variant="primary" size="md" onClick={() => { onUse(selected.prompt) }}>
|
|
320
|
+
{tt('templates.use')}
|
|
321
|
+
</Button>
|
|
322
|
+
<Button variant="outline" size="md" onClick={() => { void copyPrompt(selected.prompt) }}>
|
|
323
|
+
{copied ? tt('templates.copied') : tt('templates.copy')}
|
|
324
|
+
</Button>
|
|
325
|
+
<Button variant="outline" size="md" onClick={() => { setSelected(null) }}>
|
|
326
|
+
{tt('templates.back')}
|
|
327
|
+
</Button>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</section>
|
|
331
|
+
</div>
|
|
332
|
+
) : null}
|
|
333
|
+
</div>,
|
|
334
|
+
document.body,
|
|
335
|
+
)
|
|
336
|
+
}
|