@dickpy/dsh-imagegen 1.5.1 → 1.5.3

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/src/engine.ts CHANGED
@@ -84,6 +84,13 @@ function isZhipuImage(model: string): boolean {
84
84
  return modelFamily(model) === 'zhipu'
85
85
  }
86
86
 
87
+ /** Whether the model is Alibaba Qwen-Image, which speaks the DashScope native
88
+ * multimodal-generation contract (NOT OpenAI-compatible): a chat-style
89
+ * messages body, `宽*高` pixel sizes, and image URLs in the reply content. */
90
+ function isQwenImage(model: string): boolean {
91
+ return modelFamily(model) === 'qwen'
92
+ }
93
+
87
94
  function isGlmImage(model: string): boolean {
88
95
  return /^glm-image(?:-|$)/i.test(model.trim())
89
96
  }
@@ -101,6 +108,42 @@ function seedreamSize(quality: string): string {
101
108
  return '2K'
102
109
  }
103
110
 
111
+ /** The panel's aspect ratios mapped to Qwen-Image's `宽*高` pixel sizes.
112
+ * The classic series (qwen-image / -plus / -max) documents this fixed list;
113
+ * 2.0 / 3.0-series models accept any size within their pixel budget and
114
+ * recommend the larger set. */
115
+ const QWEN_SIZE_CLASSIC: Readonly<Record<string, string>> = {
116
+ '16:9': '1664*928',
117
+ '21:9': '1664*928',
118
+ '4:3': '1472*1104',
119
+ '3:2': '1472*1104',
120
+ '1:1': '1328*1328',
121
+ '3:4': '1104*1472',
122
+ '2:3': '1104*1472',
123
+ '9:16': '928*1664',
124
+ }
125
+
126
+ const QWEN_SIZE_HD: Readonly<Record<string, string>> = {
127
+ '16:9': '2688*1536',
128
+ '21:9': '2688*1536',
129
+ '4:3': '2368*1728',
130
+ '3:2': '2368*1728',
131
+ '1:1': '2048*2048',
132
+ '3:4': '1728*2368',
133
+ '2:3': '1728*2368',
134
+ '9:16': '1536*2688',
135
+ }
136
+
137
+ /** Versioned ids (qwen-image-2.0 / -3.0-pro / …) take the large size set. */
138
+ function isVersionedQwenImage(model: string): boolean {
139
+ return /^qwen-image-\d+\.\d/i.test(model.trim())
140
+ }
141
+
142
+ function qwenSize(model: string, ratio: string): string | undefined {
143
+ if (ratio === '' || ratio === 'auto') return undefined
144
+ return (isVersionedQwenImage(model) ? QWEN_SIZE_HD : QWEN_SIZE_CLASSIC)[ratio]
145
+ }
146
+
104
147
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
105
148
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
106
149
  const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
@@ -472,6 +515,110 @@ async function requestOneImage(
472
515
  }))
473
516
  }
474
517
 
518
+ /**
519
+ * Qwen-Image (DashScope native multimodal-generation): one chat-style request
520
+ * carries the prompt (plus the reference image for edit mode) and answers
521
+ * synchronously with image URLs in the reply content. The versioned series
522
+ * batches natively (n ≤ 6; the panel caps at 4), the classic series is
523
+ * single-image per call.
524
+ */
525
+ async function generateQwenImage(
526
+ baseUrl: string,
527
+ upstream: UpstreamConfig,
528
+ request: GenerateRequest,
529
+ options: { signal?: AbortSignal },
530
+ ): Promise<GenerateResult> {
531
+ const model = wireModel(request)
532
+ const content: Array<Record<string, unknown>> = []
533
+ if (request.mode === 'edit') {
534
+ if (typeof request.image !== 'string' || request.image === '') {
535
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
536
+ }
537
+ // DashScope multimodal messages take the reference image as a content
538
+ // item; a base64 data URI rides in the same field as a remote URL.
539
+ const parsed = parseDataUrl(request.image)
540
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
541
+ const bytes = Buffer.from(parsed.base64, 'base64')
542
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
543
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
544
+ }
545
+ content.push({ image: request.image })
546
+ }
547
+ content.push({ text: request.prompt })
548
+
549
+ const batchable = isVersionedQwenImage(model)
550
+ const count = batchable ? clampCount(request.n) : 1
551
+ const size = qwenSize(model, request.size)
552
+ const body = {
553
+ model,
554
+ input: { messages: [{ role: 'user', content }] },
555
+ parameters: {
556
+ ...size !== undefined ? { size } : {},
557
+ ...count > 1 ? { n: count } : {},
558
+ },
559
+ }
560
+
561
+ const budget = requestSignal(options.signal, UPSTREAM_TIMEOUT_MS)
562
+ let response: Response
563
+ try {
564
+ response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
565
+ method: 'POST',
566
+ headers: {
567
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
568
+ 'content-type': 'application/json',
569
+ },
570
+ body: JSON.stringify(body),
571
+ signal: budget.signal,
572
+ })
573
+ } catch (error) {
574
+ const message = error instanceof Error ? error.message : String(error)
575
+ if (/aborter/i.test(message) || /timeout/i.test(message)) {
576
+ throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
577
+ }
578
+ throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
579
+ } finally {
580
+ budget.dispose()
581
+ }
582
+
583
+ let payload: unknown
584
+ try {
585
+ payload = await response.json()
586
+ } catch {
587
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
588
+ }
589
+ if (!response.ok || payload === null || typeof payload !== 'object') {
590
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
591
+ }
592
+
593
+ // output.choices[].message.content[] mixes text and { image: url } items.
594
+ const record = payload as Record<string, unknown>
595
+ const output = record.output as Record<string, unknown> | undefined
596
+ const choices = output !== undefined && Array.isArray(output.choices) ? output.choices : []
597
+ const urls: string[] = []
598
+ for (const choice of choices) {
599
+ const message = choice !== null && typeof choice === 'object'
600
+ ? (choice as Record<string, unknown>).message
601
+ : undefined
602
+ const items = message !== null && typeof message === 'object' && Array.isArray((message as Record<string, unknown>).content)
603
+ ? (message as Record<string, unknown>).content as unknown[]
604
+ : []
605
+ for (const item of items) {
606
+ if (item !== null && typeof item === 'object') {
607
+ const image = (item as Record<string, unknown>).image
608
+ if (typeof image === 'string' && image !== '') urls.push(image)
609
+ }
610
+ }
611
+ }
612
+ if (urls.length === 0) {
613
+ throw new ImageGenError('上游响应缺少图片内容', 'upstream-empty')
614
+ }
615
+ const images = await Promise.all(urls.map(async url => {
616
+ const normalized = await normalizeItem({ url }, upstream)
617
+ return { b64: normalized.b64, mime: normalized.mime }
618
+ }))
619
+ return { images }
620
+ }
621
+
475
622
  /**
476
623
  * Forward one generate request to the configured endpoint. The requested image
477
624
  * count is satisfied with N parallel single-image requests (the `n` batch
@@ -482,6 +629,7 @@ export async function generateImage(upstream: UpstreamConfig, request: GenerateR
482
629
  const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
483
630
  if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
484
631
  if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
632
+ if (isQwenImage(wireModel(request))) return generateQwenImage(baseUrl, upstream, request, options)
485
633
  if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
486
634
  throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
487
635
  }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-tools'
20
20
  import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
21
21
  import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
22
22
  import { makeRoutes, type SettingsSeam } from './routes.ts'
23
+ import { syncAllTemplates } from './templates-store.ts'
23
24
  import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
24
25
  import { registerAgentImageTools } from './agent-image-tools.ts'
25
26
  import { registerEditImageCommand } from './edit-image-command.ts'
@@ -39,7 +40,8 @@ export { ImageGenerationRuntime } from './generation-runtime.ts'
39
40
  export { registerAgentImageTools } from './agent-image-tools.ts'
40
41
  export { latestSessionImage, registerEditImageCommand } from './edit-image-command.ts'
41
42
  export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
42
- export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
43
+ export { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates, syncAllTemplates, clearTemplateMemo } from './templates-store.ts'
44
+ export { addTemplateFavorite, clearTemplateFavoritesMemo, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
43
45
  export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
44
46
 
45
47
  /** The branded settings namespace of this plugin (the card edits it). */
@@ -115,7 +117,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
115
117
  const SECTION_ORDER = 150
116
118
 
117
119
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
118
- export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
120
+ export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送;智谱 `glm-image` 使用官方 `/api/paas/v4/images/generations`,当前仅支持文生图;qwen-image 系列使用阿里云 DashScope 原生接口(api_url 填 https://dashscope.aliyuncs.com/api/v1,不支持 OpenAI 兼容模式,该渠道不可复用于提示词增强,尺寸自动映射为宽*高)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;检测模型时会过滤聊天、Embedding 等非图片模型,但模型出现在 /models 中仍不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):多来源标签页(精选案例库 / 沧河案例库,后续可扩展),打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选、收藏(星标,宿主持久化)与复用;各来源列表独立刷新,宿主每 12 小时后台自动同步一次。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。用户也可以使用 `/edit_image <修改描述>`,命令会直接读取当前对话最近图片并调用插件图片模型,不经过对话模型的图片能力检查。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问对应来源站点(vibeui.top / gpt-image2.canghe.ai)。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
119
121
 
120
122
  /** Append the live channel × model table so an Agent can honor user choices. */
121
123
  function guidanceFor(channels: RuntimeChannel[], defaultChannelId: string): string {
@@ -276,7 +278,25 @@ export function apply(ctx: Context, config?: Config): void {
276
278
  runtime,
277
279
  })
278
280
  const disposers = routes.map(route => ctx.webServer.register(route))
279
- return () => { for (const dispose of disposers) dispose() }
281
+ // Background template sync: the upstream sources update on their own
282
+ // schedule, so pull every one of them shortly after start and then
283
+ // twice a day while the plugin stays enabled. Best-effort: failures
284
+ // keep the last good snapshot (bundled or previously refreshed).
285
+ const TEMPLATE_SYNC_INITIAL_DELAY_MS = 30_000
286
+ const TEMPLATE_SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000
287
+ let syncTimer: NodeJS.Timeout | undefined
288
+ const runSync = (): void => {
289
+ if (!resolve().enabled) return
290
+ void syncAllTemplates().catch(() => { /* keep the last good snapshot */ })
291
+ }
292
+ const startTimer = setTimeout(runSync, TEMPLATE_SYNC_INITIAL_DELAY_MS)
293
+ syncTimer = setInterval(runSync, TEMPLATE_SYNC_INTERVAL_MS)
294
+ syncTimer.unref?.()
295
+ return () => {
296
+ clearTimeout(startTimer)
297
+ clearInterval(syncTimer)
298
+ for (const dispose of disposers) dispose()
299
+ }
280
300
  },
281
301
  'dsh-imagegen: routes',
282
302
  )
@@ -8,7 +8,7 @@
8
8
  * Framework-free (pure data + regex), safe for the client bundle to inline.
9
9
  */
10
10
 
11
- export type ModelFamily = 'gpt-image' | 'dall-e' | 'grok' | 'nanobanana' | 'seedream' | 'zhipu' | 'unknown'
11
+ export type ModelFamily = 'gpt-image' | 'dall-e' | 'grok' | 'nanobanana' | 'seedream' | 'zhipu' | 'qwen' | 'unknown'
12
12
 
13
13
  /** Capability/identity annotation for one model id. */
14
14
  export interface ModelCatalogEntry {
@@ -77,6 +77,14 @@ const ENTRIES: Record<Exclude<ModelFamily, 'unknown'>, Omit<ModelCatalogEntry, '
77
77
  supportsAspectRatio: false,
78
78
  qualityTiers: ['HD'],
79
79
  },
80
+ qwen: {
81
+ label: 'qwen-image',
82
+ labelZh: '千问图像',
83
+ known: true,
84
+ supportsEdit: true,
85
+ supportsAspectRatio: true,
86
+ qualityTiers: ['auto'],
87
+ },
80
88
  }
81
89
 
82
90
  /** Official Gemini image ids served by Nano Banana gateways. */
@@ -98,6 +106,7 @@ export function describeModel(model: string): ModelCatalogEntry {
98
106
  if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return { family: 'nanobanana', ...ENTRIES.nanobanana }
99
107
  if (/^(?:doubao-)?seedream/i.test(id)) return { family: 'seedream', ...ENTRIES.seedream }
100
108
  if (/^(?:glm-image|cogview(?:-|$))/i.test(id)) return { family: 'zhipu', ...ENTRIES.zhipu }
109
+ if (/^qwen-image(?:[-_.]|$)/i.test(id)) return { family: 'qwen', ...ENTRIES.qwen }
101
110
  return { family: 'unknown', label: 'unknown', labelZh: '未知协议', known: false, supportsEdit: true, supportsAspectRatio: false, qualityTiers: [] }
102
111
  }
103
112
 
package/src/presets.ts CHANGED
@@ -54,6 +54,21 @@ export const IMAGE_PRESETS: PresetProvider[] = [
54
54
  { alias: 'glm-image', id: 'glm-image' },
55
55
  ],
56
56
  },
57
+ {
58
+ id: 'aliyun-dashscope-qwen',
59
+ name: '阿里云百炼(Qwen-Image)',
60
+ apiUrl: 'https://dashscope.aliyuncs.com/api/v1',
61
+ hint: '阿里云百炼 DashScope 原生接口:通义千问 Qwen-Image 系列(该渠道不可复用于提示词增强)',
62
+ models: [
63
+ { alias: 'qwen-image-3.0-pro', id: 'qwen-image-3.0-pro' },
64
+ { alias: 'qwen-image-3.0', id: 'qwen-image-3.0' },
65
+ { alias: 'qwen-image-2.0-pro', id: 'qwen-image-2.0-pro' },
66
+ { alias: 'qwen-image-2.0', id: 'qwen-image-2.0' },
67
+ { alias: 'qwen-image-max', id: 'qwen-image-max' },
68
+ { alias: 'qwen-image-plus', id: 'qwen-image-plus' },
69
+ { alias: 'qwen-image', id: 'qwen-image' },
70
+ ],
71
+ },
57
72
  {
58
73
  id: 'xai-grok',
59
74
  name: 'xAI(Grok)',
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.5.1'
11
+ export const PLUGIN_VERSION = '1.5.3'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -92,17 +92,68 @@ export const GALLERY_API = {
92
92
  export const HISTORY_MAX = 50
93
93
 
94
94
  /**
95
- * Same-origin route family for the bundled prompt-template library
96
- * (awesome-gpt-image-2 mirror). The case list ships inside the package and is
97
- * served by the host; reference images are proxied through the `image` prefix
98
- * route and cached on disk so repeated views never hit the network again.
95
+ * Same-origin route family for the prompt-template libraries. The library is
96
+ * multi-source: every request names a source id from {@link TEMPLATE_SOURCES},
97
+ * each source keeps an independent snapshot/image cache host-side, and
98
+ * reference images are proxied through the source-scoped `image` prefix route
99
+ * (`…/image/<sourceId>/<file>`) and cached on disk so repeated views never hit
100
+ * the network again.
99
101
  */
100
102
  export const TEMPLATES_API = {
101
103
  list: '/api/dsh-imagegen/templates/list',
102
104
  refresh: '/api/dsh-imagegen/templates/refresh',
105
+ sample: '/api/dsh-imagegen/templates/sample',
103
106
  image: '/api/dsh-imagegen/templates/image',
104
107
  } as const
105
108
 
109
+ /** Same-origin route family for the user's saved (favorited) templates. */
110
+ export const TEMPLATE_FAVORITES_API = {
111
+ list: '/api/dsh-imagegen/templates/favorites/list',
112
+ add: '/api/dsh-imagegen/templates/favorites/add',
113
+ remove: '/api/dsh-imagegen/templates/favorites/remove',
114
+ } as const
115
+
116
+ /** One prompt-template library source (a tab in the library overlay). */
117
+ export interface TemplateSourceMeta {
118
+ /** Stable source id: snapshot dir name, image-cache dir, and request key. */
119
+ id: string
120
+ /** Tab label shown in the library overlay. */
121
+ label: string
122
+ /** Source homepage linked in the overlay footer. */
123
+ homepage: string
124
+ /** One-line description of the source (tab tooltip). */
125
+ description: string
126
+ }
127
+
128
+ /**
129
+ * The template-library source registry. Each entry is fully independent (own
130
+ * upstream JSON, own image pool, own refresh state) and renders as its own
131
+ * tab; adding a source later means appending an entry here plus a host-side
132
+ * fetch definition in templates-store.ts and an optional bundled snapshot.
133
+ */
134
+ export const TEMPLATE_SOURCES: TemplateSourceMeta[] = [
135
+ {
136
+ id: 'vibeui',
137
+ label: '精选案例库',
138
+ homepage: 'https://vibeui.top/',
139
+ description: 'awesome-gpt-image-2 精选提示词案例(vibeui.top 镜像)',
140
+ },
141
+ {
142
+ id: 'canghe',
143
+ label: '沧河案例库',
144
+ homepage: 'https://gpt-image2.canghe.ai/',
145
+ description: 'GPT-Image2 Prompt Gallery(gpt-image2.canghe.ai,定期更新)',
146
+ },
147
+ ]
148
+
149
+ /** Default source id when a request does not name one (legacy clients). */
150
+ export const DEFAULT_TEMPLATE_SOURCE_ID = TEMPLATE_SOURCES[0]!.id
151
+
152
+ /** True when the id names a registered template source. */
153
+ export function isTemplateSourceId(id: string): boolean {
154
+ return TEMPLATE_SOURCES.some(source => source.id === id)
155
+ }
156
+
106
157
  /** One prompt-library case as the browser consumes it. */
107
158
  export interface TemplateCase {
108
159
  /** Upstream case number (stable across refreshes). */
@@ -131,8 +182,10 @@ export interface TemplateCase {
131
182
  featured: boolean
132
183
  }
133
184
 
134
- /** Template-library list payload. */
185
+ /** Template-library list payload (one source). */
135
186
  export interface TemplateListResult {
187
+ /** The source this list belongs to. */
188
+ sourceId: string
136
189
  cases: TemplateCase[]
137
190
  total: number
138
191
  /** Where the served list came from. */
@@ -143,12 +196,33 @@ export interface TemplateListResult {
143
196
  fetchedAt: string
144
197
  }
145
198
 
146
- /** Template-library refresh outcome. */
199
+ /** Template-library refresh outcome (one source). */
147
200
  export interface TemplateRefreshResult {
201
+ sourceId: string
148
202
  total: number
149
203
  fetchedAt: string
150
204
  }
151
205
 
206
+ /** One random inspiration pick served to the studio's empty state. */
207
+ export interface TemplateSample {
208
+ /** Source the case came from (drives the image proxy URL). */
209
+ sourceId: string
210
+ /** The sampled case (full prompt is handed to the form on use). */
211
+ case: TemplateCase
212
+ }
213
+
214
+ /** One favorited template as persisted host-side and served to the browser. */
215
+ export interface TemplateFavorite {
216
+ /** Stable key: `${sourceId}:${caseId}`. */
217
+ key: string
218
+ /** Source the case came from. */
219
+ sourceId: string
220
+ /** ISO time the favorite was saved. */
221
+ savedAt: string
222
+ /** Full case snapshot, so favorites survive upstream list churn. */
223
+ case: TemplateCase
224
+ }
225
+
152
226
  /** Generation modes. */
153
227
  export type GenerateMode = 'text' | 'edit'
154
228
 
package/src/routes.ts CHANGED
@@ -16,10 +16,11 @@ import { normalizeImageModels } from './image-models.ts'
16
16
  import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
17
17
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
18
18
  import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
19
- import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
19
+ import { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates } from './templates-store.ts'
20
+ import { addTemplateFavorite, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
20
21
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
21
22
  import { IMAGE_PRESETS } from './presets.ts'
22
- import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
23
+ import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, DEFAULT_TEMPLATE_SOURCE_ID, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, USAGE_API, isTemplateSourceId, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
23
24
 
24
25
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
25
26
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -74,9 +75,16 @@ export interface ImageGenRoutesDeps {
74
75
  }
75
76
  /** Overrideable template-library backend, primarily for host integration tests. */
76
77
  templates?: {
77
- list: () => Promise<TemplateListResult>
78
- refresh: () => Promise<TemplateRefreshResult>
79
- readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
78
+ list: (sourceId: string) => Promise<TemplateListResult>
79
+ refresh: (sourceId: string) => Promise<TemplateRefreshResult>
80
+ sample: (count: number) => Promise<TemplateSample[]>
81
+ readImage: (sourceId: string, file: string) => Promise<{ data: Buffer; mime: string } | undefined>
82
+ }
83
+ /** Overrideable template-favorites backend, primarily for host integration tests. */
84
+ favorites?: {
85
+ list: () => Promise<TemplateFavorite[]>
86
+ add: (sourceId: string, item: TemplateFavorite['case']) => Promise<TemplateFavorite[]>
87
+ remove: (key: string) => Promise<TemplateFavorite[]>
80
88
  }
81
89
  /** Shared host queue, used by Agent tools and browser task endpoints. */
82
90
  runtime?: ImageGenerationRuntime
@@ -135,6 +143,13 @@ function messageOf(error: unknown): string {
135
143
  return error instanceof Error ? error.message : String(error)
136
144
  }
137
145
 
146
+ /** Validate the { source } body of a template-library request. */
147
+ function templateSourceOf(body: Record<string, unknown> | undefined): string | undefined {
148
+ const raw = body?.source
149
+ if (raw === undefined || raw === '') return DEFAULT_TEMPLATE_SOURCE_ID
150
+ return typeof raw === 'string' && isTemplateSourceId(raw) ? raw : undefined
151
+ }
152
+
138
153
  function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
139
154
  const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
140
155
  if (prompt === '') return undefined
@@ -311,8 +326,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
311
326
  const templates = deps.templates ?? {
312
327
  list: listTemplates,
313
328
  refresh: refreshTemplates,
329
+ sample: sampleTemplates,
314
330
  readImage: readTemplateImage,
315
331
  }
332
+ const favorites = deps.favorites ?? {
333
+ list: listTemplateFavorites,
334
+ add: addTemplateFavorite,
335
+ remove: removeTemplateFavorite,
336
+ }
316
337
  const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
317
338
  const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
318
339
 
@@ -908,8 +929,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
908
929
  path: TEMPLATES_API.list,
909
930
  handler: async (req, res) => {
910
931
  if (!guard(req, res, 'POST')) return
932
+ const body = await readJsonBody(req)
933
+ const sourceId = templateSourceOf(body)
934
+ if (sourceId === undefined) {
935
+ writeJson(res, 200, { ok: false, code: 'templates-source-unknown', message: `未知的模板库来源:${String(body?.source ?? '')}` })
936
+ return
937
+ }
911
938
  try {
912
- const result = await templates.list()
939
+ const result = await templates.list(sourceId)
913
940
  writeJson(res, 200, { ok: true, ...result })
914
941
  } catch (error) {
915
942
  writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
@@ -922,14 +949,36 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
922
949
  path: TEMPLATES_API.refresh,
923
950
  handler: async (req, res) => {
924
951
  if (!guard(req, res, 'POST')) return
952
+ const body = await readJsonBody(req)
953
+ const sourceId = templateSourceOf(body)
954
+ if (sourceId === undefined) {
955
+ writeJson(res, 200, { ok: false, code: 'templates-source-unknown', message: `未知的模板库来源:${String(body?.source ?? '')}` })
956
+ return
957
+ }
925
958
  try {
926
- const result = await templates.refresh()
959
+ const result = await templates.refresh(sourceId)
927
960
  writeJson(res, 200, { ok: true, ...result })
928
961
  } catch (error) {
929
962
  writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
930
963
  }
931
964
  },
932
965
  },
966
+ // --------------------------------------------- templates random sample
967
+ {
968
+ kind: 'exact',
969
+ path: TEMPLATES_API.sample,
970
+ handler: async (req, res) => {
971
+ if (!guard(req, res, 'POST')) return
972
+ const body = await readJsonBody(req)
973
+ const requested = Number(body?.count)
974
+ const count = Number.isFinite(requested) ? requested : 9
975
+ try {
976
+ writeJson(res, 200, { ok: true, samples: await templates.sample(count) })
977
+ } catch (error) {
978
+ writeJson(res, 200, { ok: false, code: 'templates-sample-failed', message: messageOf(error) })
979
+ }
980
+ },
981
+ },
933
982
  // -------------------------------------- templates image (prefix, proxied)
934
983
  {
935
984
  kind: 'prefix',
@@ -943,12 +992,17 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
943
992
  writeJson(res, 405, { error: `method not allowed: ${req.method}` })
944
993
  return
945
994
  }
946
- const file = imageFileFrom(req.url, TEMPLATES_API.image)
947
- if (file === undefined) {
995
+ // Source-scoped: /image/<sourceId>/<file> (file names collide across
996
+ // sources, so the pool on disk is per source).
997
+ const raw = imageFileFrom(req.url, TEMPLATES_API.image)
998
+ const slash = raw?.indexOf('/') ?? -1
999
+ const sourceId = slash > 0 ? raw!.slice(0, slash) : ''
1000
+ const file = slash > 0 ? raw!.slice(slash + 1) : ''
1001
+ if (sourceId === '' || !isTemplateSourceId(sourceId) || file === '') {
948
1002
  writeJson(res, 404, { error: 'not found' })
949
1003
  return
950
1004
  }
951
- const found = await templates.readImage(file)
1005
+ const found = await templates.readImage(sourceId, file)
952
1006
  if (found === undefined) {
953
1007
  writeJson(res, 404, { error: 'not found' })
954
1008
  return
@@ -962,5 +1016,65 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
962
1016
  res.end(found.data)
963
1017
  },
964
1018
  },
1019
+ // ------------------------------------------ template favorites: list
1020
+ {
1021
+ kind: 'exact',
1022
+ path: TEMPLATE_FAVORITES_API.list,
1023
+ handler: async (req, res) => {
1024
+ if (!guard(req, res, 'POST')) return
1025
+ try {
1026
+ writeJson(res, 200, { ok: true, favorites: await favorites.list() })
1027
+ } catch (error) {
1028
+ writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
1029
+ }
1030
+ },
1031
+ },
1032
+ // ------------------------------------------- template favorites: add
1033
+ {
1034
+ kind: 'exact',
1035
+ path: TEMPLATE_FAVORITES_API.add,
1036
+ handler: async (req, res) => {
1037
+ if (!guard(req, res, 'POST')) return
1038
+ const body = await readJsonBody(req)
1039
+ const sourceId = templateSourceOf(body)
1040
+ const rawCase = body?.case
1041
+ if (sourceId === undefined || rawCase === null || typeof rawCase !== 'object') {
1042
+ writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '收藏请求缺少有效的来源或模板数据' })
1043
+ return
1044
+ }
1045
+ const record = rawCase as Record<string, unknown>
1046
+ const id = Number(record.id)
1047
+ const title = typeof record.title === 'string' ? record.title.trim() : ''
1048
+ const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : ''
1049
+ if (!Number.isInteger(id) || title === '' || prompt === '') {
1050
+ writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '收藏请求缺少有效的模板数据' })
1051
+ return
1052
+ }
1053
+ try {
1054
+ writeJson(res, 200, { ok: true, favorites: await favorites.add(sourceId, rawCase as TemplateFavorite['case']) })
1055
+ } catch (error) {
1056
+ writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
1057
+ }
1058
+ },
1059
+ },
1060
+ // ---------------------------------------- template favorites: remove
1061
+ {
1062
+ kind: 'exact',
1063
+ path: TEMPLATE_FAVORITES_API.remove,
1064
+ handler: async (req, res) => {
1065
+ if (!guard(req, res, 'POST')) return
1066
+ const body = await readJsonBody(req)
1067
+ const key = typeof body?.key === 'string' ? body.key : ''
1068
+ if (key === '') {
1069
+ writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '取消收藏请求缺少模板标识' })
1070
+ return
1071
+ }
1072
+ try {
1073
+ writeJson(res, 200, { ok: true, favorites: await favorites.remove(key) })
1074
+ } catch (error) {
1075
+ writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
1076
+ }
1077
+ },
1078
+ },
965
1079
  ]
966
1080
  }