@dickpy/dsh-imagegen 1.5.0 → 1.5.2

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 (36) hide show
  1. package/README.md +11 -8
  2. package/docs/images/ecommerce-mode.png +0 -0
  3. package/docs/images/gallery-workspace.png +0 -0
  4. package/docs/images/image-generation-studio-three-column.png +0 -0
  5. package/docs/images/imagegen-overview.png +0 -0
  6. package/docs/images/plugin-settings.png +0 -0
  7. package/docs/images/prompt-template-library.png +0 -0
  8. package/lib/client.js +824 -449
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +496 -80
  11. package/package.json +23 -17
  12. package/src/client/ImageGenPanel.tsx +10 -8
  13. package/src/client/InspirationGallery.tsx +106 -0
  14. package/src/client/SettingsCard.tsx +1 -1
  15. package/src/client/TemplateLibrary.tsx +159 -48
  16. package/src/client/api.ts +41 -8
  17. package/src/client/channels-form.ts +1 -1
  18. package/src/client/conversation-sync.ts +1 -1
  19. package/src/client/image-toolview.tsx +16 -10
  20. package/src/client/index.ts +5 -4
  21. package/src/client/inspiration.module.css +148 -0
  22. package/src/client/locales.ts +34 -8
  23. package/src/client/mount.tsx +1 -1
  24. package/src/client/settings-form.ts +2 -1
  25. package/src/client/settings-scope.ts +9 -5
  26. package/src/client/templates.module.css +95 -0
  27. package/src/index.ts +26 -6
  28. package/src/protocol.ts +81 -7
  29. package/src/routes.ts +128 -12
  30. package/src/settings-compat.ts +60 -0
  31. package/src/template-favorites.ts +108 -0
  32. package/src/templates/canghe-cases.json +11126 -0
  33. package/src/templates-store.ts +179 -68
  34. package/docs/images/image-generation-studio-single.png +0 -0
  35. package/docs/images/image-generation-studio.png +0 -0
  36. package/docs/images/poster-features-16x9.png +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI image generation plugin for the dsh web GUI: text-to-image and image-to-image through configurable provider channels (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with per-channel model catalogs and a New Session / Image Generation tab entry opening a three-column studio beside the native conversation.",
4
- "version": "1.5.0",
4
+ "version": "1.5.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -15,9 +15,12 @@
15
15
  },
16
16
  "client": {
17
17
  "inject": [
18
- "@deepseek-ai/dsh-client-runtime",
18
+ "@deepseek-ai/dsh-client-store",
19
+ "@deepseek-ai/dsh-api-remotes",
20
+ "@deepseek-ai/dsh-api-session-controller",
19
21
  "@deepseek-ai/dsh-client-connection",
20
22
  "@deepseek-ai/dsh-client-ui-conversation",
23
+ "@deepseek-ai/dsh-client-ui-renderer",
21
24
  "@deepseek-ai/dsh-client-ui-settings",
22
25
  "@deepseek-ai/dsh-client-ui-tool"
23
26
  ],
@@ -28,21 +31,24 @@
28
31
  "schemastery": "^3.18.0"
29
32
  },
30
33
  "devDependencies": {
31
- "@deepseek-ai/cordis": "^4.0.1",
32
- "@deepseek-ai/dsh-attachment": "0.1.0-rc.7",
33
- "@deepseek-ai/dsh-client-connection": "0.1.0-rc.7",
34
- "@deepseek-ai/dsh-client-locale": "0.1.0-rc.7",
35
- "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.7",
36
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.7",
37
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.7",
38
- "@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.7",
39
- "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7",
40
- "@deepseek-ai/dsh-commands": "0.1.0-rc.7",
41
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
42
- "@deepseek-ai/dsh-llm": "0.1.0-rc.7",
43
- "@deepseek-ai/dsh-settings": "0.1.0-rc.7",
44
- "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.7",
45
- "@deepseek-ai/dsh-tools": "0.1.0-rc.7",
34
+ "@deepseek-ai/cordis": "^4.0.2",
35
+ "@deepseek-ai/dsh-api-remotes": "0.1.2-alpha.2",
36
+ "@deepseek-ai/dsh-api-session-controller": "0.1.2-alpha.2",
37
+ "@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
38
+ "@deepseek-ai/dsh-client-connection": "0.1.2-alpha.2",
39
+ "@deepseek-ai/dsh-client-locale": "0.1.2-alpha.2",
40
+ "@deepseek-ai/dsh-client-store": "0.1.2-alpha.2",
41
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.2",
42
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.2",
43
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-alpha.2",
44
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.2",
45
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.2",
46
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
47
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.2",
48
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
50
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
51
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
46
52
  "@types/node": "^22.20.0",
47
53
  "@types/react": "~18.3.1",
48
54
  "@types/react-dom": "^18.3.5",
@@ -11,11 +11,13 @@
11
11
  import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
12
12
  import { createPortal } from 'react-dom'
13
13
  import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
- import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
14
+ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
15
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
15
16
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
16
17
  import type { ImageGenApi } from './api.ts'
17
18
  import { errorMessage, tt } from './helpers.ts'
18
19
  import { TemplateLibrary } from './TemplateLibrary.tsx'
20
+ import { InspirationGallery } from './InspirationGallery.tsx'
19
21
  import type { EcommerceRefRole, GeneratedImage, GenerateMode, GenerateRequest, GenerationTask, GenerationTaskStatus, HistoryEntry, HistoryImageRef, ProductSetDraft, ProductSetSlot, UpdateInfo } from '../protocol.ts'
20
22
  import { AGENT_IMAGE_API } from '../protocol.ts'
21
23
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
@@ -2427,13 +2429,13 @@ export function ImageGenPanel(props: {
2427
2429
  ) : null}
2428
2430
 
2429
2431
  {!generating && !error && images.length === 0 && workspace !== 'ecommerce' ? (
2430
- <div className={css.canvasState}>
2431
- <span className={css.canvasEmptyIcon}>
2432
- <svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><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>
2433
- </span>
2434
- <span className={css.canvasStateTitle}>{tt('canvas.emptyTitle')}</span>
2435
- <span className={css.canvasStateHint}>{tt('canvas.emptyHint')}</span>
2436
- </div>
2432
+ <InspirationGallery
2433
+ api={api}
2434
+ onUse={(text) => {
2435
+ setPrompt(text)
2436
+ setError(null)
2437
+ }}
2438
+ />
2437
2439
  ) : null}
2438
2440
 
2439
2441
  {!generating && images.length > 0 && workspace !== 'ecommerce' ? (
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Inspiration wall for the studio's empty canvas: a small grid of random
3
+ * template cases sampled host-side across every library source. Clicking a
4
+ * card hands its prompt to the form; the 随机 button re-rolls the pick. On
5
+ * failure the wall collapses to nothing (the panel falls back to the plain
6
+ * empty-state hint).
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import type { ImageGenApi } from './api.ts'
12
+ import { tt } from './helpers.ts'
13
+ import { TEMPLATES_API, type TemplateSample } from '../protocol.ts'
14
+ import css from './inspiration.module.css'
15
+
16
+ /** Card count per deal — a 4×3 wall that uses the canvas's spare width. */
17
+ const SAMPLE_COUNT = 12
18
+
19
+ /** Same-origin proxy URL of one sampled case's reference image. */
20
+ function imageUrlOf(sample: TemplateSample): string {
21
+ return `${TEMPLATES_API.image}/${encodeURIComponent(sample.sourceId)}/${encodeURIComponent(sample.case.image)}`
22
+ }
23
+
24
+ /** One thumbnail that degrades to a text tile when the image 404s. */
25
+ function SampleThumb(props: { sample: TemplateSample }) {
26
+ const [failed, setFailed] = useState(false)
27
+ return (
28
+ <span className={css.thumbWrap}>
29
+ {props.sample.case.image !== '' && !failed ? (
30
+ <img className={css.thumb} src={imageUrlOf(props.sample)} alt={props.sample.case.title} loading="lazy" onError={() => { setFailed(true) }} />
31
+ ) : (
32
+ <span className={css.thumbFallback} aria-hidden="true">{props.sample.case.title}</span>
33
+ )}
34
+ <span className={css.thumbTitle}>{props.sample.case.title}</span>
35
+ </span>
36
+ )
37
+ }
38
+
39
+ /** The random-case wall shown while the canvas has no results. */
40
+ export function InspirationGallery(props: {
41
+ api: ImageGenApi
42
+ /** Hand the picked prompt back to the studio form. */
43
+ onUse: (prompt: string) => void
44
+ }) {
45
+ const { api, onUse } = props
46
+ const [samples, setSamples] = useState<TemplateSample[] | null>(null)
47
+ const [loading, setLoading] = useState(false)
48
+
49
+ const deal = (): void => {
50
+ if (loading) return
51
+ setLoading(true)
52
+ api.templatesSample(SAMPLE_COUNT)
53
+ .then(setSamples)
54
+ .catch(() => { setSamples(current => current ?? []) })
55
+ .finally(() => { setLoading(false) })
56
+ }
57
+
58
+ // Deal once on mount; shuffles re-use the same handler.
59
+ useEffect(() => {
60
+ deal()
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [])
63
+
64
+ // Sample fetch failed or the libraries are empty: collapse to the plain
65
+ // empty-state hint so the canvas never looks broken.
66
+ if (samples !== null && samples.length === 0) {
67
+ return (
68
+ <div className={css.wrap} aria-label={tt('inspiration.title')}>
69
+ <span className={css.emptyIcon}>
70
+ <svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><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>
71
+ </span>
72
+ <span className={css.emptyTitle}>{tt('canvas.emptyTitle')}</span>
73
+ <span className={css.emptyHint}>{tt('canvas.emptyHint')}</span>
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className={css.wrap} aria-label={tt('inspiration.title')}>
80
+ <span className={css.title}>{tt('inspiration.title')}</span>
81
+ {samples === null ? (
82
+ <span className={css.spinner} aria-hidden="true" />
83
+ ) : (
84
+ <>
85
+ <div className={css.grid}>
86
+ {samples.map(sample => (
87
+ <button
88
+ key={`${sample.sourceId}:${sample.case.id}`}
89
+ type="button"
90
+ className={css.tile}
91
+ title={tt('inspiration.useHint')}
92
+ onClick={() => { onUse(sample.case.prompt) }}
93
+ >
94
+ <SampleThumb sample={sample} />
95
+ </button>
96
+ ))}
97
+ </div>
98
+ <Button variant="outline" size="sm" disabled={loading} onClick={deal}>
99
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.8-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/></svg>
100
+ {loading ? tt('inspiration.shuffling') : tt('inspiration.shuffle')}
101
+ </Button>
102
+ </>
103
+ )}
104
+ </div>
105
+ )
106
+ }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react'
16
16
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
17
- import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
17
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
18
18
  import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
19
19
  import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
20
20
  import type { ImageGenScope } from './settings-scope.ts'
@@ -1,10 +1,13 @@
1
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.
2
+ * Prompt-template library overlay: a multi-source, searchable, category-
3
+ * filtered gallery. Each registered source (TEMPLATE_SOURCES) renders as its
4
+ * own tab with an independent list, refresh state, and image pool; case lists
5
+ * are served by the host (bundled snapshot, optionally refreshed online or
6
+ * auto-synced in the background) and reference images load lazily through the
7
+ * host's caching proxy, so browsing progressively mirrors the gallery onto the
8
+ * local disk. Templates can be starred; favorites persist host-side as full
9
+ * case snapshots and are reachable through the ★ filter pill per tab. Picking
10
+ * a template hands its prompt back to the studio form.
8
11
  */
9
12
 
10
13
  import { useEffect, useMemo, useRef, useState } from 'react'
@@ -12,19 +15,24 @@ import { createPortal } from 'react-dom'
12
15
  import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
13
16
  import type { ImageGenApi } from './api.ts'
14
17
  import { errorMessage, tt } from './helpers.ts'
15
- import { TEMPLATES_API, type TemplateCase, type TemplateListResult } from '../protocol.ts'
18
+ import { TEMPLATE_SOURCES, TEMPLATES_API, type TemplateCase, type TemplateFavorite, type TemplateListResult } from '../protocol.ts'
16
19
  import css from './templates.module.css'
17
20
 
18
21
  /** Concurrent image downloads while caching the whole gallery offline. */
19
22
  const CACHE_ALL_CONCURRENCY = 4
20
23
 
24
+ /** Stable favorites key of one case within a source. */
25
+ function favoriteKeyOf(sourceId: string, item: TemplateCase): string {
26
+ return `${sourceId}:${item.id}`
27
+ }
28
+
21
29
  /** 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)}`
30
+ function imageUrlOf(sourceId: string, item: TemplateCase): string {
31
+ return `${TEMPLATES_API.image}/${encodeURIComponent(sourceId)}/${encodeURIComponent(item.image)}`
24
32
  }
25
33
 
26
34
  /** A card thumbnail that falls back to a placeholder when the proxy 404s. */
27
- function TemplateThumb(props: { item: TemplateCase }) {
35
+ function TemplateThumb(props: { sourceId: string; item: TemplateCase }) {
28
36
  const [failed, setFailed] = useState(false)
29
37
  if (props.item.image === '' || failed) {
30
38
  return (
@@ -36,7 +44,7 @@ function TemplateThumb(props: { item: TemplateCase }) {
36
44
  return (
37
45
  <img
38
46
  className={css.thumb}
39
- src={imageUrlOf(props.item)}
47
+ src={imageUrlOf(props.sourceId, props.item)}
40
48
  alt={props.item.title}
41
49
  loading="lazy"
42
50
  onError={() => { setFailed(true) }}
@@ -44,6 +52,30 @@ function TemplateThumb(props: { item: TemplateCase }) {
44
52
  )
45
53
  }
46
54
 
55
+ /** Card-corner star toggle; the click must not open the detail view. Rendered
56
+ * as a span (a button cannot nest inside the card button). */
57
+ function FavoriteStar(props: { active: boolean; title: string; onToggle: () => void }) {
58
+ return (
59
+ <span
60
+ role="button"
61
+ tabIndex={0}
62
+ className={css.favStar}
63
+ data-active={props.active ? '' : undefined}
64
+ aria-label={props.title}
65
+ title={props.title}
66
+ onClick={(event) => { event.stopPropagation(); props.onToggle() }}
67
+ onKeyDown={(event) => {
68
+ if (event.key !== 'Enter' && event.key !== ' ') return
69
+ event.stopPropagation()
70
+ event.preventDefault()
71
+ props.onToggle()
72
+ }}
73
+ >
74
+ <svg viewBox="0 0 24 24" width="15" height="15" fill={props.active ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3.6l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.6 9.7l5.8-.8z"/></svg>
75
+ </span>
76
+ )
77
+ }
78
+
47
79
  /** The template-library modal. Rendered through a portal above the studio. */
48
80
  export function TemplateLibrary(props: {
49
81
  api: ImageGenApi
@@ -52,31 +84,53 @@ export function TemplateLibrary(props: {
52
84
  onClose: () => void
53
85
  }) {
54
86
  const { api, onUse, onClose } = props
55
- const [list, setList] = useState<TemplateListResult | null>(null)
56
- const [loadError, setLoadError] = useState<string | null>(null)
87
+ const [activeSource, setActiveSource] = useState(TEMPLATE_SOURCES[0]!.id)
88
+ const [lists, setLists] = useState<Record<string, TemplateListResult>>({})
89
+ const [loadErrors, setLoadErrors] = useState<Record<string, string>>({})
90
+ const [favorites, setFavorites] = useState<TemplateFavorite[]>([])
57
91
  const [query, setQuery] = useState('')
58
92
  const [category, setCategory] = useState('')
93
+ const [favoritesOnly, setFavoritesOnly] = useState(false)
59
94
  const [selected, setSelected] = useState<TemplateCase | null>(null)
60
95
  const [copied, setCopied] = useState(false)
61
96
  const [refreshing, setRefreshing] = useState(false)
62
97
  const [notice, setNotice] = useState<string | null>(null)
63
98
  const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 })
64
99
  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)) })
100
+
101
+ const list = lists[activeSource]
102
+ const loadError = loadErrors[activeSource] || null
103
+
104
+ /** Fetch one source's list into the per-source cache. */
105
+ const loadSource = (sourceId: string): void => {
106
+ api.templatesList(sourceId)
107
+ .then(result => {
108
+ setLists(current => ({ ...current, [sourceId]: result }))
109
+ setLoadErrors(current => ({ ...current, [sourceId]: '' }))
110
+ })
111
+ .catch(caught => { setLoadErrors(current => ({ ...current, [sourceId]: errorMessage(caught) })) })
69
112
  }
70
113
 
71
- // Load once on open; focus the search box for immediate typing.
114
+ // Load the first source plus the favorites on open; focus the search box.
72
115
  useEffect(() => {
73
- load()
116
+ loadSource(TEMPLATE_SOURCES[0]!.id)
117
+ api.favoritesList().then(setFavorites).catch(() => { /* favorites stay empty; toggling retries */ })
74
118
  searchRef.current?.focus()
75
119
  // eslint-disable-next-line react-hooks/exhaustive-deps
76
120
  }, [])
77
121
 
122
+ const switchSource = (sourceId: string): void => {
123
+ if (sourceId === activeSource) return
124
+ setActiveSource(sourceId)
125
+ setCategory('')
126
+ setFavoritesOnly(false)
127
+ setSelected(null)
128
+ setNotice(null)
129
+ if (lists[sourceId] === undefined) loadSource(sourceId)
130
+ }
131
+
78
132
  const categories = useMemo(() => {
79
- if (list === null) return [] as Array<{ key: string; label: string; count: number }>
133
+ if (list === undefined) return [] as Array<{ key: string; label: string; count: number }>
80
134
  const counts = new Map<string, { label: string; count: number }>()
81
135
  for (const item of list.cases) {
82
136
  const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 }
@@ -86,17 +140,24 @@ export function TemplateLibrary(props: {
86
140
  return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count }))
87
141
  }, [list])
88
142
 
143
+ /** Favorites of the active source, as standalone case snapshots. */
144
+ const activeFavorites = useMemo(
145
+ () => favorites.filter(entry => entry.sourceId === activeSource).map(entry => entry.case),
146
+ [favorites, activeSource],
147
+ )
148
+ const favKeys = useMemo(() => new Set(favorites.map(entry => entry.key)), [favorites])
149
+
89
150
  const filtered = useMemo(() => {
90
- if (list === null) return [] as TemplateCase[]
151
+ const pool = favoritesOnly ? activeFavorites : list?.cases ?? []
91
152
  const needle = query.trim().toLowerCase()
92
- return list.cases.filter(item => {
153
+ return pool.filter(item => {
93
154
  if (category !== '' && item.category !== category) return false
94
155
  if (needle === '') return true
95
156
  return item.title.toLowerCase().includes(needle)
96
157
  || item.prompt.toLowerCase().includes(needle)
97
158
  || item.sourceLabel.toLowerCase().includes(needle)
98
159
  })
99
- }, [list, query, category])
160
+ }, [list, activeFavorites, favoritesOnly, query, category])
100
161
 
101
162
  // Escape backs out of the detail view first, then closes the modal.
102
163
  useEffect(() => {
@@ -115,10 +176,9 @@ export function TemplateLibrary(props: {
115
176
  setRefreshing(true)
116
177
  setNotice(null)
117
178
  try {
118
- const result = await api.templatesRefresh()
119
- const reloaded = await api.templatesList()
120
- setList(reloaded)
121
- setLoadError(null)
179
+ const result = await api.templatesRefresh(activeSource)
180
+ const reloaded = await api.templatesList(activeSource)
181
+ setLists(current => ({ ...current, [activeSource]: reloaded }))
122
182
  setNotice(tt('templates.refreshed', { count: result.total }))
123
183
  } catch (caught) {
124
184
  setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) }))
@@ -127,9 +187,18 @@ export function TemplateLibrary(props: {
127
187
  }
128
188
  }
129
189
 
130
- /** Mirror every reference image through the host cache (offline browsing). */
190
+ /** Star / unstar one template of the active source. */
191
+ const toggleFavorite = (item: TemplateCase): void => {
192
+ const key = favoriteKeyOf(activeSource, item)
193
+ const pending = favKeys.has(key)
194
+ ? api.favoritesRemove(key)
195
+ : api.favoritesAdd(activeSource, item)
196
+ pending.then(setFavorites).catch(() => { /* leave the star as-is on failure */ })
197
+ }
198
+
199
+ /** Mirror every reference image of the active source through the host cache. */
131
200
  const cacheAllImages = async (): Promise<void> => {
132
- if (cacheAll.running || list === null) return
201
+ if (cacheAll.running || list === undefined) return
133
202
  const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))]
134
203
  setCacheAll({ running: true, done: 0, total: files.length })
135
204
  let index = 0
@@ -138,7 +207,7 @@ export function TemplateLibrary(props: {
138
207
  const file = files[index]!
139
208
  index += 1
140
209
  try {
141
- await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(file)}`)
210
+ await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(activeSource)}/${encodeURIComponent(file)}`)
142
211
  } catch { /* individual failures are retried on the next run */ }
143
212
  setCacheAll(current => ({ ...current, done: current.done + 1 }))
144
213
  }
@@ -169,7 +238,8 @@ export function TemplateLibrary(props: {
169
238
  }
170
239
  }
171
240
 
172
- const originLabel = list === null ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
241
+ const originLabel = list === undefined ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
242
+ const activeMeta = TEMPLATE_SOURCES.find(source => source.id === activeSource)!
173
243
 
174
244
  return createPortal(
175
245
  <div className={css.overlay} role="dialog" aria-modal="true" aria-label={tt('templates.title')} onClick={onClose}>
@@ -177,7 +247,7 @@ export function TemplateLibrary(props: {
177
247
  <header className={css.header}>
178
248
  <span className={css.heading}>
179
249
  <h3 className={css.title}>{tt('templates.title')}</h3>
180
- {list !== null ? (
250
+ {list !== undefined ? (
181
251
  <span className={css.meta}>{tt('templates.meta', { count: list.total, origin: originLabel })}</span>
182
252
  ) : null}
183
253
  </span>
@@ -188,7 +258,7 @@ export function TemplateLibrary(props: {
188
258
  <Button
189
259
  variant="outline"
190
260
  size="sm"
191
- disabled={list === null || cacheAll.running}
261
+ disabled={list === undefined || cacheAll.running}
192
262
  title={tt('templates.cacheAllHint')}
193
263
  onClick={() => { void cacheAllImages() }}
194
264
  >
@@ -204,6 +274,24 @@ export function TemplateLibrary(props: {
204
274
  </span>
205
275
  </header>
206
276
 
277
+ <div className={css.sourceTabs} role="tablist" aria-label={tt('templates.sources')}>
278
+ {TEMPLATE_SOURCES.map(source => (
279
+ <button
280
+ key={source.id}
281
+ type="button"
282
+ role="tab"
283
+ aria-selected={source.id === activeSource}
284
+ className={css.sourceTab}
285
+ data-active={source.id === activeSource ? '' : undefined}
286
+ title={source.description}
287
+ onClick={() => { switchSource(source.id) }}
288
+ >
289
+ {source.label}
290
+ {lists[source.id] !== undefined ? <span className={css.sourceTabCount}>{lists[source.id]!.total}</span> : null}
291
+ </button>
292
+ ))}
293
+ </div>
294
+
207
295
  <div className={css.toolbar}>
208
296
  <input
209
297
  ref={searchRef}
@@ -217,18 +305,27 @@ export function TemplateLibrary(props: {
217
305
  <button
218
306
  type="button"
219
307
  className={css.categoryPill}
220
- data-active={category === '' ? '' : undefined}
221
- onClick={() => { setCategory('') }}
308
+ data-active={favoritesOnly ? '' : undefined}
309
+ title={tt('templates.favoritesHint')}
310
+ onClick={() => { setFavoritesOnly(value => !value) }}
311
+ >
312
+ ★ {tt('templates.favorites')}{activeFavorites.length > 0 ? ` ${activeFavorites.length}` : ''}
313
+ </button>
314
+ <button
315
+ type="button"
316
+ className={css.categoryPill}
317
+ data-active={!favoritesOnly && category === '' ? '' : undefined}
318
+ onClick={() => { setFavoritesOnly(false); setCategory('') }}
222
319
  >
223
- {tt('templates.all')}{list !== null ? ` ${list.total}` : ''}
320
+ {tt('templates.all')}{list !== undefined ? ` ${list.total}` : ''}
224
321
  </button>
225
322
  {categories.map(entry => (
226
323
  <button
227
324
  key={entry.key}
228
325
  type="button"
229
326
  className={css.categoryPill}
230
- data-active={category === entry.key ? '' : undefined}
231
- onClick={() => { setCategory(entry.key) }}
327
+ data-active={!favoritesOnly && category === entry.key ? '' : undefined}
328
+ onClick={() => { setFavoritesOnly(false); setCategory(entry.key) }}
232
329
  >
233
330
  {entry.label} {entry.count}
234
331
  </button>
@@ -239,7 +336,7 @@ export function TemplateLibrary(props: {
239
336
  {notice !== null ? <div className={css.notice} role="status">{notice}</div> : null}
240
337
 
241
338
  <div className={css.body}>
242
- {list === null && loadError === null ? (
339
+ {list === undefined && loadError === null ? (
243
340
  <div className={css.state} role="status">
244
341
  <span className={css.spinner} />
245
342
  <span>{tt('templates.loading')}</span>
@@ -249,28 +346,35 @@ export function TemplateLibrary(props: {
249
346
  {loadError !== null ? (
250
347
  <div className={css.state} role="alert">
251
348
  <span>{tt('templates.loadFailed', { error: loadError })}</span>
252
- <Button variant="outline" size="sm" onClick={() => { setLoadError(null); setList(null); load() }}>
349
+ <Button variant="outline" size="sm" onClick={() => { setLoadErrors(current => ({ ...current, [activeSource]: '' })); loadSource(activeSource) }}>
253
350
  {tt('templates.retry')}
254
351
  </Button>
255
352
  </div>
256
353
  ) : null}
257
354
 
258
- {list !== null && filtered.length === 0 ? (
259
- <div className={css.state}>{tt('templates.empty')}</div>
355
+ {loadError === null && (list !== undefined || favoritesOnly) && filtered.length === 0 ? (
356
+ <div className={css.state}>
357
+ {favoritesOnly && activeFavorites.length === 0 ? tt('templates.favoritesEmpty') : tt('templates.empty')}
358
+ </div>
260
359
  ) : null}
261
360
 
262
- {list !== null && filtered.length > 0 ? (
361
+ {filtered.length > 0 ? (
263
362
  <div className={css.grid}>
264
363
  {filtered.map(item => (
265
364
  <button
266
- key={item.id}
365
+ key={`${activeSource}:${item.id}`}
267
366
  type="button"
268
367
  className={css.card}
269
368
  onClick={() => { setSelected(item); setCopied(false) }}
270
369
  >
271
370
  <span className={css.thumbWrap}>
272
- <TemplateThumb item={item} />
371
+ <TemplateThumb sourceId={activeSource} item={item} />
273
372
  {item.featured ? <span className={css.featuredBadge}>{tt('templates.featured')}</span> : null}
373
+ <FavoriteStar
374
+ active={favKeys.has(favoriteKeyOf(activeSource, item))}
375
+ title={favKeys.has(favoriteKeyOf(activeSource, item)) ? tt('templates.favoriteRemove') : tt('templates.favoriteAdd')}
376
+ onToggle={() => { toggleFavorite(item) }}
377
+ />
274
378
  </span>
275
379
  <span className={css.cardBody}>
276
380
  <span className={css.cardTitle}>{item.title}</span>
@@ -287,8 +391,8 @@ export function TemplateLibrary(props: {
287
391
 
288
392
  <footer className={css.footer}>
289
393
  <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')}
394
+ <a className={css.sourceLink} href={activeMeta.homepage} target="_blank" rel="noreferrer">
395
+ {tt('templates.source', { label: activeMeta.label })}
292
396
  </a>
293
397
  </footer>
294
398
  </section>
@@ -298,7 +402,7 @@ export function TemplateLibrary(props: {
298
402
  <section className={css.detail} onClick={(event) => { event.stopPropagation() }}>
299
403
  <div className={css.detailMedia}>
300
404
  {selected.image !== '' ? (
301
- <img className={css.detailImage} src={imageUrlOf(selected)} alt={selected.title} />
405
+ <img className={css.detailImage} src={imageUrlOf(activeSource, selected)} alt={selected.title} />
302
406
  ) : (
303
407
  <span className={css.thumbPlaceholder} aria-hidden="true" />
304
408
  )}
@@ -322,6 +426,13 @@ export function TemplateLibrary(props: {
322
426
  <Button variant="outline" size="md" onClick={() => { void copyPrompt(selected.prompt) }}>
323
427
  {copied ? tt('templates.copied') : tt('templates.copy')}
324
428
  </Button>
429
+ <Button
430
+ variant="outline"
431
+ size="md"
432
+ onClick={() => { toggleFavorite(selected) }}
433
+ >
434
+ {favKeys.has(favoriteKeyOf(activeSource, selected)) ? tt('templates.unfavorite') : tt('templates.favorite')}
435
+ </Button>
325
436
  <Button variant="outline" size="md" onClick={() => { setSelected(null) }}>
326
437
  {tt('templates.back')}
327
438
  </Button>