@dickpy/dsh-imagegen 1.0.0 → 1.0.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.
package/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
2
  import z from "schemastery";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { promises } from "node:fs";
4
5
  import { homedir } from "node:os";
5
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
6
8
  //#region src/protocol.ts
7
9
  /**
8
10
  * Wire contract shared by the host and client halves of dsh-imagegen: the
@@ -18,6 +20,11 @@ const SETTINGS_API = {
18
20
  };
19
21
  /** The image-generation proxy route. */
20
22
  const GENERATE_API = "/api/dsh-imagegen/generate";
23
+ /** Host-mediated GitHub Release update routes. */
24
+ const UPDATE_API = {
25
+ check: "/api/dsh-imagegen/update/check",
26
+ apply: "/api/dsh-imagegen/update/apply"
27
+ };
21
28
  /**
22
29
  * Same-origin route family for the host-persisted generation history. Images
23
30
  * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
@@ -260,6 +267,12 @@ function extensionOf$1(mime) {
260
267
  const HISTORY_DIR = path.join(homedir(), ".dsh", "dsh-imagegen");
261
268
  const INDEX_PATH = path.join(HISTORY_DIR, "index.json");
262
269
  const IMAGES_DIR = path.join(HISTORY_DIR, "images");
270
+ let pendingMutation = Promise.resolve();
271
+ function mutateHistory(operation) {
272
+ const next = pendingMutation.then(operation, operation);
273
+ pendingMutation = next.then(() => void 0, () => void 0);
274
+ return next;
275
+ }
263
276
  /** File extension for a MIME type (image file names). */
264
277
  function extensionOf(mime) {
265
278
  switch (mime.split(";")[0].trim()) {
@@ -351,52 +364,63 @@ async function listHistory() {
351
364
  }
352
365
  /** Append one generation, evicting the oldest beyond HISTORY_MAX. */
353
366
  async function appendHistory(input) {
354
- await ensureDirs();
355
- const prefix = safeId(input.id);
356
- const storedImages = [];
357
- for (let index = 0; index < input.images.length; index++) {
358
- const image = input.images[index];
359
- const file = `${prefix}-${index}.${extensionOf(image.mime)}`;
360
- await promises.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, "base64"));
361
- storedImages.push({
362
- file,
363
- mime: image.mime,
364
- ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
365
- });
366
- }
367
- const merged = [{
368
- id: input.id,
369
- createdAt: input.createdAt,
370
- mode: input.mode,
371
- model: input.model,
372
- prompt: input.prompt,
373
- size: input.size,
374
- quality: input.quality,
375
- detail: input.detail,
376
- n: input.n,
377
- images: storedImages,
378
- ...input.refName === void 0 ? {} : { refName: input.refName }
379
- }, ...await readIndex()];
380
- const kept = merged.slice(0, 50);
381
- for (const dropped of merged.slice(50)) await removeEntryFiles(dropped);
382
- await writeIndex(kept);
383
- return kept.map(toWire);
367
+ return mutateHistory(async () => {
368
+ await ensureDirs();
369
+ const prefix = safeId(input.id);
370
+ const storedImages = [];
371
+ try {
372
+ for (let index = 0; index < input.images.length; index++) {
373
+ const image = input.images[index];
374
+ const file = `${prefix}-${index}.${extensionOf(image.mime)}`;
375
+ await promises.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, "base64"));
376
+ storedImages.push({
377
+ file,
378
+ mime: image.mime,
379
+ ...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
380
+ });
381
+ }
382
+ } catch (error) {
383
+ await removeEntryFiles({ images: storedImages });
384
+ throw error;
385
+ }
386
+ const merged = [{
387
+ id: input.id,
388
+ createdAt: input.createdAt,
389
+ mode: input.mode,
390
+ model: input.model,
391
+ prompt: input.prompt,
392
+ size: input.size,
393
+ quality: input.quality,
394
+ detail: input.detail,
395
+ n: input.n,
396
+ images: storedImages,
397
+ ...input.refName === void 0 ? {} : { refName: input.refName }
398
+ }, ...await readIndex()];
399
+ const kept = merged.slice(0, 50);
400
+ for (const dropped of merged.slice(50)) await removeEntryFiles(dropped);
401
+ await writeIndex(kept);
402
+ return kept.map(toWire);
403
+ });
384
404
  }
385
405
  /** Remove one entry (and its image files). */
386
406
  async function removeHistory(id) {
387
- const previous = await readIndex();
388
- const target = previous.find((entry) => entry.id === id);
389
- if (target !== void 0) await removeEntryFiles(target);
390
- const kept = previous.filter((entry) => entry.id !== id);
391
- await writeIndex(kept);
392
- return kept.map(toWire);
407
+ return mutateHistory(async () => {
408
+ const previous = await readIndex();
409
+ const target = previous.find((entry) => entry.id === id);
410
+ if (target !== void 0) await removeEntryFiles(target);
411
+ const kept = previous.filter((entry) => entry.id !== id);
412
+ await writeIndex(kept);
413
+ return kept.map(toWire);
414
+ });
393
415
  }
394
416
  /** Remove every entry (and all image files). */
395
417
  async function clearHistory() {
396
- const previous = await readIndex();
397
- for (const entry of previous) await removeEntryFiles(entry);
398
- await writeIndex([]);
399
- return [];
418
+ return mutateHistory(async () => {
419
+ const previous = await readIndex();
420
+ for (const entry of previous) await removeEntryFiles(entry);
421
+ await writeIndex([]);
422
+ return [];
423
+ });
400
424
  }
401
425
  /** Read one stored image file by its (validated) file name. */
402
426
  async function readHistoryImage(file) {
@@ -411,6 +435,106 @@ async function readHistoryImage(file) {
411
435
  }
412
436
  }
413
437
  //#endregion
438
+ //#region src/updater.ts
439
+ /** GitHub Release discovery and explicit, user-triggered plugin updates. */
440
+ /** Keep this in sync with package.json for each published release. */
441
+ const CURRENT_VERSION = "1.0.2";
442
+ const PACKAGE_NAME = "@dickpy/dsh-imagegen";
443
+ const RELEASES_URL = "https://api.github.com/repos/dickpy/dsh-imagegen/releases/latest";
444
+ const CHECK_TIMEOUT_MS = 1e4;
445
+ const CACHE_TTL_MS = 15 * 6e4;
446
+ let cached;
447
+ /** Compare stable semver triples; returns positive when `left` is newer. */
448
+ function compareVersions(left, right) {
449
+ const parse = (value) => {
450
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
451
+ if (match === null) return [
452
+ 0,
453
+ 0,
454
+ 0
455
+ ];
456
+ return [
457
+ Number(match[1]),
458
+ Number(match[2]),
459
+ Number(match[3])
460
+ ];
461
+ };
462
+ const a = parse(left);
463
+ const b = parse(right);
464
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
465
+ }
466
+ function normalizedReleaseVersion(tag) {
467
+ if (typeof tag !== "string" || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag.trim())) return void 0;
468
+ return tag.trim().replace(/^v/, "");
469
+ }
470
+ /** Read the latest stable GitHub Release, with a short host-side cache. */
471
+ async function checkForUpdate(fetchFn = fetch, now = Date.now()) {
472
+ if (cached !== void 0 && cached.expiresAt > now) return cached.value;
473
+ const response = await fetchFn(RELEASES_URL, {
474
+ headers: {
475
+ accept: "application/vnd.github+json",
476
+ "user-agent": "dsh-imagegen-update-check"
477
+ },
478
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
479
+ });
480
+ if (!response.ok) throw new Error(`GitHub Releases returned HTTP ${response.status}`);
481
+ const payload = await response.json();
482
+ if (payload === null || typeof payload !== "object") throw new Error("GitHub Releases returned malformed JSON");
483
+ const release = payload;
484
+ if (release.draft === true || release.prerelease === true) throw new Error("latest GitHub Release is not stable");
485
+ const latestVersion = normalizedReleaseVersion(release.tag_name);
486
+ if (latestVersion === void 0) throw new Error("latest GitHub Release has an invalid version tag");
487
+ const releaseUrl = typeof release.html_url === "string" ? release.html_url : "https://github.com/dickpy/dsh-imagegen/releases";
488
+ const value = {
489
+ currentVersion: CURRENT_VERSION,
490
+ latestVersion,
491
+ updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
492
+ releaseUrl,
493
+ ...typeof release.published_at === "string" ? { publishedAt: release.published_at } : {}
494
+ };
495
+ cached = {
496
+ expiresAt: now + CACHE_TTL_MS,
497
+ value
498
+ };
499
+ return value;
500
+ }
501
+ /** Resolve the profile that launched the current DSH process. */
502
+ function profileFromProcess(argv = process.argv, env = process.env) {
503
+ const envProfile = env.DSH_PROFILE?.trim();
504
+ if (envProfile !== void 0 && /^[a-zA-Z0-9_-]+$/.test(envProfile)) return envProfile;
505
+ const profileIndex = argv.indexOf("--profile");
506
+ const explicit = profileIndex >= 0 ? argv[profileIndex + 1]?.trim() : void 0;
507
+ if (explicit !== void 0 && /^[a-zA-Z0-9_-]+$/.test(explicit)) return explicit;
508
+ if (argv.includes("web")) return "web";
509
+ return "web";
510
+ }
511
+ /** Run the same official command documented for plugin installation. */
512
+ function installUpdate(version, spawnFn = spawn, argv = process.argv, env = process.env) {
513
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) return Promise.reject(/* @__PURE__ */ new Error("invalid update version"));
514
+ const profile = profileFromProcess(argv, env);
515
+ const child = spawnFn(process.platform === "win32" ? "dsh.cmd" : "dsh", [
516
+ "plugin",
517
+ "--profile",
518
+ profile,
519
+ "add",
520
+ `${PACKAGE_NAME}@${version}`
521
+ ], {
522
+ shell: process.platform === "win32",
523
+ stdio: "ignore"
524
+ });
525
+ return new Promise((resolve, reject) => {
526
+ child.once("error", reject);
527
+ child.once("exit", (code, signal) => {
528
+ if (code === 0) resolve();
529
+ else reject(/* @__PURE__ */ new Error(signal === null ? `plugin update exited with code ${code ?? "unknown"}` : `plugin update terminated by ${signal}`));
530
+ });
531
+ });
532
+ }
533
+ /** Test helper: clear the host-side Release cache. */
534
+ function clearUpdateCache() {
535
+ cached = void 0;
536
+ }
537
+ //#endregion
414
538
  //#region src/routes.ts
415
539
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
416
540
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -550,6 +674,13 @@ function failureOf(error) {
550
674
  * @returns the route registrations.
551
675
  */
552
676
  function makeRoutes(deps) {
677
+ const history = deps.history ?? {
678
+ list: listHistory,
679
+ append: appendHistory,
680
+ remove: removeHistory,
681
+ clear: clearHistory,
682
+ readImage: readHistoryImage
683
+ };
553
684
  const guard = (req, res, method) => {
554
685
  if (!isLoopbackRequest(req)) {
555
686
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -661,13 +792,37 @@ function makeRoutes(deps) {
661
792
  quality: typeof body.quality === "string" ? body.quality : "auto",
662
793
  n: typeof body.n === "number" ? body.n : 1,
663
794
  detail: typeof body.detail === "string" ? body.detail : "",
664
- ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {}
795
+ ...typeof body.image === "string" && body.image !== "" ? { image: body.image } : {},
796
+ ...typeof body.refName === "string" && body.refName !== "" ? { refName: body.refName } : {}
665
797
  };
666
798
  try {
667
- writeJson(res, 200, {
668
- ok: true,
669
- ...await generateImage(deps.resolve(), request)
670
- });
799
+ const result = await generateImage(deps.resolve(), request);
800
+ try {
801
+ const entries = await history.append({
802
+ id: randomUUID(),
803
+ createdAt: Date.now(),
804
+ mode: request.mode,
805
+ model: request.model,
806
+ prompt: request.prompt,
807
+ size: request.size,
808
+ quality: request.quality,
809
+ detail: request.detail,
810
+ n: request.n,
811
+ images: result.images,
812
+ ...request.refName === void 0 ? {} : { refName: request.refName }
813
+ });
814
+ writeJson(res, 200, {
815
+ ok: true,
816
+ ...result,
817
+ history: entries
818
+ });
819
+ } catch (error) {
820
+ writeJson(res, 200, {
821
+ ok: true,
822
+ ...result,
823
+ historyError: messageOf(error)
824
+ });
825
+ }
671
826
  } catch (error) {
672
827
  const message = error instanceof Error ? error.message : String(error);
673
828
  writeJson(res, 200, {
@@ -678,6 +833,66 @@ function makeRoutes(deps) {
678
833
  }
679
834
  }
680
835
  },
836
+ {
837
+ kind: "exact",
838
+ path: UPDATE_API.check,
839
+ handler: async (req, res) => {
840
+ if (!guard(req, res, "POST")) return;
841
+ try {
842
+ writeJson(res, 200, {
843
+ ok: true,
844
+ update: await checkForUpdate()
845
+ });
846
+ } catch (error) {
847
+ writeJson(res, 200, {
848
+ ok: false,
849
+ code: "update-check-failed",
850
+ message: messageOf(error)
851
+ });
852
+ }
853
+ }
854
+ },
855
+ {
856
+ kind: "exact",
857
+ path: UPDATE_API.apply,
858
+ handler: async (req, res) => {
859
+ if (!guard(req, res, "POST")) return;
860
+ const body = await readJsonBody(req);
861
+ const version = body !== void 0 && typeof body.version === "string" ? body.version.trim() : "";
862
+ if (version === "") {
863
+ writeJson(res, 200, {
864
+ ok: false,
865
+ code: "bad-request",
866
+ message: "update version is required"
867
+ });
868
+ return;
869
+ }
870
+ try {
871
+ const latest = await checkForUpdate();
872
+ if (!latest.updateAvailable || latest.latestVersion !== version) {
873
+ writeJson(res, 200, {
874
+ ok: false,
875
+ code: "update-not-available",
876
+ message: `version ${version} is not the latest available release`
877
+ });
878
+ return;
879
+ }
880
+ await installUpdate(version);
881
+ writeJson(res, 200, {
882
+ ok: true,
883
+ currentVersion: CURRENT_VERSION,
884
+ updatedVersion: version,
885
+ restartRequired: true
886
+ });
887
+ } catch (error) {
888
+ writeJson(res, 200, {
889
+ ok: false,
890
+ code: "update-failed",
891
+ message: messageOf(error)
892
+ });
893
+ }
894
+ }
895
+ },
681
896
  {
682
897
  kind: "exact",
683
898
  path: HISTORY_API.list,
@@ -686,7 +901,7 @@ function makeRoutes(deps) {
686
901
  try {
687
902
  writeJson(res, 200, {
688
903
  ok: true,
689
- entries: await listHistory()
904
+ entries: await history.list()
690
905
  });
691
906
  } catch (error) {
692
907
  writeJson(res, 200, {
@@ -723,7 +938,7 @@ function makeRoutes(deps) {
723
938
  try {
724
939
  writeJson(res, 200, {
725
940
  ok: true,
726
- entries: await appendHistory(entry)
941
+ entries: await history.append(entry)
727
942
  });
728
943
  } catch (error) {
729
944
  writeJson(res, 200, {
@@ -752,7 +967,7 @@ function makeRoutes(deps) {
752
967
  try {
753
968
  writeJson(res, 200, {
754
969
  ok: true,
755
- entries: await removeHistory(id)
970
+ entries: await history.remove(id)
756
971
  });
757
972
  } catch (error) {
758
973
  writeJson(res, 200, {
@@ -771,7 +986,7 @@ function makeRoutes(deps) {
771
986
  try {
772
987
  writeJson(res, 200, {
773
988
  ok: true,
774
- entries: await clearHistory()
989
+ entries: await history.clear()
775
990
  });
776
991
  } catch (error) {
777
992
  writeJson(res, 200, {
@@ -799,7 +1014,7 @@ function makeRoutes(deps) {
799
1014
  writeJson(res, 404, { error: "not found" });
800
1015
  return;
801
1016
  }
802
- const found = await readHistoryImage(file);
1017
+ const found = await history.readImage(file);
803
1018
  if (found === void 0) {
804
1019
  writeJson(res, 404, { error: "not found" });
805
1020
  return;
@@ -834,7 +1049,7 @@ const DEFAULT_ANNOUNCE = true;
834
1049
  /** Order of the announcement section within the tool-guidance band. */
835
1050
  const SECTION_ORDER = 150;
836
1051
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
837
- const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口;本地插件(源码位于 E:\\dsh-plugin,独立于 dsh-web-ui 插件全家桶)。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。";
1052
+ const IMAGEGEN_GUIDANCE = "本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits,上传参考图);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / 文生图 / 图生图」时即指本插件,请据此协作。";
838
1053
  /**
839
1054
  * Mount the settings section, routes, and announcement.
840
1055
  * @param ctx - host plugin context carrying webServer/systemPrompt.
@@ -893,4 +1108,4 @@ function apply(ctx, config) {
893
1108
  sync();
894
1109
  }
895
1110
  //#endregion
896
- export { Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, generateImage, inject, makeRoutes, name };
1111
+ export { CURRENT_VERSION, Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, checkForUpdate, clearUpdateCache, compareVersions, generateImage, inject, installUpdate, makeRoutes, name, profileFromProcess };
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 a configurable OpenAI-compatible endpoint (gpt-image-2 / gpt-image-1 / dall-e-3), with a settings card for api_url / api_key and a sidebar entry opening a split-pane generation studio.",
4
- "version": "1.0.0",
4
+ "version": "1.0.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -51,6 +51,7 @@
51
51
  "lib",
52
52
  "src",
53
53
  "cordis.patch.yml",
54
+ "docs",
54
55
  "README.md"
55
56
  ],
56
57
  "license": "Apache-2.0",
@@ -63,4 +64,4 @@
63
64
  "watch": "tsdown --watch",
64
65
  "typecheck": "tsc --noEmit"
65
66
  }
66
- }
67
+ }
@@ -13,7 +13,7 @@ import { createPortal } from 'react-dom'
13
13
  import { Button, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
14
14
  import type { ImageGenApi } from './api.ts'
15
15
  import { errorMessage, tt } from './helpers.ts'
16
- import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryEntryInput, HistoryImageRef } from '../protocol.ts'
16
+ import type { GeneratedImage, GenerateMode, GenerateRequest, HistoryEntry, HistoryImageRef, UpdateInfo } from '../protocol.ts'
17
17
  import type { ImageGenConfig, ImageGenScope } from './settings-scope.ts'
18
18
  import css from './panel.module.css'
19
19
 
@@ -124,6 +124,10 @@ export function ImageGenPanel(props: {
124
124
  const [history, setHistory] = useState<HistoryEntry[]>([])
125
125
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
126
126
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
127
+ const [update, setUpdate] = useState<UpdateInfo | null>(null)
128
+ const [updating, setUpdating] = useState(false)
129
+ const [updateMessage, setUpdateMessage] = useState<string | null>(null)
130
+ const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
127
131
  const fileInput = useRef<HTMLInputElement>(null)
128
132
  const elapsed = useElapsed(generating, startedAt)
129
133
 
@@ -137,6 +141,35 @@ export function ImageGenPanel(props: {
137
141
  return () => { disposed = true }
138
142
  }, [api])
139
143
 
144
+ // Release checks are host-mediated and intentionally best-effort: a GitHub
145
+ // outage must never make the image-generation studio unavailable.
146
+ useEffect(() => {
147
+ let disposed = false
148
+ api.updateCheck()
149
+ .then(info => {
150
+ if (!disposed && info.updateAvailable) setUpdate(info)
151
+ })
152
+ .catch(() => { /* update discovery is optional */ })
153
+ return () => { disposed = true }
154
+ }, [api])
155
+
156
+ const applyUpdate = async (): Promise<void> => {
157
+ if (update === null || updating) return
158
+ setUpdating(true)
159
+ setUpdateMessage(null)
160
+ setUpdateResult(null)
161
+ try {
162
+ const result = await api.updateApply(update.latestVersion)
163
+ setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
164
+ setUpdateResult('success')
165
+ } catch {
166
+ setUpdateMessage(tt('update.failed'))
167
+ setUpdateResult('failed')
168
+ } finally {
169
+ setUpdating(false)
170
+ }
171
+ }
172
+
140
173
  /** Read an uploaded reference image into a data URL. */
141
174
  const acceptFile = (file: File | undefined): void => {
142
175
  if (file === undefined) return
@@ -177,6 +210,7 @@ export function ImageGenPanel(props: {
177
210
  n: count,
178
211
  detail,
179
212
  ...mode === 'edit' && refImage !== null ? { image: refImage.dataUrl } : {},
213
+ ...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
180
214
  }
181
215
  setGenerating(true)
182
216
  setError(null)
@@ -186,24 +220,8 @@ export function ImageGenPanel(props: {
186
220
  const result = await api.generate(request)
187
221
  setImages(result.images)
188
222
  setViewingHistoryId(null)
189
- const entry: HistoryEntryInput = {
190
- id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
191
- createdAt: Date.now(),
192
- mode,
193
- model,
194
- prompt: promptText,
195
- size,
196
- quality,
197
- detail,
198
- n: count,
199
- images: result.images,
200
- ...mode === 'edit' && refImage !== null ? { refName: refImage.name } : {},
201
- }
202
- try {
203
- setHistory(await api.historyAppend(entry))
204
- } catch {
205
- // Persisting history is best-effort; the images stay on the canvas.
206
- }
223
+ if (result.history !== undefined) setHistory(result.history)
224
+ if (result.historyError !== undefined) setError(result.historyError)
207
225
  } catch (caught) {
208
226
  setError(errorMessage(caught))
209
227
  } finally {
@@ -308,6 +326,20 @@ export function ImageGenPanel(props: {
308
326
  ? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
309
327
  : <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
310
328
 
329
+ {update !== null ? (
330
+ <div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
331
+ <span className={css.updateText}>
332
+ {updateMessage ?? tt('update.available', { version: update.latestVersion })}
333
+ </span>
334
+ <span className={css.updateActions}>
335
+ <a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
336
+ <Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
337
+ {updating ? tt('update.installing') : tt('update.install')}
338
+ </Button>
339
+ </span>
340
+ </div>
341
+ ) : null}
342
+
311
343
  <div className={css.studio}>
312
344
  {/* ---------------------------------------------------- config sidebar */}
313
345
  <aside className={css.config}>
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, type GenerateRequest, type GenerateResult, type HistoryEntry, type HistoryEntryInput } from '../protocol.ts'
6
+ import { GENERATE_API, HISTORY_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type UpdateInfo } from '../protocol.ts'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -40,6 +40,24 @@ async function readEnvelope<T>(response: Response): Promise<T> {
40
40
 
41
41
  /** The browser half's data entry point. */
42
42
  export class ImageGenApi {
43
+ /** Ask the host to check the latest stable GitHub Release. */
44
+ async updateCheck(): Promise<UpdateInfo> {
45
+ const response = await fetch(UPDATE_API.check, { method: 'POST' })
46
+ const body = await readEnvelope<{ ok: true; update: UpdateInfo }>(response)
47
+ return body.update
48
+ }
49
+
50
+ /** Ask the host to install a previously discovered Release. */
51
+ async updateApply(version: string): Promise<{ updatedVersion: string; restartRequired: boolean }> {
52
+ const response = await fetch(UPDATE_API.apply, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ version }),
56
+ })
57
+ const body = await readEnvelope<{ ok: true; updatedVersion: string; restartRequired: boolean }>(response)
58
+ return { updatedVersion: body.updatedVersion, restartRequired: body.restartRequired }
59
+ }
60
+
43
61
  /** Forward one generate request to the host proxy. */
44
62
  async generate(request: GenerateRequest): Promise<GenerateResult> {
45
63
  const response = await fetch(GENERATE_API, {
@@ -47,8 +65,12 @@ export class ImageGenApi {
47
65
  headers: { 'content-type': 'application/json' },
48
66
  body: JSON.stringify(request),
49
67
  })
50
- const body = await readEnvelope<{ ok: true; images: GenerateResult['images'] }>(response)
51
- return { images: body.images }
68
+ const body = await readEnvelope<{ ok: true; images: GenerateResult['images']; history?: HistoryEntry[]; historyError?: string }>(response)
69
+ return {
70
+ images: body.images,
71
+ ...body.history === undefined ? {} : { history: body.history },
72
+ ...body.historyError === undefined ? {} : { historyError: body.historyError },
73
+ }
52
74
  }
53
75
 
54
76
  /** List the host-persisted history (newest first). */
@@ -58,17 +80,6 @@ export class ImageGenApi {
58
80
  return body.entries
59
81
  }
60
82
 
61
- /** Append one generation to the host-persisted history. */
62
- async historyAppend(entry: HistoryEntryInput): Promise<HistoryEntry[]> {
63
- const response = await fetch(HISTORY_API.append, {
64
- method: 'POST',
65
- headers: { 'content-type': 'application/json' },
66
- body: JSON.stringify({ entry }),
67
- })
68
- const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
69
- return body.entries
70
- }
71
-
72
83
  /** Remove one history entry by id. */
73
84
  async historyRemove(id: string): Promise<HistoryEntry[]> {
74
85
  const response = await fetch(HISTORY_API.remove, {
@@ -76,6 +76,13 @@ export const zh = {
76
76
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
77
77
  'config.configured': '已连接 {url}',
78
78
  'config.disabled': '插件已停用,请在设置中重新启用。',
79
+ // plugin update
80
+ 'update.available': '检测到新版本:{version}',
81
+ 'update.install': '在线更新',
82
+ 'update.installing': '更新中…',
83
+ 'update.success': '已更新到 {version},请重启 DSH',
84
+ 'update.failed': '更新失败,请重试',
85
+ 'update.release': '查看 Release',
79
86
  // settings card
80
87
  'settings.title': 'AI 生图(dsh-imagegen)',
81
88
  'settings.description': '配置图像生成 API 地址与密钥',
@@ -168,9 +175,15 @@ export const en: Record<keyof typeof zh, string> = {
168
175
  'preview.prev': 'Previous',
169
176
  'preview.next': 'Next',
170
177
  'preview.index': '{index} / {total}',
171
- 'config.missing': 'API not configured: open "Settings → Web UI Plugins" and fill in api_url and api_key for AI Image.',
178
+ 'config.missing': 'API not configured: open "Settings → Plugins Configurable" and fill in api_url and api_key for AI Image.',
172
179
  'config.configured': 'Connected to {url}',
173
180
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
181
+ 'update.available': 'A new version is available: {version}',
182
+ 'update.install': 'Update online',
183
+ 'update.installing': 'Updating…',
184
+ 'update.success': 'Updated to {version}; restart DSH to load it',
185
+ 'update.failed': 'Update failed; please try again',
186
+ 'update.release': 'View Release',
174
187
  'settings.title': 'AI Image (dsh-imagegen)',
175
188
  'settings.description': 'Configure the image generation API endpoint and key',
176
189
  'settings.apiUrl': 'API URL (api_url)',