@dickpy/dsh-imagegen 1.0.1 → 1.0.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/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { promises } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import path from "node:path";
7
+ import { spawn } from "node:child_process";
7
8
  //#region src/protocol.ts
8
9
  /**
9
10
  * Wire contract shared by the host and client halves of dsh-imagegen: the
@@ -12,6 +13,8 @@ import path from "node:path";
12
13
  */
13
14
  /** Settings namespace this plugin owns (host settings seam + bridge). */
14
15
  const IMAGEGEN_SETTINGS_NAMESPACE = "dsh-imagegen";
16
+ /** Published package version shared by the host updater and the client UI. */
17
+ const PLUGIN_VERSION = "1.0.3";
15
18
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
16
19
  const SETTINGS_API = {
17
20
  describe: "/api/dsh-imagegen/settings/describe",
@@ -19,6 +22,11 @@ const SETTINGS_API = {
19
22
  };
20
23
  /** The image-generation proxy route. */
21
24
  const GENERATE_API = "/api/dsh-imagegen/generate";
25
+ /** Host-mediated GitHub Release update routes. */
26
+ const UPDATE_API = {
27
+ check: "/api/dsh-imagegen/update/check",
28
+ apply: "/api/dsh-imagegen/update/apply"
29
+ };
22
30
  /**
23
31
  * Same-origin route family for the host-persisted generation history. Images
24
32
  * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
@@ -429,6 +437,106 @@ async function readHistoryImage(file) {
429
437
  }
430
438
  }
431
439
  //#endregion
440
+ //#region src/updater.ts
441
+ /** GitHub Release discovery and explicit, user-triggered plugin updates. */
442
+ /** Keep this in sync with package.json for each published release. */
443
+ const CURRENT_VERSION = PLUGIN_VERSION;
444
+ const PACKAGE_NAME = "@dickpy/dsh-imagegen";
445
+ const RELEASES_URL = "https://api.github.com/repos/dickpy/dsh-imagegen/releases/latest";
446
+ const CHECK_TIMEOUT_MS = 1e4;
447
+ const CACHE_TTL_MS = 15 * 6e4;
448
+ let cached;
449
+ /** Compare stable semver triples; returns positive when `left` is newer. */
450
+ function compareVersions(left, right) {
451
+ const parse = (value) => {
452
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
453
+ if (match === null) return [
454
+ 0,
455
+ 0,
456
+ 0
457
+ ];
458
+ return [
459
+ Number(match[1]),
460
+ Number(match[2]),
461
+ Number(match[3])
462
+ ];
463
+ };
464
+ const a = parse(left);
465
+ const b = parse(right);
466
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
467
+ }
468
+ function normalizedReleaseVersion(tag) {
469
+ if (typeof tag !== "string" || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag.trim())) return void 0;
470
+ return tag.trim().replace(/^v/, "");
471
+ }
472
+ /** Read the latest stable GitHub Release, with a short host-side cache. */
473
+ async function checkForUpdate(fetchFn = fetch, now = Date.now()) {
474
+ if (cached !== void 0 && cached.expiresAt > now) return cached.value;
475
+ const response = await fetchFn(RELEASES_URL, {
476
+ headers: {
477
+ accept: "application/vnd.github+json",
478
+ "user-agent": "dsh-imagegen-update-check"
479
+ },
480
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
481
+ });
482
+ if (!response.ok) throw new Error(`GitHub Releases returned HTTP ${response.status}`);
483
+ const payload = await response.json();
484
+ if (payload === null || typeof payload !== "object") throw new Error("GitHub Releases returned malformed JSON");
485
+ const release = payload;
486
+ if (release.draft === true || release.prerelease === true) throw new Error("latest GitHub Release is not stable");
487
+ const latestVersion = normalizedReleaseVersion(release.tag_name);
488
+ if (latestVersion === void 0) throw new Error("latest GitHub Release has an invalid version tag");
489
+ const releaseUrl = typeof release.html_url === "string" ? release.html_url : "https://github.com/dickpy/dsh-imagegen/releases";
490
+ const value = {
491
+ currentVersion: CURRENT_VERSION,
492
+ latestVersion,
493
+ updateAvailable: compareVersions(latestVersion, CURRENT_VERSION) > 0,
494
+ releaseUrl,
495
+ ...typeof release.published_at === "string" ? { publishedAt: release.published_at } : {}
496
+ };
497
+ cached = {
498
+ expiresAt: now + CACHE_TTL_MS,
499
+ value
500
+ };
501
+ return value;
502
+ }
503
+ /** Resolve the profile that launched the current DSH process. */
504
+ function profileFromProcess(argv = process.argv, env = process.env) {
505
+ const envProfile = env.DSH_PROFILE?.trim();
506
+ if (envProfile !== void 0 && /^[a-zA-Z0-9_-]+$/.test(envProfile)) return envProfile;
507
+ const profileIndex = argv.indexOf("--profile");
508
+ const explicit = profileIndex >= 0 ? argv[profileIndex + 1]?.trim() : void 0;
509
+ if (explicit !== void 0 && /^[a-zA-Z0-9_-]+$/.test(explicit)) return explicit;
510
+ if (argv.includes("web")) return "web";
511
+ return "web";
512
+ }
513
+ /** Run the same official command documented for plugin installation. */
514
+ function installUpdate(version, spawnFn = spawn, argv = process.argv, env = process.env) {
515
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) return Promise.reject(/* @__PURE__ */ new Error("invalid update version"));
516
+ const profile = profileFromProcess(argv, env);
517
+ const child = spawnFn(process.platform === "win32" ? "dsh.cmd" : "dsh", [
518
+ "plugin",
519
+ "--profile",
520
+ profile,
521
+ "add",
522
+ `${PACKAGE_NAME}@${version}`
523
+ ], {
524
+ shell: process.platform === "win32",
525
+ stdio: "ignore"
526
+ });
527
+ return new Promise((resolve, reject) => {
528
+ child.once("error", reject);
529
+ child.once("exit", (code, signal) => {
530
+ if (code === 0) resolve();
531
+ else reject(/* @__PURE__ */ new Error(signal === null ? `plugin update exited with code ${code ?? "unknown"}` : `plugin update terminated by ${signal}`));
532
+ });
533
+ });
534
+ }
535
+ /** Test helper: clear the host-side Release cache. */
536
+ function clearUpdateCache() {
537
+ cached = void 0;
538
+ }
539
+ //#endregion
432
540
  //#region src/routes.ts
433
541
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
434
542
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024;
@@ -727,6 +835,66 @@ function makeRoutes(deps) {
727
835
  }
728
836
  }
729
837
  },
838
+ {
839
+ kind: "exact",
840
+ path: UPDATE_API.check,
841
+ handler: async (req, res) => {
842
+ if (!guard(req, res, "POST")) return;
843
+ try {
844
+ writeJson(res, 200, {
845
+ ok: true,
846
+ update: await checkForUpdate()
847
+ });
848
+ } catch (error) {
849
+ writeJson(res, 200, {
850
+ ok: false,
851
+ code: "update-check-failed",
852
+ message: messageOf(error)
853
+ });
854
+ }
855
+ }
856
+ },
857
+ {
858
+ kind: "exact",
859
+ path: UPDATE_API.apply,
860
+ handler: async (req, res) => {
861
+ if (!guard(req, res, "POST")) return;
862
+ const body = await readJsonBody(req);
863
+ const version = body !== void 0 && typeof body.version === "string" ? body.version.trim() : "";
864
+ if (version === "") {
865
+ writeJson(res, 200, {
866
+ ok: false,
867
+ code: "bad-request",
868
+ message: "update version is required"
869
+ });
870
+ return;
871
+ }
872
+ try {
873
+ const latest = await checkForUpdate();
874
+ if (!latest.updateAvailable || latest.latestVersion !== version) {
875
+ writeJson(res, 200, {
876
+ ok: false,
877
+ code: "update-not-available",
878
+ message: `version ${version} is not the latest available release`
879
+ });
880
+ return;
881
+ }
882
+ await installUpdate(version);
883
+ writeJson(res, 200, {
884
+ ok: true,
885
+ currentVersion: CURRENT_VERSION,
886
+ updatedVersion: version,
887
+ restartRequired: true
888
+ });
889
+ } catch (error) {
890
+ writeJson(res, 200, {
891
+ ok: false,
892
+ code: "update-failed",
893
+ message: messageOf(error)
894
+ });
895
+ }
896
+ }
897
+ },
730
898
  {
731
899
  kind: "exact",
732
900
  path: HISTORY_API.list,
@@ -942,4 +1110,4 @@ function apply(ctx, config) {
942
1110
  sync();
943
1111
  }
944
1112
  //#endregion
945
- export { Config, IMAGEGEN_GUIDANCE, ImageGenError, ImageGenSettingsNamespace, apply, generateImage, inject, makeRoutes, name };
1113
+ 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.1",
4
+ "version": "1.0.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -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, 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
 
@@ -50,6 +50,13 @@ function useConfig(scope: ImageGenScope): ImageGenConfig | undefined {
50
50
  return value
51
51
  }
52
52
 
53
+ /** Track the redacted api-key presence bit exposed by the settings bridge. */
54
+ function useKeySet(scope: ImageGenScope): boolean {
55
+ const [keySet, setKeySet] = useState(scope.getKeySetSnapshot())
56
+ useEffect(() => scope.subscribeKeySet(() => { setKeySet(scope.getKeySetSnapshot()) }), [scope])
57
+ return keySet
58
+ }
59
+
53
60
  /** Tick a seconds counter while `running`. */
54
61
  function useElapsed(running: boolean, startedAt: number | null): number {
55
62
  const [elapsed, setElapsed] = useState(0)
@@ -108,6 +115,8 @@ export function ImageGenPanel(props: {
108
115
  const enabled = config?.enabled ?? true
109
116
  const apiUrl = config?.apiUrl ?? ''
110
117
  const configured = apiUrl.trim() !== ''
118
+ const keySet = useKeySet(scope)
119
+ const connected = enabled && configured && keySet
111
120
 
112
121
  const [mode, setMode] = useState<GenerateMode>('text')
113
122
  const [prompt, setPrompt] = useState('')
@@ -124,6 +133,10 @@ export function ImageGenPanel(props: {
124
133
  const [history, setHistory] = useState<HistoryEntry[]>([])
125
134
  const [viewingHistoryId, setViewingHistoryId] = useState<string | null>(null)
126
135
  const [preview, setPreview] = useState<{ images: GeneratedImage[]; index: number } | null>(null)
136
+ const [update, setUpdate] = useState<UpdateInfo | null>(null)
137
+ const [updating, setUpdating] = useState(false)
138
+ const [updateMessage, setUpdateMessage] = useState<string | null>(null)
139
+ const [updateResult, setUpdateResult] = useState<'success' | 'failed' | null>(null)
127
140
  const fileInput = useRef<HTMLInputElement>(null)
128
141
  const elapsed = useElapsed(generating, startedAt)
129
142
 
@@ -137,6 +150,35 @@ export function ImageGenPanel(props: {
137
150
  return () => { disposed = true }
138
151
  }, [api])
139
152
 
153
+ // Release checks are host-mediated and intentionally best-effort: a GitHub
154
+ // outage must never make the image-generation studio unavailable.
155
+ useEffect(() => {
156
+ let disposed = false
157
+ api.updateCheck()
158
+ .then(info => {
159
+ if (!disposed && info.updateAvailable) setUpdate(info)
160
+ })
161
+ .catch(() => { /* update discovery is optional */ })
162
+ return () => { disposed = true }
163
+ }, [api])
164
+
165
+ const applyUpdate = async (): Promise<void> => {
166
+ if (update === null || updating) return
167
+ setUpdating(true)
168
+ setUpdateMessage(null)
169
+ setUpdateResult(null)
170
+ try {
171
+ const result = await api.updateApply(update.latestVersion)
172
+ setUpdateMessage(tt('update.success', { version: result.updatedVersion }))
173
+ setUpdateResult('success')
174
+ } catch {
175
+ setUpdateMessage(tt('update.failed'))
176
+ setUpdateResult('failed')
177
+ } finally {
178
+ setUpdating(false)
179
+ }
180
+ }
181
+
140
182
  /** Read an uploaded reference image into a data URL. */
141
183
  const acceptFile = (file: File | undefined): void => {
142
184
  if (file === undefined) return
@@ -283,15 +325,34 @@ export function ImageGenPanel(props: {
283
325
  return (
284
326
  <div className={css.panel}>
285
327
  <header className={css.panelHeader}>
286
- <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
287
- <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
328
+ <span className={css.panelHeading}>
329
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
330
+ <span className={css.panelSubtitle}>{tt('panel.subtitle')}</span>
331
+ </span>
332
+ <button
333
+ type="button"
334
+ className={css.connectionStatus}
335
+ data-connected={connected ? 'true' : 'false'}
336
+ aria-label={tt(connected ? 'connection.connected' : 'connection.disconnected')}
337
+ >
338
+ <span className={css.connectionDot} aria-hidden="true" />
339
+ {tt(connected ? 'connection.connected' : 'connection.disconnected')}
340
+ </button>
288
341
  </header>
289
342
 
290
- {!enabled
291
- ? <div className={css.banner} data-kind="warn">{tt('config.disabled')}</div>
292
- : !configured
293
- ? <div className={css.banner} data-kind="warn">{tt('config.missing')}</div>
294
- : <div className={css.banner} data-kind="ok">{tt('config.configured', { url: apiUrl })}</div>}
343
+ {update !== null ? (
344
+ <div className={css.updateBanner} data-kind={updateResult === 'success' ? 'ok' : 'warn'}>
345
+ <span className={css.updateText}>
346
+ {updateMessage ?? tt('update.available', { version: update.latestVersion })}
347
+ </span>
348
+ <span className={css.updateActions}>
349
+ <a className={css.updateRelease} href={update.releaseUrl} target="_blank" rel="noreferrer">{tt('update.release')}</a>
350
+ <Button variant="primary" size="sm" disabled={updating || updateMessage !== null} onClick={() => { void applyUpdate() }}>
351
+ {updating ? tt('update.installing') : tt('update.install')}
352
+ </Button>
353
+ </span>
354
+ </div>
355
+ ) : null}
295
356
 
296
357
  <div className={css.studio}>
297
358
  {/* ---------------------------------------------------- config sidebar */}
@@ -11,6 +11,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
11
11
  import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
12
  import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
13
  import type { ImageGenScope } from './settings-scope.ts'
14
+ import { PLUGIN_VERSION } from '../protocol.ts'
14
15
  import css from './settings-card.module.css'
15
16
 
16
17
  /** The fields this card edits (the namespace's full schema). */
@@ -161,6 +162,10 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
161
162
  ? (
162
163
  <div className={css.body}>
163
164
  {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
165
+ <div className={css.versionRow}>
166
+ <span className={css.versionLabel}>{t('settings.currentVersion')}</span>
167
+ <code className={css.versionValue}>v{PLUGIN_VERSION}</code>
168
+ </div>
164
169
  <ValueField
165
170
  id="dsh-imagegen-settings-apikey"
166
171
  label={t('settings.apiKey')}
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 } 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, {
@@ -76,9 +76,19 @@ export const zh = {
76
76
  'config.missing': '尚未配置 API:请前往「设置 → 插件 → 可配置」为 AI 生图填写 api_url 与 api_key。',
77
77
  'config.configured': '已连接 {url}',
78
78
  'config.disabled': '插件已停用,请在设置中重新启用。',
79
+ 'connection.connected': '已连接',
80
+ 'connection.disconnected': '未连接',
81
+ // plugin update
82
+ 'update.available': '检测到新版本:{version}',
83
+ 'update.install': '在线更新',
84
+ 'update.installing': '更新中…',
85
+ 'update.success': '已更新到 {version},请重启 DSH',
86
+ 'update.failed': '更新失败,请重试',
87
+ 'update.release': '查看 Release',
79
88
  // settings card
80
89
  'settings.title': 'AI 生图(dsh-imagegen)',
81
90
  'settings.description': '配置图像生成 API 地址与密钥',
91
+ 'settings.currentVersion': '当前版本',
82
92
  'settings.apiUrl': 'API 地址(api_url)',
83
93
  'settings.apiUrlHint': 'OpenAI 兼容接口基址,如 https://api.openai.com/v1;将自动拼接 /images/generations 与 /images/edits',
84
94
  'settings.apiKey': 'API 密钥(api_key)',
@@ -171,8 +181,17 @@ export const en: Record<keyof typeof zh, string> = {
171
181
  'config.missing': 'API not configured: open "Settings → Plugins → Configurable" and fill in api_url and api_key for AI Image.',
172
182
  'config.configured': 'Connected to {url}',
173
183
  'config.disabled': 'The plugin is disabled — re-enable it in Settings.',
184
+ 'connection.connected': 'Connected',
185
+ 'connection.disconnected': 'Disconnected',
186
+ 'update.available': 'A new version is available: {version}',
187
+ 'update.install': 'Update online',
188
+ 'update.installing': 'Updating…',
189
+ 'update.success': 'Updated to {version}; restart DSH to load it',
190
+ 'update.failed': 'Update failed; please try again',
191
+ 'update.release': 'View Release',
174
192
  'settings.title': 'AI Image (dsh-imagegen)',
175
193
  'settings.description': 'Configure the image generation API endpoint and key',
194
+ 'settings.currentVersion': 'Current version',
176
195
  'settings.apiUrl': 'API URL (api_url)',
177
196
  'settings.apiUrlHint': 'OpenAI-compatible base URL, e.g. https://api.openai.com/v1; /images/generations and /images/edits are appended',
178
197
  'settings.apiKey': 'API Key (api_key)',
@@ -121,10 +121,18 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
121
121
  }
122
122
 
123
123
  .panelHeader {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 12px;
128
+ flex: none;
129
+ }
130
+
131
+ .panelHeading {
124
132
  display: flex;
125
133
  align-items: baseline;
126
134
  gap: 10px;
127
- flex: none;
135
+ min-width: 0;
128
136
  }
129
137
 
130
138
  .panelTitle {
@@ -143,27 +151,95 @@ html[data-dsh-imagegen-active]:not([data-dsh-taskboard-active]):not([data-dsh-ss
143
151
  text-overflow: ellipsis;
144
152
  }
145
153
 
146
- /* --- banners ------------------------------------------------------------------ */
154
+ /* --- connection status and update notice -------------------------------------- */
155
+
156
+ .connectionStatus {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 6px;
160
+ flex: none;
161
+ height: 28px;
162
+ padding: 0 10px;
163
+ border: 1px solid var(--dsw-alias-label-error);
164
+ border-radius: 8px;
165
+ background: transparent;
166
+ color: var(--dsw-alias-label-error);
167
+ font: inherit;
168
+ font-size: 12px;
169
+ line-height: 1;
170
+ white-space: nowrap;
171
+ }
172
+
173
+ .connectionStatus[data-connected='true'] {
174
+ border-color: var(--dsw-alias-state-success-primary);
175
+ color: var(--dsw-alias-state-success-primary);
176
+ }
177
+
178
+ .connectionDot {
179
+ width: 6px;
180
+ height: 6px;
181
+ border-radius: 50%;
182
+ background: currentColor;
183
+ }
147
184
 
148
- .banner {
185
+ .updateBanner {
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: space-between;
189
+ gap: 12px;
149
190
  flex: none;
150
- padding: 7px 12px;
191
+ padding: 7px 10px 7px 12px;
151
192
  font-size: 12px;
152
193
  line-height: 1.5;
153
194
  border-radius: 10px;
154
- border: 1px solid var(--dsw-alias-border-l2);
155
- color: var(--dsw-alias-label-secondary);
195
+ border: 1px solid var(--dsw-alias-state-warn-primary);
196
+ color: var(--dsw-alias-state-warn-primary);
156
197
  overflow-wrap: anywhere;
157
198
  }
158
199
 
159
- .banner[data-kind='ok'] {
200
+ .updateBanner[data-kind='ok'] {
160
201
  color: var(--dsw-alias-state-success-primary);
161
202
  border-color: var(--dsw-alias-state-success-primary);
162
203
  }
163
204
 
164
- .banner[data-kind='warn'] {
165
- color: var(--dsw-alias-state-warn-primary);
166
- border-color: var(--dsw-alias-state-warn-primary);
205
+ .updateText {
206
+ min-width: 0;
207
+ }
208
+
209
+ .updateActions {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ gap: 10px;
213
+ flex: none;
214
+ }
215
+
216
+ .updateRelease {
217
+ color: inherit;
218
+ text-decoration: underline;
219
+ text-underline-offset: 2px;
220
+ white-space: nowrap;
221
+ }
222
+
223
+ @media (max-width: 700px) {
224
+ .panelHeader {
225
+ align-items: flex-start;
226
+ }
227
+
228
+ .panelHeading {
229
+ align-items: flex-start;
230
+ flex-direction: column;
231
+ gap: 2px;
232
+ }
233
+
234
+ .updateBanner {
235
+ align-items: flex-start;
236
+ flex-direction: column;
237
+ }
238
+
239
+ .updateActions {
240
+ width: 100%;
241
+ justify-content: space-between;
242
+ }
167
243
  }
168
244
 
169
245
  /* --- studio split: left config sidebar (narrow) + right canvas (wide) --------- */
@@ -95,6 +95,32 @@
95
95
  padding-bottom: 8px;
96
96
  }
97
97
 
98
+ .versionRow {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: 12px;
103
+ padding: 12px 0;
104
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
105
+ }
106
+
107
+ .versionLabel {
108
+ font-size: 13px;
109
+ font-weight: 500;
110
+ line-height: 1.5;
111
+ color: var(--dsw-alias-label-primary);
112
+ }
113
+
114
+ .versionValue {
115
+ padding: 1px 8px;
116
+ border-radius: 999px;
117
+ background: var(--dsw-alias-bg-module-platform);
118
+ color: var(--dsw-alias-label-secondary);
119
+ font-family: var(--dsw-font-family-mono, monospace);
120
+ font-size: 12px;
121
+ line-height: 1.5;
122
+ }
123
+
98
124
  /* --- fields (mirror of the official plugin-config fields) --------------------- */
99
125
 
100
126
  .field {
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export const inject = ['webServer', 'systemPrompt']
26
26
  // contract only requires name / inject / Config / apply.
27
27
  export { makeRoutes } from './routes.ts'
28
28
  export { generateImage, ImageGenError } from './engine.ts'
29
+ export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
29
30
 
30
31
  /** The branded settings namespace of this plugin (the card edits it). */
31
32
  export const ImageGenSettingsNamespace = settingsNamespace(IMAGEGEN_SETTINGS_NAMESPACE)
package/src/protocol.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  /** Settings namespace this plugin owns (host settings seam + bridge). */
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
+ /** Published package version shared by the host updater and the client UI. */
11
+ export const PLUGIN_VERSION = '1.0.3'
12
+
10
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
11
14
  export const SETTINGS_API = {
12
15
  describe: '/api/dsh-imagegen/settings/describe',
@@ -16,6 +19,12 @@ export const SETTINGS_API = {
16
19
  /** The image-generation proxy route. */
17
20
  export const GENERATE_API = '/api/dsh-imagegen/generate'
18
21
 
22
+ /** Host-mediated GitHub Release update routes. */
23
+ export const UPDATE_API = {
24
+ check: '/api/dsh-imagegen/update/check',
25
+ apply: '/api/dsh-imagegen/update/apply',
26
+ } as const
27
+
19
28
  /**
20
29
  * Same-origin route family for the host-persisted generation history. Images
21
30
  * live as files under ~/.dsh/dsh-imagegen/images/ and are served back through
@@ -82,6 +91,15 @@ export interface GenerateResult {
82
91
  historyError?: string
83
92
  }
84
93
 
94
+ /** GitHub Release update information shown by the client. */
95
+ export interface UpdateInfo {
96
+ currentVersion: string
97
+ latestVersion: string
98
+ updateAvailable: boolean
99
+ releaseUrl: string
100
+ publishedAt?: string
101
+ }
102
+
85
103
  /** One history image reference as the browser consumes it (a served URL). */
86
104
  export interface HistoryImageRef {
87
105
  /** Same-origin URL: `${HISTORY_API.image}/<file>`. */