@dickpy/dsh-imagegen 1.0.7 → 1.0.19
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/README.md +28 -9
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +1739 -408
- package/lib/client.js.map +1 -1
- package/lib/index.js +797 -38
- package/package.json +5 -4
- package/src/client/ImageGenPanel.tsx +476 -61
- package/src/client/TemplateLibrary.tsx +336 -0
- package/src/client/api.ts +58 -1
- package/src/client/locales.ts +127 -22
- package/src/client/mount.tsx +11 -6
- package/src/client/panel.module.css +245 -8
- package/src/client/templates.module.css +453 -0
- package/src/engine.ts +78 -11
- package/src/gallery-store.ts +266 -0
- package/src/index.ts +3 -1
- package/src/protocol.ts +79 -5
- package/src/routes.ts +191 -1
- package/src/templates/cases.json +10196 -0
- package/src/templates-store.ts +278 -0
|
@@ -0,0 +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
|
+
}
|
package/src/client/api.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* data access path the panel uses — plain fetch, same origin.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { GENERATE_API, HISTORY_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type UpdateInfo } from '../protocol.ts'
|
|
6
|
+
import { GALLERY_API, GENERATE_API, HISTORY_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
|
|
7
7
|
|
|
8
8
|
/** Error carrying the route's JSON error message. */
|
|
9
9
|
export class ImageGenApiError extends Error {
|
|
@@ -97,4 +97,61 @@ export class ImageGenApi {
|
|
|
97
97
|
const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
|
|
98
98
|
return body.entries
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/** List the host-persisted gallery (newest first). */
|
|
102
|
+
async galleryList(): Promise<HistoryEntry[]> {
|
|
103
|
+
const response = await fetch(GALLERY_API.list, { method: 'POST' })
|
|
104
|
+
const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
|
|
105
|
+
return body.entries
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Append one image to the gallery. The host assigns the id and skips the
|
|
109
|
+
* append when a content-identical image is already in the gallery. */
|
|
110
|
+
async galleryAppend(entry: HistoryEntryInput): Promise<{ entries: HistoryEntry[]; added: boolean }> {
|
|
111
|
+
const response = await fetch(GALLERY_API.append, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ entry }),
|
|
115
|
+
})
|
|
116
|
+
const body = await readEnvelope<{ ok: true; entries: HistoryEntry[]; added: boolean }>(response)
|
|
117
|
+
return { entries: body.entries, added: body.added }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove one gallery entry by id. */
|
|
121
|
+
async galleryRemove(id: string): Promise<HistoryEntry[]> {
|
|
122
|
+
const response = await fetch(GALLERY_API.remove, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ id }),
|
|
126
|
+
})
|
|
127
|
+
const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
|
|
128
|
+
return body.entries
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Clear the entire gallery. */
|
|
132
|
+
async galleryClear(): Promise<HistoryEntry[]> {
|
|
133
|
+
const response = await fetch(GALLERY_API.clear, { method: 'POST' })
|
|
134
|
+
const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
|
|
135
|
+
return body.entries
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
|
|
139
|
+
async templatesList(): Promise<TemplateListResult> {
|
|
140
|
+
const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
|
|
141
|
+
const body = await readEnvelope<TemplateListResult & { ok: true }>(response)
|
|
142
|
+
return {
|
|
143
|
+
cases: body.cases,
|
|
144
|
+
total: body.total,
|
|
145
|
+
origin: body.origin,
|
|
146
|
+
repository: body.repository,
|
|
147
|
+
fetchedAt: body.fetchedAt,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Re-download the template library from the upstream mirror (host-side). */
|
|
152
|
+
async templatesRefresh(): Promise<TemplateRefreshResult> {
|
|
153
|
+
const response = await fetch(TEMPLATES_API.refresh, { method: 'POST' })
|
|
154
|
+
const body = await readEnvelope<TemplateRefreshResult & { ok: true }>(response)
|
|
155
|
+
return { total: body.total, fetchedAt: body.fetchedAt }
|
|
156
|
+
}
|
|
100
157
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
export const zh = {
|
|
6
6
|
'entry.label': 'AI 生图',
|
|
7
|
-
'entry.tooltip': 'AI 生图面板(gpt-image-2)',
|
|
7
|
+
'entry.tooltip': 'AI 生图面板(gpt-image-2 / grok-imagine-image)',
|
|
8
8
|
'panel.title': 'AI 生图',
|
|
9
|
-
'panel.
|
|
9
|
+
'panel.githubTip': '觉得好用或有建议?欢迎来 GitHub 提 issues、点个 star 支持一下!',
|
|
10
10
|
// mode
|
|
11
11
|
'mode.text': '文生图',
|
|
12
12
|
'mode.edit': '图生图',
|
|
@@ -20,16 +20,18 @@ export const zh = {
|
|
|
20
20
|
'params.count': '生成数量',
|
|
21
21
|
'params.detail': '细节',
|
|
22
22
|
'size.auto': '自动',
|
|
23
|
-
'size.square': '
|
|
24
|
-
'size.
|
|
25
|
-
'size.
|
|
26
|
-
'size.
|
|
27
|
-
'size.
|
|
28
|
-
'size.
|
|
23
|
+
'size.square': '1:1 方图',
|
|
24
|
+
'size.portrait34': '3:4 标准竖图',
|
|
25
|
+
'size.landscape43': '4:3 标准横图',
|
|
26
|
+
'size.portrait916': '9:16 竖屏',
|
|
27
|
+
'size.portrait23': '2:3 竖图',
|
|
28
|
+
'size.landscape32': '3:2 横图',
|
|
29
|
+
'size.wide169': '16:9 宽屏',
|
|
30
|
+
'size.ultrawide21': '21:9 超宽屏',
|
|
29
31
|
'quality.auto': '自动',
|
|
30
|
-
'quality.
|
|
31
|
-
'quality.
|
|
32
|
-
'quality.
|
|
32
|
+
'quality.1k': '1K',
|
|
33
|
+
'quality.2k': '2K',
|
|
34
|
+
'quality.4k': '4K',
|
|
33
35
|
'count.one': '1 张',
|
|
34
36
|
'count.two': '2 张',
|
|
35
37
|
'count.three': '3 张',
|
|
@@ -65,6 +67,29 @@ export const zh = {
|
|
|
65
67
|
'history.delete': '删除',
|
|
66
68
|
'history.images': '张',
|
|
67
69
|
'history.viewing': '历史 · {time}',
|
|
70
|
+
// gallery
|
|
71
|
+
'gallery.title': '画廊',
|
|
72
|
+
'gallery.categories': '分类',
|
|
73
|
+
'gallery.all': '全部作品',
|
|
74
|
+
'gallery.gpt': 'gpt-image-2',
|
|
75
|
+
'gallery.grok': 'grok-imagine-image',
|
|
76
|
+
'gallery.ratio': '画面比例',
|
|
77
|
+
'gallery.filterHint': '按生成模式、模型和比例筛选画廊',
|
|
78
|
+
'gallery.count': '· 共 {count} 幅',
|
|
79
|
+
'gallery.viewMode': '视图模式',
|
|
80
|
+
'gallery.masonry': '瀑布流',
|
|
81
|
+
'gallery.grid': '整齐网格',
|
|
82
|
+
'gallery.sort': '排序',
|
|
83
|
+
'gallery.newest': '最新发布',
|
|
84
|
+
'gallery.oldest': '最早发布',
|
|
85
|
+
'gallery.untitled': '未命名作品',
|
|
86
|
+
'gallery.add': '加入画廊',
|
|
87
|
+
'gallery.added': '已加入画廊',
|
|
88
|
+
'gallery.already': '已在画廊中',
|
|
89
|
+
'gallery.delete': '移出画廊',
|
|
90
|
+
'gallery.clear': '清空画廊',
|
|
91
|
+
'gallery.empty': '画廊还是空的,把喜欢的图片加入进来吧',
|
|
92
|
+
'gallery.viewing': '画廊 · {time}',
|
|
68
93
|
// preview
|
|
69
94
|
'preview.title': '图片预览',
|
|
70
95
|
'preview.open': '点击预览',
|
|
@@ -122,13 +147,41 @@ export const zh = {
|
|
|
122
147
|
'settings.overridden': '已覆盖',
|
|
123
148
|
'settings.reset': '重置',
|
|
124
149
|
'settings.invalidNumber': '请输入有效数字',
|
|
150
|
+
// template library
|
|
151
|
+
'templates.open': '模板库',
|
|
152
|
+
'templates.title': '提示词模板库',
|
|
153
|
+
'templates.meta': '共 {count} 个模板 · {origin}',
|
|
154
|
+
'templates.origin.bundled': '内置快照',
|
|
155
|
+
'templates.origin.refreshed': '在线刷新',
|
|
156
|
+
'templates.search': '搜索模板标题或提示词…',
|
|
157
|
+
'templates.all': '全部',
|
|
158
|
+
'templates.close': '关闭',
|
|
159
|
+
'templates.back': '返回列表',
|
|
160
|
+
'templates.use': '使用此提示词',
|
|
161
|
+
'templates.copy': '复制提示词',
|
|
162
|
+
'templates.copied': '已复制',
|
|
163
|
+
'templates.refresh': '刷新模板库',
|
|
164
|
+
'templates.refreshing': '刷新中…',
|
|
165
|
+
'templates.refreshed': '已刷新,共 {count} 个模板',
|
|
166
|
+
'templates.refreshFailed': '刷新失败:{error}',
|
|
167
|
+
'templates.cacheAll': '缓存全部图片',
|
|
168
|
+
'templates.cacheAllHint': '通过本机代理把全部参考图缓存到本地磁盘,之后离线也能浏览',
|
|
169
|
+
'templates.caching': '缓存中 {done}/{total}…',
|
|
170
|
+
'templates.cached': '图片已全部缓存',
|
|
171
|
+
'templates.empty': '没有匹配的模板',
|
|
172
|
+
'templates.loading': '正在加载模板库…',
|
|
173
|
+
'templates.loadFailed': '模板库加载失败:{error}',
|
|
174
|
+
'templates.retry': '重试',
|
|
175
|
+
'templates.attribution': '模板与图片来自 awesome-gpt-image-2 项目,作者链接见各模板详情',
|
|
176
|
+
'templates.source': '来源:vibeui.top',
|
|
177
|
+
'templates.featured': '精选',
|
|
125
178
|
} as const
|
|
126
179
|
|
|
127
180
|
export const en: Record<keyof typeof zh, string> = {
|
|
128
181
|
'entry.label': 'AI Image',
|
|
129
|
-
'entry.tooltip': 'AI image generation studio (gpt-image-2)',
|
|
182
|
+
'entry.tooltip': 'AI image generation studio (gpt-image-2 / grok-imagine-image)',
|
|
130
183
|
'panel.title': 'AI Image',
|
|
131
|
-
'panel.
|
|
184
|
+
'panel.githubTip': 'Like it or have suggestions? Head to GitHub to open issues and star us!',
|
|
132
185
|
'mode.text': 'Text to Image',
|
|
133
186
|
'mode.edit': 'Image to Image',
|
|
134
187
|
'prompt.placeholder': 'Describe the picture you want, e.g. an orange cat in an astronaut helmet raising a telescope on the moon, watercolor style, soft light…',
|
|
@@ -139,16 +192,18 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
139
192
|
'params.count': 'Count',
|
|
140
193
|
'params.detail': 'Detail',
|
|
141
194
|
'size.auto': 'Auto',
|
|
142
|
-
'size.square': '
|
|
143
|
-
'size.
|
|
144
|
-
'size.
|
|
145
|
-
'size.
|
|
146
|
-
'size.
|
|
147
|
-
'size.
|
|
195
|
+
'size.square': '1:1 Square',
|
|
196
|
+
'size.portrait34': '3:4 Portrait',
|
|
197
|
+
'size.landscape43': '4:3 Landscape',
|
|
198
|
+
'size.portrait916': '9:16 Vertical',
|
|
199
|
+
'size.portrait23': '2:3 Portrait',
|
|
200
|
+
'size.landscape32': '3:2 Landscape',
|
|
201
|
+
'size.wide169': '16:9 Widescreen',
|
|
202
|
+
'size.ultrawide21': '21:9 Ultrawide',
|
|
148
203
|
'quality.auto': 'Auto',
|
|
149
|
-
'quality.
|
|
150
|
-
'quality.
|
|
151
|
-
'quality.
|
|
204
|
+
'quality.1k': '1K',
|
|
205
|
+
'quality.2k': '2K',
|
|
206
|
+
'quality.4k': '4K',
|
|
152
207
|
'count.one': '1',
|
|
153
208
|
'count.two': '2',
|
|
154
209
|
'count.three': '3',
|
|
@@ -180,6 +235,28 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
180
235
|
'history.delete': 'Delete',
|
|
181
236
|
'history.images': 'images',
|
|
182
237
|
'history.viewing': 'History · {time}',
|
|
238
|
+
'gallery.title': 'Gallery',
|
|
239
|
+
'gallery.categories': 'Categories',
|
|
240
|
+
'gallery.all': 'All works',
|
|
241
|
+
'gallery.gpt': 'gpt-image-2',
|
|
242
|
+
'gallery.grok': 'grok-imagine-image',
|
|
243
|
+
'gallery.ratio': 'Aspect ratio',
|
|
244
|
+
'gallery.filterHint': 'Filter by mode, model, and aspect ratio',
|
|
245
|
+
'gallery.count': '· {count} works',
|
|
246
|
+
'gallery.viewMode': 'View mode',
|
|
247
|
+
'gallery.masonry': 'Masonry',
|
|
248
|
+
'gallery.grid': 'Grid',
|
|
249
|
+
'gallery.sort': 'Sort',
|
|
250
|
+
'gallery.newest': 'Newest',
|
|
251
|
+
'gallery.oldest': 'Oldest',
|
|
252
|
+
'gallery.untitled': 'Untitled work',
|
|
253
|
+
'gallery.add': 'Add to gallery',
|
|
254
|
+
'gallery.added': 'Added to gallery',
|
|
255
|
+
'gallery.already': 'Already in gallery',
|
|
256
|
+
'gallery.delete': 'Remove',
|
|
257
|
+
'gallery.clear': 'Clear gallery',
|
|
258
|
+
'gallery.empty': 'The gallery is empty — add images you like here',
|
|
259
|
+
'gallery.viewing': 'Gallery · {time}',
|
|
183
260
|
'preview.title': 'Image preview',
|
|
184
261
|
'preview.open': 'Click to preview',
|
|
185
262
|
'preview.close': 'Close',
|
|
@@ -233,6 +310,34 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
233
310
|
'settings.overridden': 'Overridden',
|
|
234
311
|
'settings.reset': 'Reset',
|
|
235
312
|
'settings.invalidNumber': 'Enter a valid number',
|
|
313
|
+
// template library
|
|
314
|
+
'templates.open': 'Templates',
|
|
315
|
+
'templates.title': 'Prompt Template Library',
|
|
316
|
+
'templates.meta': '{count} templates · {origin}',
|
|
317
|
+
'templates.origin.bundled': 'bundled snapshot',
|
|
318
|
+
'templates.origin.refreshed': 'refreshed online',
|
|
319
|
+
'templates.search': 'Search template titles or prompts…',
|
|
320
|
+
'templates.all': 'All',
|
|
321
|
+
'templates.close': 'Close',
|
|
322
|
+
'templates.back': 'Back to list',
|
|
323
|
+
'templates.use': 'Use this prompt',
|
|
324
|
+
'templates.copy': 'Copy prompt',
|
|
325
|
+
'templates.copied': 'Copied',
|
|
326
|
+
'templates.refresh': 'Refresh library',
|
|
327
|
+
'templates.refreshing': 'Refreshing…',
|
|
328
|
+
'templates.refreshed': 'Refreshed — {count} templates',
|
|
329
|
+
'templates.refreshFailed': 'Refresh failed: {error}',
|
|
330
|
+
'templates.cacheAll': 'Cache all images',
|
|
331
|
+
'templates.cacheAllHint': 'Mirror every reference image to local disk through the host proxy, for offline browsing',
|
|
332
|
+
'templates.caching': 'Caching {done}/{total}…',
|
|
333
|
+
'templates.cached': 'All images cached',
|
|
334
|
+
'templates.empty': 'No matching templates',
|
|
335
|
+
'templates.loading': 'Loading the template library…',
|
|
336
|
+
'templates.loadFailed': 'Failed to load the library: {error}',
|
|
337
|
+
'templates.retry': 'Retry',
|
|
338
|
+
'templates.attribution': 'Templates and images come from the awesome-gpt-image-2 project; author links are on each template',
|
|
339
|
+
'templates.source': 'Source: vibeui.top',
|
|
340
|
+
'templates.featured': 'Featured',
|
|
236
341
|
}
|
|
237
342
|
|
|
238
343
|
/** Locale key union. */
|
package/src/client/mount.tsx
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The `conversation` slot is single-occupant (ui-conversation) and external
|
|
5
5
|
* plugins cannot declare slots, so the panel takes over the center column at
|
|
6
|
-
* the DOM level: a container is appended inside the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* the DOM level: a container is appended inside the conversation grid item
|
|
7
|
+
* (an extra trailing child React never manages), and a stylesheet rule hides
|
|
8
|
+
* the conversation content while the panel is active. Toggling is a data
|
|
9
|
+
* attribute on <html> — no React involvement, so the conversation subtree
|
|
10
|
+
* underneath stays mounted and stateful.
|
|
11
|
+
*
|
|
12
|
+
* Shell compatibility: the center column is `[data-pane="conversation"]` on
|
|
13
|
+
* legacy shells and `[class*="centerCol"]` on the rc.6+ AppFrame layout (the
|
|
14
|
+
* same dual selector the dsh-ssh / task-board panels use); both are queried
|
|
15
|
+
* and both get the `position: relative` base in panel.module.css.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { createRoot, type Root } from 'react-dom/client'
|
|
@@ -20,7 +25,7 @@ import css from './panel.module.css'
|
|
|
20
25
|
/** The injected panel container (kept in the DOM, hidden when inactive). */
|
|
21
26
|
export const PANEL_VIEW_SELECTOR = '[data-dsh-imagegen-view]'
|
|
22
27
|
|
|
23
|
-
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
|
|
28
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
|
|
24
29
|
const ACTIVE_ATTR = 'data-dsh-imagegen-active'
|
|
25
30
|
/** Sibling panels' activation attributes, removed when this panel opens. */
|
|
26
31
|
const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
|